Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf44cc0f25 | ||
|
|
31acd33a2e | ||
|
|
cf699d6207 | ||
|
|
f9c71cc076 | ||
|
|
28287a1447 | ||
|
|
abef9ffabf |
+2
-1
@@ -3,7 +3,8 @@ TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
|
|||||||
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
|
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN=
|
TJWATER_MAPBOX_ACCESS_TOKEN=
|
||||||
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||||
TJWATER_GEOSERVER_WORKSPACE=tjwater
|
TJWATER_GEOSERVER_WORKSPACE=tjwater_next
|
||||||
|
TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000
|
||||||
TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
||||||
TJWATER_ENABLE_DEV_PANEL=false
|
TJWATER_ENABLE_DEV_PANEL=false
|
||||||
TJWATER_ENABLE_MSW=false
|
TJWATER_ENABLE_MSW=false
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
|||||||
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
|
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
|
||||||
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
||||||
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
||||||
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 |
|
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 |
|
||||||
|
| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 |
|
||||||
| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
|
| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
|
||||||
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
|
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
|
||||||
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
|
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
|
||||||
@@ -50,7 +51,7 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
|||||||
|
|
||||||
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
|
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
|
||||||
|
|
||||||
Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
|
业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ runtime_config=$(jq -cn \
|
|||||||
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
|
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
|
||||||
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
||||||
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
||||||
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
|
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \
|
||||||
|
--arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \
|
||||||
--arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
|
--arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
|
||||||
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
||||||
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
||||||
@@ -18,6 +19,7 @@ runtime_config=$(jq -cn \
|
|||||||
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
||||||
TJWATER_MAP_URL: $mapUrl,
|
TJWATER_MAP_URL: $mapUrl,
|
||||||
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
||||||
|
TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl,
|
||||||
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
|
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
|
||||||
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
|
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
|
||||||
TJWATER_ENABLE_MSW: $enableMsw
|
TJWATER_ENABLE_MSW: $enableMsw
|
||||||
|
|||||||
@@ -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-*` 类。
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
globalThis.__TJWATER_CONFIG__ = {
|
globalThis.__TJWATER_CONFIG__ = {
|
||||||
TJWATER_AUTH_MODE: "required",
|
TJWATER_AUTH_MODE: "required",
|
||||||
TJWATER_KEYCLOAK_ISSUER: "",
|
TJWATER_KEYCLOAK_ISSUER: "",
|
||||||
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
|
|||||||
expect(runtimeConfig).toMatchObject({
|
expect(runtimeConfig).toMatchObject({
|
||||||
TJWATER_MAP_URL: expect.any(String),
|
TJWATER_MAP_URL: expect.any(String),
|
||||||
TJWATER_GEOSERVER_WORKSPACE: expect.any(String),
|
TJWATER_GEOSERVER_WORKSPACE: expect.any(String),
|
||||||
|
TJWATER_SERVER_API_BASE_URL: expect.any(String),
|
||||||
TJWATER_AGENT_API_BASE_URL: expect.any(String)
|
TJWATER_AGENT_API_BASE_URL: expect.any(String)
|
||||||
});
|
});
|
||||||
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
|
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function createRequest(name: string) {
|
|||||||
toolCallId: "call-1",
|
toolCallId: "call-1",
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
name,
|
name,
|
||||||
params: { items: [{ sensor_id: "MP01", level: "high" }] },
|
params: { items: [{ device_id: "MP01", level: "high" }] },
|
||||||
issuedAt: Date.now(),
|
issuedAt: Date.now(),
|
||||||
expiresAt: Date.now() + 15_000
|
expiresAt: Date.now() + 15_000
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["P1", "P2"],
|
featureIds: ["P1", "P2"],
|
||||||
layer: "geo_pipes_mat",
|
layer: "pipes",
|
||||||
fallbackText: undefined
|
fallbackText: undefined
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -83,7 +83,7 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
|
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["42"],
|
featureIds: ["42"],
|
||||||
layer: "geo_junctions_mat"
|
layer: "junctions"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ describe("toTrustedMapAction", () => {
|
|||||||
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
|
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
featureIds: ["S-1"],
|
featureIds: ["S-1"],
|
||||||
layer: "geo_scada"
|
layer: "scada"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -164,30 +164,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
||||||
locate_junctions: "geo_junctions_mat",
|
locate_junctions: "junctions",
|
||||||
locate_pipes: "geo_pipes_mat",
|
locate_pipes: "pipes",
|
||||||
locate_valves: "geo_valves",
|
locate_valves: "valves",
|
||||||
locate_reservoirs: "geo_reservoirs",
|
locate_reservoirs: "reservoirs",
|
||||||
locate_scada: "geo_scada",
|
locate_scada: "scada",
|
||||||
locate_pumps: "geo_pumps",
|
locate_pumps: "pumps",
|
||||||
locate_tanks: "geo_tanks"
|
locate_tanks: "tanks"
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
|
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
|
||||||
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada"];
|
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada", "pumps", "tanks"];
|
||||||
const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS);
|
const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS);
|
||||||
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
|
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
|
||||||
const ALLOWED_LOCATE_LAYERS = new Set([
|
const ALLOWED_LOCATE_LAYERS = new Set([
|
||||||
...SUPPLY_SOURCE_IDS,
|
...SUPPLY_SOURCE_IDS
|
||||||
"pumps",
|
|
||||||
"tanks",
|
|
||||||
"geo_junctions_mat",
|
|
||||||
"geo_pipes_mat",
|
|
||||||
"geo_valves",
|
|
||||||
"geo_reservoirs",
|
|
||||||
"geo_scada",
|
|
||||||
"geo_pumps",
|
|
||||||
"geo_tanks"
|
|
||||||
]);
|
]);
|
||||||
const WEB_MERCATOR_RADIUS = 6378137;
|
const WEB_MERCATOR_RADIUS = 6378137;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fetchScadaDeviceCollection } from "./scada-client";
|
||||||
|
|
||||||
|
const device = {
|
||||||
|
device_id: "SCADA-1",
|
||||||
|
device_type: "pressure",
|
||||||
|
node_id: "J-1",
|
||||||
|
link_id: null,
|
||||||
|
api_query_id: "pressure-query",
|
||||||
|
transmission_mode: "realtime",
|
||||||
|
transmission_frequency: "2s",
|
||||||
|
reliability: 100,
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
longitude: 121.5,
|
||||||
|
latitude: 30.9
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SCADA API client", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the active project and builds GeoJSON keyed by device_id", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
|
||||||
|
total: 1,
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0
|
||||||
|
}), { status: 200 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
items: [device, { ...device, device_id: "SCADA-NO-LOCATION", longitude: null }],
|
||||||
|
total: 2,
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0
|
||||||
|
}), { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const getAccessToken = vi.fn().mockResolvedValue("access-token");
|
||||||
|
|
||||||
|
const collection = await fetchScadaDeviceCollection(getAccessToken);
|
||||||
|
|
||||||
|
expect(collection.features).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "SCADA-1",
|
||||||
|
geometry: { type: "Point", coordinates: [121.5, 30.9] },
|
||||||
|
properties: expect.objectContaining({ device_id: "SCADA-1", node_id: "J-1" })
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(getAccessToken).toHaveBeenCalledOnce();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const projectHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
||||||
|
const deviceHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
|
||||||
|
expect(projectHeaders.get("Authorization")).toBe("Bearer access-token");
|
||||||
|
expect(deviceHeaders.get("Authorization")).toBe("Bearer access-token");
|
||||||
|
expect(deviceHeaders.get("X-Project-Id")).toBe("project-1");
|
||||||
|
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/scada-devices?limit=1000&offset=0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not query devices when no active project matches the workspace", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
items: [{ project_id: "project-1", gs_workspace: "other", status: "active" }],
|
||||||
|
total: 1,
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0
|
||||||
|
}), { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(fetchScadaDeviceCollection()).rejects.toThrow("未找到GeoServer工作空间");
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { Feature, FeatureCollection, Point } from "geojson";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
|
import { env } from "@/shared/config/env";
|
||||||
|
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
|
||||||
|
|
||||||
|
const projectSchema = z.object({
|
||||||
|
project_id: z.string().min(1),
|
||||||
|
gs_workspace: z.string().min(1),
|
||||||
|
status: z.string()
|
||||||
|
});
|
||||||
|
const projectPageSchema = z.object({
|
||||||
|
items: z.array(projectSchema),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
limit: z.number().int().positive(),
|
||||||
|
offset: z.number().int().nonnegative()
|
||||||
|
});
|
||||||
|
|
||||||
|
const scadaDeviceSchema = z.object({
|
||||||
|
device_id: z.string().trim().min(1),
|
||||||
|
device_type: z.string(),
|
||||||
|
node_id: z.string().nullable(),
|
||||||
|
link_id: z.string().nullable(),
|
||||||
|
api_query_id: z.string().nullable(),
|
||||||
|
transmission_mode: z.string(),
|
||||||
|
transmission_frequency: z.string(),
|
||||||
|
reliability: z.number().int().nullable(),
|
||||||
|
x: z.number().nullable(),
|
||||||
|
y: z.number().nullable(),
|
||||||
|
longitude: z.number().nullable(),
|
||||||
|
latitude: z.number().nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
const scadaDevicePageSchema = z.object({
|
||||||
|
items: z.array(scadaDeviceSchema),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
limit: z.number().int().positive(),
|
||||||
|
offset: z.number().int().nonnegative()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ScadaDevice = z.infer<typeof scadaDeviceSchema>;
|
||||||
|
|
||||||
|
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||||
|
|
||||||
|
export async function fetchScadaDeviceCollection(
|
||||||
|
getAccessToken?: AccessTokenProvider,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<FeatureCollection<Point, ScadaDevice>> {
|
||||||
|
const accessToken = await getAccessToken?.();
|
||||||
|
const projects = projectPageSchema.parse(
|
||||||
|
await requestJson("/api/v1/projects?limit=1000&offset=0", accessToken, undefined, signal)
|
||||||
|
);
|
||||||
|
const project = projects.items.find(
|
||||||
|
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
|
||||||
|
);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的项目`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = scadaDevicePageSchema.parse(
|
||||||
|
await requestJson(
|
||||||
|
"/api/v1/scada-devices?limit=1000&offset=0",
|
||||||
|
accessToken,
|
||||||
|
project.project_id,
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (page.total > page.items.length) {
|
||||||
|
throw new Error(`SCADA设备数量 ${page.total} 超过单次加载上限 ${page.items.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: page.items.flatMap(toScadaFeature)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toScadaFeature(device: ScadaDevice): Array<Feature<Point, ScadaDevice>> {
|
||||||
|
if (device.longitude === null || device.latitude === null) return [];
|
||||||
|
return [{
|
||||||
|
type: "Feature",
|
||||||
|
id: device.device_id,
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [device.longitude, device.latitude]
|
||||||
|
},
|
||||||
|
properties: device
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(
|
||||||
|
path: string,
|
||||||
|
accessToken: string | null | undefined,
|
||||||
|
projectId: string | undefined,
|
||||||
|
signal: AbortSignal | undefined
|
||||||
|
) {
|
||||||
|
const headers = new Headers({ Accept: "application/json" });
|
||||||
|
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
|
if (projectId) headers.set("X-Project-Id", projectId);
|
||||||
|
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { headers, signal });
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||||
|
? String(payload.detail)
|
||||||
|
: `HTTP ${response.status}`;
|
||||||
|
throw new Error(`SCADA API请求失败:${detail}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
@@ -32,7 +32,9 @@ const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
|
|||||||
junctions: "节点",
|
junctions: "节点",
|
||||||
valves: "阀门",
|
valves: "阀门",
|
||||||
reservoirs: "水库",
|
reservoirs: "水库",
|
||||||
scada: "SCADA"
|
scada: "SCADA",
|
||||||
|
pumps: "水泵",
|
||||||
|
tanks: "水箱"
|
||||||
};
|
};
|
||||||
|
|
||||||
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
|
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
|
||||||
@@ -63,7 +65,7 @@ const LABEL_PRESETS: Record<
|
|||||||
unit: "m",
|
unit: "m",
|
||||||
options: [
|
options: [
|
||||||
{ value: "elevation", label: "高程 elevation" },
|
{ value: "elevation", label: "高程 elevation" },
|
||||||
{ value: "demand", label: "需水量 demand" }
|
{ value: "base_demand", label: "基础需水量 base_demand" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
valves: {
|
valves: {
|
||||||
@@ -88,6 +90,9 @@ const ERROR_LABELS: Record<string, string> = {
|
|||||||
MAP_NOT_READY: "地图尚未就绪",
|
MAP_NOT_READY: "地图尚未就绪",
|
||||||
FEATURE_NOT_FOUND: "目标要素不存在",
|
FEATURE_NOT_FOUND: "目标要素不存在",
|
||||||
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
||||||
|
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
|
||||||
|
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
|
||||||
|
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
|
||||||
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
||||||
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
|
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
|
||||||
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
||||||
|
|
||||||
describe("map feature interaction state", () => {
|
describe("map feature interaction state", () => {
|
||||||
const feature = { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" } as const;
|
const feature = { source: "junctions", sourceLayer: "junctions", id: "junction-7" } as const;
|
||||||
|
|
||||||
it("maps selected business features to promoted vector feature references", () => {
|
it("maps selected business features to promoted vector feature references", () => {
|
||||||
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
|
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
|
||||||
@@ -18,7 +18,7 @@ describe("map feature interaction state", () => {
|
|||||||
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||||
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
|
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
|
||||||
expect(map.setFeatureState).toHaveBeenCalledWith(
|
expect(map.setFeatureState).toHaveBeenCalledWith(
|
||||||
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
|
||||||
{ selected: true, hovered: false }
|
{ selected: true, hovered: false }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -27,7 +27,7 @@ describe("map feature interaction state", () => {
|
|||||||
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||||
clearMapFeatureInteractionState(map, feature, "selected");
|
clearMapFeatureInteractionState(map, feature, "selected");
|
||||||
expect(map.removeFeatureState).toHaveBeenCalledWith(
|
expect(map.removeFeatureState).toHaveBeenCalledWith(
|
||||||
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
|
||||||
"selected"
|
"selected"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -38,6 +38,8 @@ describe("map feature interaction state", () => {
|
|||||||
"junctions-hit",
|
"junctions-hit",
|
||||||
"valves-hit",
|
"valves-hit",
|
||||||
"reservoirs-hit",
|
"reservoirs-hit",
|
||||||
|
"pumps-hit",
|
||||||
|
"tanks-hit",
|
||||||
"scada-hit"
|
"scada-hit"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
|
|||||||
|
|
||||||
export type MapFeatureInteractionState = {
|
export type MapFeatureInteractionState = {
|
||||||
source: WaterNetworkSourceId;
|
source: WaterNetworkSourceId;
|
||||||
sourceLayer: string;
|
sourceLayer?: string;
|
||||||
id: string;
|
id: string;
|
||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -25,7 +25,9 @@ export function toMapFeatureReference(
|
|||||||
feature: Pick<DetailFeature, "id" | "layer"> | null
|
feature: Pick<DetailFeature, "id" | "layer"> | null
|
||||||
): FeatureReference | null {
|
): FeatureReference | null {
|
||||||
if (!feature?.id) return null;
|
if (!feature?.id) return null;
|
||||||
return { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
|
return feature.layer === "scada"
|
||||||
|
? { source: feature.layer, id: feature.id }
|
||||||
|
: { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setMapFeatureInteractionState(
|
export function setMapFeatureInteractionState(
|
||||||
@@ -33,10 +35,7 @@ export function setMapFeatureInteractionState(
|
|||||||
feature: FeatureReference,
|
feature: FeatureReference,
|
||||||
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
|
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
|
||||||
) {
|
) {
|
||||||
map.setFeatureState(
|
map.setFeatureState(toFeatureStateTarget(feature), state);
|
||||||
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
|
||||||
state
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearMapFeatureInteractionState(
|
export function clearMapFeatureInteractionState(
|
||||||
@@ -44,13 +43,11 @@ export function clearMapFeatureInteractionState(
|
|||||||
feature: FeatureReference,
|
feature: FeatureReference,
|
||||||
key?: "hovered" | "selected"
|
key?: "hovered" | "selected"
|
||||||
) {
|
) {
|
||||||
map.removeFeatureState(
|
map.removeFeatureState(toFeatureStateTarget(feature), key);
|
||||||
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
|
||||||
key
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseMapInteractionsOptions = {
|
type UseMapInteractionsOptions = {
|
||||||
|
availableSourceIds: readonly WaterNetworkSourceId[];
|
||||||
mapRef: RefObject<MapLibreMap | null>;
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
onSelectFeature: (feature: DetailFeature) => void;
|
onSelectFeature: (feature: DetailFeature) => void;
|
||||||
@@ -58,6 +55,7 @@ type UseMapInteractionsOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useMapInteractions({
|
export function useMapInteractions({
|
||||||
|
availableSourceIds,
|
||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
onSelectFeature,
|
onSelectFeature,
|
||||||
@@ -81,7 +79,9 @@ export function useMapInteractions({
|
|||||||
const id = feature && getFeatureId(feature);
|
const id = feature && getFeatureId(feature);
|
||||||
if (!source || !id) return;
|
if (!source || !id) return;
|
||||||
|
|
||||||
const next = { source, sourceLayer: SOURCE_LAYERS[source], id };
|
const next = source === "scada"
|
||||||
|
? { source, id }
|
||||||
|
: { source, sourceLayer: SOURCE_LAYERS[source], id };
|
||||||
if (selectedFeature?.id === id && selectedFeature.layer === source) {
|
if (selectedFeature?.id === id && selectedFeature.layer === source) {
|
||||||
clearHoveredFeature();
|
clearHoveredFeature();
|
||||||
return;
|
return;
|
||||||
@@ -104,7 +104,7 @@ export function useMapInteractions({
|
|||||||
if (feature) onSelectFeature(toDetailFeature(feature));
|
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("mousemove", hitLayerIds, handleMouseMove);
|
||||||
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
map.on("click", hitLayerIds, handleClick);
|
map.on("click", hitLayerIds, handleClick);
|
||||||
@@ -115,15 +115,25 @@ export function useMapInteractions({
|
|||||||
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
map.off("click", hitLayerIds, handleClick);
|
map.off("click", hitLayerIds, handleClick);
|
||||||
};
|
};
|
||||||
}, [mapRef, mapReady, onSelectFeature, selectedFeature]);
|
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!mapReady || !map) return;
|
if (!mapReady || !map) return;
|
||||||
const next = toMapFeatureReference(selectedFeature);
|
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 () => {
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import type { FeatureCollection, Point } from "geojson";
|
||||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||||
|
import type { ScadaDevice } from "../api/scada-client";
|
||||||
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
|
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
|
||||||
import { getResponsiveWorkbenchPadding } from "../map/camera";
|
import { getResponsiveWorkbenchPadding } from "../map/camera";
|
||||||
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
import { WorkbenchMapController } from "../map/workbench-map-controller";
|
import { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||||
|
|
||||||
export function useWorkbenchMapController({
|
export function useWorkbenchMapController({
|
||||||
@@ -9,7 +12,9 @@ export function useWorkbenchMapController({
|
|||||||
leftPanelOpen,
|
leftPanelOpen,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
conditionPanelExpanded = false,
|
conditionPanelExpanded = false,
|
||||||
agentPanelWidth
|
agentPanelWidth,
|
||||||
|
getScadaFeatures,
|
||||||
|
availableSourceIds
|
||||||
}: {
|
}: {
|
||||||
mapRef: RefObject<MapLibreMap | null>;
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
@@ -17,6 +22,8 @@ export function useWorkbenchMapController({
|
|||||||
rightPanelOpen: boolean;
|
rightPanelOpen: boolean;
|
||||||
conditionPanelExpanded?: boolean;
|
conditionPanelExpanded?: boolean;
|
||||||
agentPanelWidth?: number;
|
agentPanelWidth?: number;
|
||||||
|
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
||||||
|
availableSourceIds?: readonly WaterNetworkSourceId[];
|
||||||
}) {
|
}) {
|
||||||
const valuesRef = useRef({
|
const valuesRef = useRef({
|
||||||
mapReady,
|
mapReady,
|
||||||
@@ -48,7 +55,8 @@ export function useWorkbenchMapController({
|
|||||||
valuesRef.current.agentPanelWidth
|
valuesRef.current.agentPanelWidth
|
||||||
)
|
)
|
||||||
: { top: 48, right: 48, bottom: 48, left: 48 };
|
: { top: 48, right: 48, bottom: 48, left: 48 };
|
||||||
}
|
},
|
||||||
|
getScadaFeatures
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +66,9 @@ export function useWorkbenchMapController({
|
|||||||
controller.getSnapshot,
|
controller.getSnapshot,
|
||||||
controller.getSnapshot
|
controller.getSnapshot
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableSourceIds) controller.syncAvailableSources(availableSourceIds);
|
||||||
|
}, [availableSourceIds, controller]);
|
||||||
useEffect(() => () => controller.destroy(), [controller]);
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
return { controller, state };
|
return { controller, state };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
|
||||||
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
|
import type { FeatureCollection, Point } from "geojson";
|
||||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
import maplibregl, {
|
||||||
|
type GeoJSONSource,
|
||||||
|
type Map as MapLibreMap,
|
||||||
|
type MapSourceDataEvent,
|
||||||
|
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 { env } from "@/shared/config/env";
|
||||||
|
import { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client";
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
import {
|
import {
|
||||||
SIMULATION_SOURCE_IDS,
|
SIMULATION_SOURCE_IDS,
|
||||||
@@ -11,20 +19,30 @@ import {
|
|||||||
} from "../map/annotation-layers";
|
} from "../map/annotation-layers";
|
||||||
import { fitNetworkBounds } from "../map/camera";
|
import { fitNetworkBounds } from "../map/camera";
|
||||||
import {
|
import {
|
||||||
|
filterWaterNetworkLayersBySourceIds,
|
||||||
waterNetworkBusinessLayers,
|
waterNetworkBusinessLayers,
|
||||||
waterNetworkHitLayers,
|
waterNetworkHitLayers,
|
||||||
waterNetworkInteractionLayers
|
waterNetworkInteractionLayers
|
||||||
} from "../map/layers";
|
} 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 { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
|
||||||
import { createValueLabelLayers } from "../map/value-label";
|
import { createValueLabelLayers } from "../map/value-label";
|
||||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||||
import {
|
import {
|
||||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||||
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||||
createBaseStyle,
|
createBaseStyle,
|
||||||
createWaterNetworkSources,
|
createWaterNetworkSources,
|
||||||
WATER_NETWORK_GLOBAL_VIEW
|
WATER_NETWORK_GLOBAL_VIEW,
|
||||||
|
type GeoServerWaterNetworkSourceId,
|
||||||
|
type WaterNetworkSourceId
|
||||||
} from "../map/sources";
|
} from "../map/sources";
|
||||||
import {
|
import {
|
||||||
|
SCADA_SOURCE_ID,
|
||||||
registerScadaImages,
|
registerScadaImages,
|
||||||
scadaBusinessLayers,
|
scadaBusinessLayers,
|
||||||
scadaFallbackBusinessLayers,
|
scadaFallbackBusinessLayers,
|
||||||
@@ -44,8 +62,11 @@ declare global {
|
|||||||
type UseWorkbenchMapOptions = {
|
type UseWorkbenchMapOptions = {
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
impactVisible: boolean;
|
impactVisible: boolean;
|
||||||
|
layerVisibility: Record<string, boolean>;
|
||||||
|
onClearSelection: () => void;
|
||||||
onSelectFeature: (feature: DetailFeature) => void;
|
onSelectFeature: (feature: DetailFeature) => void;
|
||||||
selectedFeature: DetailFeature | null;
|
selectedFeature: DetailFeature | null;
|
||||||
|
getAccessToken?: AccessTokenProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseWorkbenchMapResult = {
|
type UseWorkbenchMapResult = {
|
||||||
@@ -53,6 +74,9 @@ type UseWorkbenchMapResult = {
|
|||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
mapError: string | null;
|
mapError: string | null;
|
||||||
sourceStatuses: WorkbenchSourceStatus[];
|
sourceStatuses: WorkbenchSourceStatus[];
|
||||||
|
availableSourceIds: WaterNetworkSourceId[];
|
||||||
|
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
||||||
|
refreshGeoServerLayers: () => boolean;
|
||||||
fitNetworkBounds: () => void;
|
fitNetworkBounds: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,6 +92,8 @@ export type WorkbenchSourceStatus = {
|
|||||||
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||||
"mapbox-base": "Mapbox 底图",
|
"mapbox-base": "Mapbox 底图",
|
||||||
"geoserver-mvt": "GeoServer MVT",
|
"geoserver-mvt": "GeoServer MVT",
|
||||||
|
"geoserver-catalog": "GeoServer 图层目录",
|
||||||
|
"scada-api": "SCADA API",
|
||||||
"scada-icons": "SCADA 图标",
|
"scada-icons": "SCADA 图标",
|
||||||
"annotation-source": "业务标注源"
|
"annotation-source": "业务标注源"
|
||||||
};
|
};
|
||||||
@@ -76,34 +102,134 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
|||||||
"mapbox-light": "mapbox-base",
|
"mapbox-light": "mapbox-base",
|
||||||
"mapbox-satellite": "mapbox-base",
|
"mapbox-satellite": "mapbox-base",
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
|
||||||
),
|
),
|
||||||
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
||||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GEOSERVER_LAYER_PROBE_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
export function useWorkbenchMap({
|
export function useWorkbenchMap({
|
||||||
containerRef,
|
containerRef,
|
||||||
impactVisible,
|
impactVisible,
|
||||||
|
layerVisibility,
|
||||||
|
onClearSelection,
|
||||||
onSelectFeature,
|
onSelectFeature,
|
||||||
selectedFeature
|
selectedFeature,
|
||||||
|
getAccessToken
|
||||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||||
const mapRef = useRef<MapLibreMap | null>(null);
|
const mapRef = useRef<MapLibreMap | null>(null);
|
||||||
const impactVisibleRef = useRef(impactVisible);
|
const impactVisibleRef = useRef(impactVisible);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const [mapError, setMapError] = useState<string | null>(null);
|
const [mapError, setMapError] = useState<string | null>(null);
|
||||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||||
|
const [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(() => {
|
useEffect(() => {
|
||||||
impactVisibleRef.current = impactVisible;
|
impactVisibleRef.current = impactVisible;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || mapRef.current) {
|
if (!containerRef.current || mapRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMapReady(false);
|
||||||
|
setMapError(null);
|
||||||
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
|
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
|
||||||
|
const scadaAbortController = new AbortController();
|
||||||
|
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
|
||||||
|
const scadaLoadPromise = fetchScadaDeviceCollection(
|
||||||
|
getAccessToken,
|
||||||
|
scadaAbortController.signal
|
||||||
|
);
|
||||||
|
scadaLoadPromiseRef.current = scadaLoadPromise;
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: createBaseStyle(mapboxToken),
|
style: createBaseStyle(mapboxToken),
|
||||||
@@ -118,10 +244,14 @@ export function useWorkbenchMap({
|
|||||||
|
|
||||||
map.on("load", async () => {
|
map.on("load", async () => {
|
||||||
map.resize();
|
map.resize();
|
||||||
const sources = createWaterNetworkSources();
|
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
|
scadaCollectionRef.current
|
||||||
map.addSource(sourceId, sources[sourceId])
|
|
||||||
);
|
);
|
||||||
|
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.impactArea, simulationSources.impactArea);
|
||||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||||
await registerSupplyAssetImages(map);
|
await registerSupplyAssetImages(map);
|
||||||
@@ -133,18 +263,30 @@ export function useWorkbenchMap({
|
|||||||
simulationAnnotationLayers
|
simulationAnnotationLayers
|
||||||
.filter((layer) => layer.id === "simulation-impact-fill")
|
.filter((layer) => layer.id === "simulation-impact-fill")
|
||||||
.forEach((layer) => map.addLayer(layer));
|
.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));
|
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||||
simulationAnnotationLayers
|
simulationAnnotationLayers
|
||||||
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
|
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
|
||||||
.forEach((layer) => map.addLayer(layer));
|
.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));
|
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
|
||||||
createValueLabelLayers().forEach((layer) => map.addLayer(layer));
|
filterWaterNetworkLayersBySourceIds(
|
||||||
|
createValueLabelLayers(),
|
||||||
|
REQUIRED_GEOSERVER_SOURCE_IDS
|
||||||
|
).forEach((layer) => map.addLayer(layer));
|
||||||
simulationAnnotationLayers
|
simulationAnnotationLayers
|
||||||
.filter((layer) => layer.type === "symbol")
|
.filter((layer) => layer.type === "symbol")
|
||||||
.forEach((layer) => map.addLayer(layer));
|
.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));
|
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
||||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||||
setMapReady(true);
|
setMapReady(true);
|
||||||
@@ -166,6 +308,25 @@ export function useWorkbenchMap({
|
|||||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void scadaLoadPromise.then((collection) => {
|
||||||
|
scadaCollectionRef.current = collection;
|
||||||
|
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(collection);
|
||||||
|
updateSourceStatus(
|
||||||
|
setSourceStatuses,
|
||||||
|
"scada-api",
|
||||||
|
"online",
|
||||||
|
`已通过API加载 ${collection.features.length} 个SCADA设备。`
|
||||||
|
);
|
||||||
|
}).catch((error) => {
|
||||||
|
if (scadaAbortController.signal.aborted) return;
|
||||||
|
updateSourceStatus(
|
||||||
|
setSourceStatuses,
|
||||||
|
"scada-api",
|
||||||
|
"offline",
|
||||||
|
error instanceof Error ? error.message : "SCADA API加载失败。"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
let resizeFrame: number | null = null;
|
let resizeFrame: number | null = null;
|
||||||
const resizeMap = () => map.resize();
|
const resizeMap = () => map.resize();
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
@@ -213,13 +374,21 @@ export function useWorkbenchMap({
|
|||||||
window.cancelAnimationFrame(resizeFrame);
|
window.cancelAnimationFrame(resizeFrame);
|
||||||
}
|
}
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
scadaAbortController.abort();
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
delete window.__waterNetworkMap;
|
delete window.__waterNetworkMap;
|
||||||
};
|
};
|
||||||
}, [containerRef]);
|
}, [containerRef, getAccessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) return;
|
||||||
|
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
|
||||||
|
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
|
||||||
|
|
||||||
useMapInteractions({
|
useMapInteractions({
|
||||||
|
availableSourceIds: availableGeoServerSourceIds,
|
||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
onSelectFeature,
|
onSelectFeature,
|
||||||
@@ -244,10 +413,104 @@ export function useWorkbenchMap({
|
|||||||
mapReady,
|
mapReady,
|
||||||
mapError,
|
mapError,
|
||||||
sourceStatuses: Object.values(sourceStatuses),
|
sourceStatuses: Object.values(sourceStatuses),
|
||||||
|
availableSourceIds: AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter(
|
||||||
|
(sourceId) =>
|
||||||
|
sourceId === "scada" ||
|
||||||
|
availableGeoServerSourceIds.includes(sourceId as GeoServerWaterNetworkSourceId)
|
||||||
|
),
|
||||||
|
getScadaFeatures,
|
||||||
|
refreshGeoServerLayers,
|
||||||
fitNetworkBounds: fitToNetworkBounds
|
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(
|
function updateStatusFromSourceEvent(
|
||||||
event: MapSourceDataEvent,
|
event: MapSourceDataEvent,
|
||||||
status: WorkbenchSourceStatusValue,
|
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 } }) {
|
function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { message?: string } }) {
|
||||||
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
|
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
|
||||||
return 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" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
|
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,5 +639,9 @@ function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
|
|||||||
return `业务标注源请求失败:${errorMessage}`;
|
return `业务标注源请求失败:${errorMessage}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourceGroupId === "scada-api") {
|
||||||
|
return `SCADA API请求失败:${errorMessage}`;
|
||||||
|
}
|
||||||
|
|
||||||
return errorMessage;
|
return errorMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
getAgentPanelMaxWidth,
|
getAgentPanelMaxWidth,
|
||||||
getWorkbenchBasemapTone,
|
getWorkbenchBasemapTone,
|
||||||
getWorkbenchCameraPadding,
|
getWorkbenchCameraPadding,
|
||||||
getWorkbenchViewportLayout
|
getWorkbenchViewportLayout,
|
||||||
|
resolveMobileWorkbenchOverlayLayout
|
||||||
} from "./workbench-layout";
|
} from "./workbench-layout";
|
||||||
|
|
||||||
describe("workbench basemap surface tone", () => {
|
describe("workbench basemap surface tone", () => {
|
||||||
@@ -20,6 +21,19 @@ describe("workbench basemap surface tone", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("workbench floating panels", () => {
|
describe("workbench floating panels", () => {
|
||||||
|
it("derives mobile dock and map control offsets from one overlay stack", () => {
|
||||||
|
expect(resolveMobileWorkbenchOverlayLayout(true)).toEqual({
|
||||||
|
timelineBottom: 12,
|
||||||
|
dockBottom: 68,
|
||||||
|
mapControlBottom: 120
|
||||||
|
});
|
||||||
|
expect(resolveMobileWorkbenchOverlayLayout(false)).toEqual({
|
||||||
|
timelineBottom: 12,
|
||||||
|
dockBottom: 12,
|
||||||
|
mapControlBottom: 64
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("uses bounded desktop and wide panel widths", () => {
|
it("uses bounded desktop and wide panel widths", () => {
|
||||||
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
|
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
|
||||||
agentWidth: 500,
|
agentWidth: 500,
|
||||||
|
|||||||
@@ -26,9 +26,29 @@ export const WORKBENCH_LAYOUT = {
|
|||||||
mobileSheet: {
|
mobileSheet: {
|
||||||
halfViewportRatio: 0.52,
|
halfViewportRatio: 0.52,
|
||||||
fullTopGap: 68
|
fullTopGap: 68
|
||||||
|
},
|
||||||
|
mobileOverlay: {
|
||||||
|
edgeInset: 12,
|
||||||
|
gap: 8,
|
||||||
|
dockHeight: 44,
|
||||||
|
timelineSummaryHeight: 48
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
|
||||||
|
const { edgeInset, gap, dockHeight, timelineSummaryHeight } =
|
||||||
|
WORKBENCH_LAYOUT.mobileOverlay;
|
||||||
|
const dockBottom = timelineVisible
|
||||||
|
? edgeInset + timelineSummaryHeight + gap
|
||||||
|
: edgeInset;
|
||||||
|
|
||||||
|
return {
|
||||||
|
timelineBottom: edgeInset,
|
||||||
|
dockBottom,
|
||||||
|
mapControlBottom: dockBottom + dockHeight + gap
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function clampAgentPanelWidth(
|
export function clampAgentPanelWidth(
|
||||||
width: number,
|
width: number,
|
||||||
viewportWidth: number,
|
viewportWidth: number,
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
WORKBENCH_LAYOUT,
|
WORKBENCH_LAYOUT,
|
||||||
getWorkbenchBasemapTone,
|
getWorkbenchBasemapTone,
|
||||||
getWorkbenchLayoutCssVariables
|
getWorkbenchLayoutCssVariables,
|
||||||
|
resolveMobileWorkbenchOverlayLayout
|
||||||
} from "./layout/workbench-layout";
|
} from "./layout/workbench-layout";
|
||||||
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
||||||
import { exportMapViewImage } from "./map/export-view";
|
import { exportMapViewImage } from "./map/export-view";
|
||||||
@@ -155,6 +156,9 @@ export function MapWorkbenchPage({
|
|||||||
const handleSelectFeature = useCallback((feature: DetailFeature) => {
|
const handleSelectFeature = useCallback((feature: DetailFeature) => {
|
||||||
setDetailFeature(feature);
|
setDetailFeature(feature);
|
||||||
}, []);
|
}, []);
|
||||||
|
const handleClearSelection = useCallback(() => {
|
||||||
|
setDetailFeature(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const agent = useWorkbenchAgent({
|
const agent = useWorkbenchAgent({
|
||||||
onUiEnvelope: handleAgentUiEnvelope,
|
onUiEnvelope: handleAgentUiEnvelope,
|
||||||
@@ -220,9 +224,10 @@ export function MapWorkbenchPage({
|
|||||||
timelineMode,
|
timelineMode,
|
||||||
timelineVisible
|
timelineVisible
|
||||||
);
|
);
|
||||||
|
const mobileOverlayLayout = resolveMobileWorkbenchOverlayLayout(timelineVisible);
|
||||||
const timelineBottom = isLargeScreen
|
const timelineBottom = isLargeScreen
|
||||||
? timelineScaleLayout.timelineBottom
|
? timelineScaleLayout.timelineBottom
|
||||||
: 12;
|
: mobileOverlayLayout.timelineBottom;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimelineMode((current) => {
|
setTimelineMode((current) => {
|
||||||
@@ -257,11 +262,23 @@ export function MapWorkbenchPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
const {
|
||||||
|
mapRef,
|
||||||
|
mapReady,
|
||||||
|
mapError,
|
||||||
|
sourceStatuses,
|
||||||
|
availableSourceIds,
|
||||||
|
fitNetworkBounds,
|
||||||
|
getScadaFeatures,
|
||||||
|
refreshGeoServerLayers
|
||||||
|
} = useWorkbenchMap({
|
||||||
containerRef: mapContainerRef,
|
containerRef: mapContainerRef,
|
||||||
impactVisible,
|
impactVisible,
|
||||||
|
layerVisibility,
|
||||||
|
onClearSelection: handleClearSelection,
|
||||||
onSelectFeature: handleSelectFeature,
|
onSelectFeature: handleSelectFeature,
|
||||||
selectedFeature: detailFeature
|
selectedFeature: detailFeature,
|
||||||
|
getAccessToken
|
||||||
});
|
});
|
||||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||||
mapRef,
|
mapRef,
|
||||||
@@ -269,7 +286,9 @@ export function MapWorkbenchPage({
|
|||||||
leftPanelOpen: false,
|
leftPanelOpen: false,
|
||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
conditionPanelExpanded: rightPanelExpanded,
|
conditionPanelExpanded: rightPanelExpanded,
|
||||||
agentPanelWidth
|
agentPanelWidth,
|
||||||
|
getScadaFeatures,
|
||||||
|
availableSourceIds
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeAgentUiResults = useMemo(
|
const activeAgentUiResults = useMemo(
|
||||||
@@ -422,8 +441,8 @@ export function MapWorkbenchPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const layerControlItems = useMemo(
|
const layerControlItems = useMemo(
|
||||||
() => createLayerControlItems(layerVisibility),
|
() => createLayerControlItems(layerVisibility, availableSourceIds),
|
||||||
[layerVisibility]
|
[availableSourceIds, layerVisibility]
|
||||||
);
|
);
|
||||||
function handleToggleLayer(layerControlId: string, visible: boolean) {
|
function handleToggleLayer(layerControlId: string, visible: boolean) {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -488,8 +507,15 @@ export function MapWorkbenchPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!refreshGeoServerLayers()) {
|
||||||
|
showMapNotice({ tone: "warning", message: "业务图层正在检查,请稍候。" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
map.triggerRepaint();
|
map.triggerRepaint();
|
||||||
showMapNotice({ tone: "success", message: "已请求地图重新渲染,业务瓦片将在视图变化时刷新。" });
|
showMapNotice({
|
||||||
|
tone: "success",
|
||||||
|
message: "已开始重新检查业务图层,并请求地图重新渲染。"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShowDataStatus() {
|
function handleShowDataStatus() {
|
||||||
@@ -987,19 +1013,30 @@ export function MapWorkbenchPage({
|
|||||||
onReturnNow={handleReturnOperationalNow}
|
onReturnNow={handleReturnOperationalNow}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div
|
||||||
type="button"
|
role="region"
|
||||||
onClick={openTimelineForViewport}
|
aria-label="移动端运行时间轴摘要"
|
||||||
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="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
|
||||||
>
|
>
|
||||||
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
|
<button
|
||||||
<span className="font-semibold">运行时间轴</span>
|
type="button"
|
||||||
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
|
aria-label="打开完整运行时间轴"
|
||||||
<span className="text-slate-500">执行 {operationalSummary.active}</span>
|
onClick={openTimelineForViewport}
|
||||||
<span className="text-amber-700">关注 {operationalSummary.attention}</span>
|
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"
|
||||||
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
|
>
|
||||||
<span className="ml-auto text-slate-500">查看全天</span>
|
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||||
</button>
|
<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.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
|
</> : null
|
||||||
)}
|
)}
|
||||||
mapContent={(
|
mapContent={(
|
||||||
@@ -1019,10 +1056,13 @@ export function MapWorkbenchPage({
|
|||||||
<ToolbarPanel {...toolbarPanelProps} />
|
<ToolbarPanel {...toolbarPanelProps} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 right-0 flex flex-col items-end gap-2"
|
className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]"
|
||||||
style={{ bottom: timelineScaleLayout.scaleBottom }}
|
style={{
|
||||||
|
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
|
||||||
|
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
|
||||||
|
} as CSSProperties}
|
||||||
>
|
>
|
||||||
<div className="flex items-end gap-2 pr-2">
|
<div className="flex items-end gap-2 lg:pr-2">
|
||||||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||||||
</div>
|
</div>
|
||||||
<MapScaleLine
|
<MapScaleLine
|
||||||
@@ -1077,7 +1117,10 @@ export function MapWorkbenchPage({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
|
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
|
||||||
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
<div
|
||||||
|
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
|
||||||
|
style={{ bottom: mobileOverlayLayout.mapControlBottom }}
|
||||||
|
>
|
||||||
<ToolbarPanel
|
<ToolbarPanel
|
||||||
{...toolbarPanelProps}
|
{...toolbarPanelProps}
|
||||||
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
||||||
@@ -1108,7 +1151,10 @@ export function MapWorkbenchPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className={`absolute z-30 lg:hidden ${timelineVisible ? "bottom-[4.25rem]" : "bottom-3"} ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}>
|
<div
|
||||||
|
className={`absolute z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}
|
||||||
|
style={{ bottom: mobileOverlayLayout.dockBottom }}
|
||||||
|
>
|
||||||
{mobileSheet === null ? (
|
{mobileSheet === null ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -1167,6 +1213,7 @@ export function MapWorkbenchPage({
|
|||||||
events={operationalEvents}
|
events={operationalEvents}
|
||||||
mode="expanded"
|
mode="expanded"
|
||||||
selectedEventId={workspaceContext.operational.selectedEventId}
|
selectedEventId={workspaceContext.operational.selectedEventId}
|
||||||
|
onModeChange={closeMobileSheet}
|
||||||
onSelectEvent={handleSelectOperationalEvent}
|
onSelectEvent={handleSelectOperationalEvent}
|
||||||
onClearSelection={handleClearOperationalSelection}
|
onClearSelection={handleClearOperationalSelection}
|
||||||
onReturnNow={handleReturnOperationalNow}
|
onReturnNow={handleReturnOperationalNow}
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
|
|||||||
});
|
});
|
||||||
if (selectedFeature) {
|
if (selectedFeature) {
|
||||||
exportMap.setFeatureState(
|
exportMap.setFeatureState(
|
||||||
{ source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id },
|
selectedFeature.sourceLayer
|
||||||
|
? { source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id }
|
||||||
|
: { source: selectedFeature.source, id: selectedFeature.id },
|
||||||
{ selected: true, hovered: false }
|
{ selected: true, hovered: false }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import type { MapGeoJSONFeature } from "maplibre-gl";
|
import type { MapGeoJSONFeature } from "maplibre-gl";
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
import { formatValue } from "../utils/format-value";
|
import { formatValue } from "../utils/format-value";
|
||||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
import {
|
||||||
|
SOURCE_LAYERS,
|
||||||
|
type GeoServerWaterNetworkSourceId,
|
||||||
|
type WaterNetworkSourceId
|
||||||
|
} from "./sources";
|
||||||
|
|
||||||
export function getFeatureId(feature: MapGeoJSONFeature) {
|
export function getFeatureId(feature: MapGeoJSONFeature) {
|
||||||
const raw = feature.properties?.id ?? feature.id;
|
const raw = feature.properties?.device_id ?? feature.properties?.id ?? feature.id;
|
||||||
return raw === undefined || raw === null ? "" : String(raw);
|
return raw === undefined || raw === null ? "" : String(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null {
|
export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null {
|
||||||
for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [WaterNetworkSourceId, string][]) {
|
for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [GeoServerWaterNetworkSourceId, string][]) {
|
||||||
if (feature.source === sourceId || feature.sourceLayer === sourceLayer) {
|
if (feature.source === sourceId || feature.sourceLayer === sourceLayer) {
|
||||||
return sourceId;
|
return sourceId;
|
||||||
}
|
}
|
||||||
@@ -43,26 +47,41 @@ function getFeatureLabels(sourceId: WaterNetworkSourceId, id: string, properties
|
|||||||
if (sourceId === "valves") {
|
if (sourceId === "valves") {
|
||||||
return {
|
return {
|
||||||
title: `阀门 ${id || "未命名"}`,
|
title: `阀门 ${id || "未命名"}`,
|
||||||
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.v_type)}`
|
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.valve_type)}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceId === "reservoirs") {
|
if (sourceId === "reservoirs") {
|
||||||
return {
|
return {
|
||||||
title: `水库 ${id || "未命名"}`,
|
title: `水库 ${id || "未命名"}`,
|
||||||
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern)}`
|
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern_id)}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceId === "scada") {
|
if (sourceId === "scada") {
|
||||||
|
const associatedElementId = properties.node_id ?? properties.link_id;
|
||||||
return {
|
return {
|
||||||
title: `SCADA ${id || "未命名"}`,
|
title: `SCADA ${id || "未命名"}`,
|
||||||
subtitle: `${formatValue(properties.type)} · 关联 ${formatValue(properties.associated_element_id)}`
|
subtitle: `${formatValue(properties.device_type)} · 关联 ${formatValue(associatedElementId)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceId === "pumps") {
|
||||||
|
return {
|
||||||
|
title: `水泵 ${id || "未命名"}`,
|
||||||
|
subtitle: `${formatValue(properties.start_node_id)} → ${formatValue(properties.end_node_id)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceId === "tanks") {
|
||||||
|
return {
|
||||||
|
title: `水箱 ${id || "未命名"}`,
|
||||||
|
subtitle: `初始水位 ${formatValue(properties.initial_level)} · 高程 ${formatValue(properties.elevation)} m`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `节点 ${id || "未命名"}`,
|
title: `节点 ${id || "未命名"}`,
|
||||||
subtitle: `需水量 ${formatValue(properties.demand)} · 高程 ${formatValue(properties.elevation)} m`
|
subtitle: `基础需水量 ${formatValue(properties.base_demand)} · 高程 ${formatValue(properties.elevation)} m`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ describe("flow overlay", () => {
|
|||||||
|
|
||||||
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
||||||
source: "pipes",
|
source: "pipes",
|
||||||
"source-layer": "geo_pipes_mat",
|
"source-layer": "pipes",
|
||||||
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
|
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
|
||||||
});
|
});
|
||||||
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
|
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
|
||||||
source: "pipes",
|
source: "pipes",
|
||||||
"source-layer": "geo_pipes_mat",
|
"source-layer": "pipes",
|
||||||
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
|
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
|
||||||
});
|
});
|
||||||
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
||||||
@@ -56,7 +56,7 @@ describe("flow overlay", () => {
|
|||||||
|
|
||||||
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
|
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
|
||||||
source: "pipes",
|
source: "pipes",
|
||||||
sourceLayer: "geo_pipes_mat",
|
sourceLayer: "pipes",
|
||||||
id: "P-1"
|
id: "P-1"
|
||||||
}, {
|
}, {
|
||||||
flowDirection: -1
|
flowDirection: -1
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +38,14 @@ describe("supply network layer styling", () => {
|
|||||||
minzoom: 8.5,
|
minzoom: 8.5,
|
||||||
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
|
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
|
||||||
});
|
});
|
||||||
|
expect(getLayer("pumps-symbol")).toMatchObject({
|
||||||
|
minzoom: 11,
|
||||||
|
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.pump }
|
||||||
|
});
|
||||||
|
expect(getLayer("tanks-symbol")).toMatchObject({
|
||||||
|
minzoom: 11,
|
||||||
|
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank }
|
||||||
|
});
|
||||||
expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
|
expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
|
||||||
|
|
||||||
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
|
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
|
||||||
@@ -47,7 +59,20 @@ describe("supply network layer styling", () => {
|
|||||||
const ids = waterNetworkLayers.map((layer) => layer.id);
|
const ids = waterNetworkLayers.map((layer) => layer.id);
|
||||||
expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover"));
|
expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover"));
|
||||||
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
|
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
|
||||||
expect(ids.at(-1)).toBe("reservoirs-hit");
|
expect(ids.at(-1)).toBe("tanks-hit");
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
|||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import { SCADA_HIT_LAYER_ID } from "./scada";
|
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 { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
||||||
import {
|
import {
|
||||||
createSupplyActiveOpacity,
|
createSupplyActiveOpacity,
|
||||||
@@ -22,6 +25,8 @@ export const INTERACTIVE_HIT_LAYER_IDS = [
|
|||||||
"junctions-hit",
|
"junctions-hit",
|
||||||
"valves-hit",
|
"valves-hit",
|
||||||
"reservoirs-hit",
|
"reservoirs-hit",
|
||||||
|
"pumps-hit",
|
||||||
|
"tanks-hit",
|
||||||
SCADA_HIT_LAYER_ID
|
SCADA_HIT_LAYER_ID
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -208,6 +213,38 @@ layers.push(
|
|||||||
paint: {
|
paint: {
|
||||||
"icon-opacity": supplyIconOpacityExpression
|
"icon-opacity": supplyIconOpacityExpression
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pumps-symbol",
|
||||||
|
type: "symbol",
|
||||||
|
source: "pumps",
|
||||||
|
"source-layer": SOURCE_LAYERS.pumps,
|
||||||
|
minzoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
|
||||||
|
layout: {
|
||||||
|
"icon-image": SUPPLY_ASSET_IMAGE_IDS.pump,
|
||||||
|
"icon-size": SUPPLY_LAYER_VISUALS.pumps.iconSize,
|
||||||
|
"icon-allow-overlap": true,
|
||||||
|
"icon-ignore-placement": true
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"icon-opacity": supplyIconOpacityExpression
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tanks-symbol",
|
||||||
|
type: "symbol",
|
||||||
|
source: "tanks",
|
||||||
|
"source-layer": SOURCE_LAYERS.tanks,
|
||||||
|
minzoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
|
||||||
|
layout: {
|
||||||
|
"icon-image": SUPPLY_ASSET_IMAGE_IDS.tank,
|
||||||
|
"icon-size": SUPPLY_LAYER_VISUALS.tanks.iconSize,
|
||||||
|
"icon-allow-overlap": true,
|
||||||
|
"icon-ignore-placement": true
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"icon-opacity": supplyIconOpacityExpression
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -295,7 +332,9 @@ lineInteractions.forEach(([prefix, source, sourceLayer, visual, color, dash]) =>
|
|||||||
const pointInteractions = [
|
const pointInteractions = [
|
||||||
["junctions", "junctions", SOURCE_LAYERS.junctions, SUPPLY_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.supply.junction],
|
["junctions", "junctions", SOURCE_LAYERS.junctions, SUPPLY_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.supply.junction],
|
||||||
["valves", "valves", SOURCE_LAYERS.valves, SUPPLY_LAYER_VISUALS.valves, MAP_STYLE_TOKENS.supply.valve],
|
["valves", "valves", SOURCE_LAYERS.valves, SUPPLY_LAYER_VISUALS.valves, MAP_STYLE_TOKENS.supply.valve],
|
||||||
["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir]
|
["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir],
|
||||||
|
["pumps", "pumps", SOURCE_LAYERS.pumps, SUPPLY_LAYER_VISUALS.pumps, MAP_STYLE_TOKENS.supply.pump],
|
||||||
|
["tanks", "tanks", SOURCE_LAYERS.tanks, SUPPLY_LAYER_VISUALS.tanks, MAP_STYLE_TOKENS.supply.tank]
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
|
pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
|
||||||
@@ -403,6 +442,30 @@ layers.push(
|
|||||||
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.hitRadius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.hitRadius,
|
||||||
"circle-opacity": 0
|
"circle-opacity": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pumps-hit",
|
||||||
|
type: "circle",
|
||||||
|
source: "pumps",
|
||||||
|
"source-layer": SOURCE_LAYERS.pumps,
|
||||||
|
minzoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
|
||||||
|
paint: {
|
||||||
|
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
|
||||||
|
"circle-radius": SUPPLY_LAYER_VISUALS.pumps.hitRadius,
|
||||||
|
"circle-opacity": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tanks-hit",
|
||||||
|
type: "circle",
|
||||||
|
source: "tanks",
|
||||||
|
"source-layer": SOURCE_LAYERS.tanks,
|
||||||
|
minzoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
|
||||||
|
paint: {
|
||||||
|
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
|
||||||
|
"circle-radius": SUPPLY_LAYER_VISUALS.tanks.hitRadius,
|
||||||
|
"circle-opacity": 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -415,3 +478,13 @@ export const waterNetworkBusinessLayers = layers.slice(0, interactionIndex) as L
|
|||||||
export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[];
|
export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[];
|
||||||
export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[];
|
export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[];
|
||||||
export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline";
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const MAP_STYLE_TOKENS = {
|
|||||||
junction: "#47787B",
|
junction: "#47787B",
|
||||||
valve: "#6B7280",
|
valve: "#6B7280",
|
||||||
reservoir: "#195F84",
|
reservoir: "#195F84",
|
||||||
|
pump: "#326B87",
|
||||||
|
tank: "#4F6F7A",
|
||||||
scadaFlow: "#007A9E",
|
scadaFlow: "#007A9E",
|
||||||
scadaPressure: "#9A6700"
|
scadaPressure: "#9A6700"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,16 +19,37 @@ describe("workbench supply layer controls", () => {
|
|||||||
"valves",
|
"valves",
|
||||||
"reservoirs",
|
"reservoirs",
|
||||||
"scada",
|
"scada",
|
||||||
|
"pumps",
|
||||||
|
"tanks",
|
||||||
"simulation"
|
"simulation"
|
||||||
]);
|
]);
|
||||||
expect(items.map((item) => item.id)).not.toContain("pumps");
|
expect(items.slice(0, 7).map((item) => item.description)).toEqual([
|
||||||
expect(items.map((item) => item.id)).not.toContain("tanks");
|
"tjwater_next:pipes",
|
||||||
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
|
"tjwater_next:junctions",
|
||||||
"tjwater:geo_pipes_mat",
|
"tjwater_next:valves",
|
||||||
"tjwater:geo_junctions_mat",
|
"tjwater_next:reservoirs",
|
||||||
"tjwater:geo_valves",
|
"后端业务 API",
|
||||||
"tjwater:geo_reservoirs",
|
"tjwater_next:pumps",
|
||||||
"tjwater:geo_scada"
|
"tjwater_next:tanks"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
"junction",
|
||||||
"valve",
|
"valve",
|
||||||
"reservoir",
|
"reservoir",
|
||||||
|
"pump",
|
||||||
|
"tank",
|
||||||
"scada-flow",
|
"scada-flow",
|
||||||
"scada-pressure",
|
"scada-pressure",
|
||||||
"impact-area"
|
"impact-area"
|
||||||
@@ -50,6 +73,8 @@ describe("workbench supply layer controls", () => {
|
|||||||
label: "水库",
|
label: "水库",
|
||||||
imageSrc: "/map/reservoir.png"
|
imageSrc: "/map/reservoir.png"
|
||||||
});
|
});
|
||||||
|
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({ imageSrc: "/map/pump.png" });
|
||||||
|
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "tank")).toMatchObject({ imageSrc: "/map/tank.png" });
|
||||||
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({ imageSrc: "/map/scada-flow.png" });
|
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({ imageSrc: "/map/scada-flow.png" });
|
||||||
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
|
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
|
||||||
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
|
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
|
||||||
@@ -82,5 +107,12 @@ describe("workbench supply layer controls", () => {
|
|||||||
"reservoirs-selected",
|
"reservoirs-selected",
|
||||||
"reservoirs-hit"
|
"reservoirs-hit"
|
||||||
]);
|
]);
|
||||||
|
expect(getWorkbenchLayerIds(map, "pumps")).toEqual([
|
||||||
|
"pumps-symbol",
|
||||||
|
"pumps-hover",
|
||||||
|
"pumps-selected-outer",
|
||||||
|
"pumps-selected",
|
||||||
|
"pumps-hit"
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ const SOURCE_CONTROL_LABELS = Object.fromEntries(
|
|||||||
layer.id,
|
layer.id,
|
||||||
{
|
{
|
||||||
label: layer.label,
|
label: layer.label,
|
||||||
description: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
|
description: layer.id === "scada"
|
||||||
|
? "后端业务 API"
|
||||||
|
: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
) as Record<WaterNetworkSourceId, { label: string; description: string }>;
|
) as Record<WaterNetworkSourceId, { label: string; description: string }>;
|
||||||
@@ -48,6 +50,8 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
|
|||||||
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot" },
|
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot" },
|
||||||
{ id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve },
|
{ id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve },
|
||||||
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir },
|
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir },
|
||||||
|
{ id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump },
|
||||||
|
{ id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank },
|
||||||
{ id: "scada-flow", label: "SCADA 流量", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow },
|
{ id: "scada-flow", label: "SCADA 流量", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow },
|
||||||
{ id: "scada-pressure", label: "SCADA 压力", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure },
|
{ id: "scada-pressure", label: "SCADA 压力", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure },
|
||||||
{ id: "impact-area", label: "影响范围", color: MAP_STYLE_TOKENS.state.incident, shape: "square" }
|
{ id: "impact-area", label: "影响范围", color: MAP_STYLE_TOKENS.state.incident, shape: "square" }
|
||||||
@@ -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 [
|
return [
|
||||||
...AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => ({
|
...availableSourceIds.map((sourceId) => ({
|
||||||
id: sourceId,
|
id: sourceId,
|
||||||
...SOURCE_CONTROL_LABELS[sourceId],
|
...SOURCE_CONTROL_LABELS[sourceId],
|
||||||
visible: layerVisibility[sourceId]
|
visible: layerVisibility[sourceId]
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ describe("map feature WFS query", () => {
|
|||||||
junctions: "id",
|
junctions: "id",
|
||||||
valves: "id",
|
valves: "id",
|
||||||
reservoirs: "id",
|
reservoirs: "id",
|
||||||
scada: "id",
|
|
||||||
pumps: "id",
|
pumps: "id",
|
||||||
tanks: "id"
|
tanks: "id"
|
||||||
});
|
});
|
||||||
@@ -23,18 +22,21 @@ describe("map feature WFS query", () => {
|
|||||||
.toBe("\"id\" IN ('J-1')");
|
.toBe("\"id\" IN ('J-1')");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("escapes CQL literals and uses materialized WFS layers", () => {
|
it("escapes CQL literals and uses the new materialized WFS layers", () => {
|
||||||
expect(escapeCqlLiteral("a'b")).toBe("'a''b'");
|
expect(escapeCqlLiteral("a'b")).toBe("'a''b'");
|
||||||
const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] });
|
const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] });
|
||||||
expect(url.searchParams.get("typeNames")).toContain("geo_pipes_mat");
|
expect(url.searchParams.get("typeNames")).toContain("tjwater_next:pipes");
|
||||||
expect(url.searchParams.get("srsName")).toBe("EPSG:4326");
|
expect(url.searchParams.get("srsName")).toBe("EPSG:4326");
|
||||||
expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')");
|
expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the authoritative supply asset WFS layers", () => {
|
it("uses WFS only for authoritative supply asset layers", () => {
|
||||||
expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("geo_valves");
|
expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:valves");
|
||||||
expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("geo_reservoirs");
|
expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:reservoirs");
|
||||||
expect(createMapFeatureWfsUrl({ sourceId: "scada", featureIds: ["S-1"] }).searchParams.get("typeNames")).toContain("geo_scada");
|
expect(parseMapFeatureQuery({ sourceId: "scada", featureIds: ["S-1"] })).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: "UNSUPPORTED_SOURCE"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("limits ids and permits full-network requests only for pipes", () => {
|
it("limits ids and permits full-network requests only for pipes", () => {
|
||||||
@@ -42,8 +44,8 @@ describe("map feature WFS query", () => {
|
|||||||
expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_PIPE_FEATURES));
|
expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_PIPE_FEATURES));
|
||||||
expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
|
expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
|
||||||
expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 });
|
expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 });
|
||||||
expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toMatchObject({ ok: false, status: 422 });
|
expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toEqual({ ok: true, value: { sourceId: "pumps", featureIds: ["P-1"] } });
|
||||||
expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toMatchObject({ ok: false, status: 422 });
|
expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toEqual({ ok: true, value: { sourceId: "tanks", featureIds: ["T-1"] } });
|
||||||
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) }))
|
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) }))
|
||||||
.toMatchObject({ ok: false, status: 400 });
|
.toMatchObject({ ok: false, status: 400 });
|
||||||
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: ["x".repeat(129)] }))
|
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: ["x".repeat(129)] }))
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import type { FeatureCollection } from "geojson";
|
|||||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||||
import {
|
import {
|
||||||
SOURCE_LAYERS,
|
SOURCE_LAYERS,
|
||||||
isAvailableWaterNetworkSourceId,
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||||
type WaterNetworkSourceId
|
type GeoServerWaterNetworkSourceId
|
||||||
} from "./sources";
|
} from "./sources";
|
||||||
|
|
||||||
export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id"> = {
|
export const MAP_FEATURE_ID_FIELDS: Record<GeoServerWaterNetworkSourceId, "id"> = {
|
||||||
pipes: "id",
|
pipes: "id",
|
||||||
junctions: "id",
|
junctions: "id",
|
||||||
valves: "id",
|
valves: "id",
|
||||||
reservoirs: "id",
|
reservoirs: "id",
|
||||||
scada: "id",
|
|
||||||
pumps: "id",
|
pumps: "id",
|
||||||
tanks: "id"
|
tanks: "id"
|
||||||
};
|
};
|
||||||
@@ -21,7 +20,7 @@ export const MAX_MAP_FEATURE_ID_LENGTH = 128;
|
|||||||
export const MAX_PIPE_FEATURES = 5000;
|
export const MAX_PIPE_FEATURES = 5000;
|
||||||
|
|
||||||
export type MapFeatureQuery = {
|
export type MapFeatureQuery = {
|
||||||
sourceId: WaterNetworkSourceId;
|
sourceId: GeoServerWaterNetworkSourceId;
|
||||||
featureIds?: string[];
|
featureIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,7 +34,10 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown };
|
const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown };
|
||||||
if (typeof sourceId !== "string" || !(sourceId in SOURCE_LAYERS) || !isAvailableWaterNetworkSourceId(sourceId)) {
|
if (
|
||||||
|
typeof sourceId !== "string" ||
|
||||||
|
!(GEOSERVER_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(sourceId)
|
||||||
|
) {
|
||||||
return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 };
|
return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { sourceId: sourceId as WaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
|
value: { sourceId: sourceId as GeoServerWaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,19 +56,19 @@ describe("SCADA analysis overlay", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("validates one to one hundred unique IDs and fixed levels", () => {
|
it("validates one to one hundred unique IDs and fixed levels", () => {
|
||||||
expect(parseScadaAnalysisItems([{ sensor_id: " MP01 ", level: "high" }])).toEqual([
|
expect(parseScadaAnalysisItems([{ device_id: " MP01 ", level: "high" }])).toEqual([
|
||||||
{ sensor_id: "MP01", level: "high" }
|
{ device_id: "MP01", level: "high" }
|
||||||
]);
|
]);
|
||||||
expect(parseScadaAnalysisItems([])).toBeNull();
|
expect(parseScadaAnalysisItems([])).toBeNull();
|
||||||
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }])).toBeNull();
|
expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }])).toBeNull();
|
||||||
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "critical" }])).toBeNull();
|
expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "critical" }])).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses trusted WFS sensor IDs for localized labels and reports partial misses", () => {
|
it("uses trusted API device IDs for localized labels and reports partial misses", () => {
|
||||||
const result = createScadaAnalysisCollection(scadaCollection, [
|
const result = createScadaAnalysisCollection(scadaCollection, [
|
||||||
{ sensor_id: "MP01", level: "high" },
|
{ device_id: "MP01", level: "high" },
|
||||||
{ sensor_id: "MP02", level: "medium" },
|
{ device_id: "MP02", level: "medium" },
|
||||||
{ sensor_id: "MP404", level: "low" }
|
{ device_id: "MP404", level: "low" }
|
||||||
]);
|
]);
|
||||||
expect(result.result).toEqual({
|
expect(result.result).toEqual({
|
||||||
rendered_ids: ["MP01", "MP02"],
|
rendered_ids: ["MP01", "MP02"],
|
||||||
@@ -76,8 +76,8 @@ describe("SCADA analysis overlay", () => {
|
|||||||
level_counts: { high: 1, medium: 1, low: 0, unrated: 0 }
|
level_counts: { high: 1, medium: 1, low: 0, unrated: 0 }
|
||||||
});
|
});
|
||||||
expect(result.collection.features.map((feature) => feature.properties)).toEqual([
|
expect(result.collection.features.map((feature) => feature.properties)).toEqual([
|
||||||
expect.objectContaining({ sensor_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
|
expect.objectContaining({ device_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
|
||||||
expect.objectContaining({ sensor_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
|
expect.objectContaining({ device_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -85,7 +85,7 @@ describe("SCADA analysis overlay", () => {
|
|||||||
const scadaCollection: FeatureCollection = {
|
const scadaCollection: FeatureCollection = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: [
|
features: [
|
||||||
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } },
|
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { device_id: "MP01" } },
|
||||||
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } }
|
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { device_id: "MP02" } }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const SCADA_ANALYSIS_LAYER_IDS = {
|
|||||||
|
|
||||||
export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const;
|
export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const;
|
||||||
export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number];
|
export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number];
|
||||||
export type ScadaAnalysisItem = { sensor_id: string; level: ScadaAnalysisLevel };
|
export type ScadaAnalysisItem = { device_id: string; level: ScadaAnalysisLevel };
|
||||||
export type ScadaAnalysisLevelCounts = Record<ScadaAnalysisLevel, number>;
|
export type ScadaAnalysisLevelCounts = Record<ScadaAnalysisLevel, number>;
|
||||||
export type ScadaAnalysisRenderResult = {
|
export type ScadaAnalysisRenderResult = {
|
||||||
rendered_ids: string[];
|
rendered_ids: string[];
|
||||||
@@ -145,17 +145,17 @@ export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | n
|
|||||||
for (const entry of value) {
|
for (const entry of value) {
|
||||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
|
||||||
const keys = Object.keys(entry);
|
const keys = Object.keys(entry);
|
||||||
if (keys.length !== 2 || !keys.includes("sensor_id") || !keys.includes("level")) return null;
|
if (keys.length !== 2 || !keys.includes("device_id") || !keys.includes("level")) return null;
|
||||||
const { sensor_id: rawSensorId, level } = entry as Record<string, unknown>;
|
const { device_id: rawDeviceId, level } = entry as Record<string, unknown>;
|
||||||
const sensorId = typeof rawSensorId === "string" ? rawSensorId.trim() : "";
|
const deviceId = typeof rawDeviceId === "string" ? rawDeviceId.trim() : "";
|
||||||
if (
|
if (
|
||||||
!sensorId ||
|
!deviceId ||
|
||||||
sensorId.length > 128 ||
|
deviceId.length > 128 ||
|
||||||
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
|
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
|
||||||
seen.has(sensorId)
|
seen.has(deviceId)
|
||||||
) return null;
|
) return null;
|
||||||
seen.add(sensorId);
|
seen.add(deviceId);
|
||||||
items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel });
|
items.push({ device_id: deviceId, level: level as ScadaAnalysisLevel });
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -164,13 +164,13 @@ export function createScadaAnalysisCollection(
|
|||||||
collection: FeatureCollection,
|
collection: FeatureCollection,
|
||||||
items: ScadaAnalysisItem[]
|
items: ScadaAnalysisItem[]
|
||||||
): { collection: FeatureCollection<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } {
|
): { collection: FeatureCollection<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } {
|
||||||
const featuresBySensorId = new Map<string, Feature<Point>>();
|
const featuresByDeviceId = new Map<string, Feature<Point>>();
|
||||||
collection.features.forEach((feature) => {
|
collection.features.forEach((feature) => {
|
||||||
const sensorId = typeof feature.properties?.sensor_id === "string"
|
const deviceId = typeof feature.properties?.device_id === "string"
|
||||||
? feature.properties.sensor_id.trim()
|
? feature.properties.device_id.trim()
|
||||||
: "";
|
: "";
|
||||||
if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) {
|
if (deviceId && feature.geometry?.type === "Point" && !featuresByDeviceId.has(deviceId)) {
|
||||||
featuresBySensorId.set(sensorId, feature as Feature<Point>);
|
featuresByDeviceId.set(deviceId, feature as Feature<Point>);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,26 +179,26 @@ export function createScadaAnalysisCollection(
|
|||||||
const missingIds: string[] = [];
|
const missingIds: string[] = [];
|
||||||
const features: Array<Feature<Point>> = [];
|
const features: Array<Feature<Point>> = [];
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const feature = featuresBySensorId.get(item.sensor_id);
|
const feature = featuresByDeviceId.get(item.device_id);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
missingIds.push(item.sensor_id);
|
missingIds.push(item.device_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim();
|
const trustedDeviceId = String(feature.properties?.device_id ?? "").trim();
|
||||||
if (!trustedSensorId) {
|
if (!trustedDeviceId) {
|
||||||
missingIds.push(item.sensor_id);
|
missingIds.push(item.device_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderedIds.push(trustedSensorId);
|
renderedIds.push(trustedDeviceId);
|
||||||
levelCounts[item.level] += 1;
|
levelCounts[item.level] += 1;
|
||||||
features.push({
|
features.push({
|
||||||
...feature,
|
...feature,
|
||||||
properties: {
|
properties: {
|
||||||
...feature.properties,
|
...feature.properties,
|
||||||
sensor_id: trustedSensorId,
|
device_id: trustedDeviceId,
|
||||||
analysis_level: item.level,
|
analysis_level: item.level,
|
||||||
analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level],
|
analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level],
|
||||||
analysis_label: `${trustedSensorId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`,
|
analysis_label: `${trustedDeviceId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`,
|
||||||
sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level]
|
sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
|||||||
describe("SCADA map presentation", () => {
|
describe("SCADA map presentation", () => {
|
||||||
it("maps the authoritative device types and falls back to unknown", () => {
|
it("maps the authoritative device types and falls back to unknown", () => {
|
||||||
expect(SCADA_ICON_EXPRESSION).toEqual([
|
expect(SCADA_ICON_EXPRESSION).toEqual([
|
||||||
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
|
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
|
||||||
"pipe_flow", SCADA_IMAGE_IDS.flow,
|
"pipe_flow", SCADA_IMAGE_IDS.flow,
|
||||||
"pressure", SCADA_IMAGE_IDS.pressure,
|
"pressure", SCADA_IMAGE_IDS.pressure,
|
||||||
SCADA_IMAGE_IDS.pressure
|
SCADA_IMAGE_IDS.pressure
|
||||||
@@ -28,6 +28,13 @@ describe("SCADA map presentation", () => {
|
|||||||
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
|
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders every SCADA layer from GeoJSON without a GeoServer source-layer", () => {
|
||||||
|
[...scadaLayers, ...scadaFallbackLayers].forEach((layer) => {
|
||||||
|
expect("source" in layer ? layer.source : undefined).toBe("scada");
|
||||||
|
expect(layer).not.toHaveProperty("source-layer");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the icon and selected ring consistent with junction selection", () => {
|
it("keeps the icon and selected ring consistent with junction selection", () => {
|
||||||
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
|
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
|
||||||
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
|
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from "./supply-style-expressions";
|
} from "./supply-style-expressions";
|
||||||
|
|
||||||
export const SCADA_SOURCE_ID = "scada";
|
export const SCADA_SOURCE_ID = "scada";
|
||||||
export const SCADA_SOURCE_LAYER = "geo_scada";
|
|
||||||
export const SCADA_HIT_LAYER_ID = "scada-hit";
|
export const SCADA_HIT_LAYER_ID = "scada-hit";
|
||||||
|
|
||||||
export const SCADA_ICON_PATHS = {
|
export const SCADA_ICON_PATHS = {
|
||||||
@@ -24,7 +23,7 @@ export const SCADA_IMAGE_IDS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
|
export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
|
||||||
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
|
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
|
||||||
"pipe_flow", SCADA_IMAGE_IDS.flow,
|
"pipe_flow", SCADA_IMAGE_IDS.flow,
|
||||||
"pressure", SCADA_IMAGE_IDS.pressure,
|
"pressure", SCADA_IMAGE_IDS.pressure,
|
||||||
SCADA_IMAGE_IDS.pressure
|
SCADA_IMAGE_IDS.pressure
|
||||||
@@ -40,7 +39,7 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
|
|||||||
];
|
];
|
||||||
|
|
||||||
const scadaColor: ExpressionSpecification = [
|
const scadaColor: ExpressionSpecification = [
|
||||||
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
|
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
|
||||||
"pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow,
|
"pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow,
|
||||||
MAP_STYLE_TOKENS.supply.scadaPressure
|
MAP_STYLE_TOKENS.supply.scadaPressure
|
||||||
];
|
];
|
||||||
@@ -53,7 +52,7 @@ export type ScadaImageRegistrationResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scadaCasingLayer: Layer = {
|
const scadaCasingLayer: Layer = {
|
||||||
id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
@@ -62,7 +61,7 @@ const scadaCasingLayer: Layer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scadaFillLayer: Layer = {
|
const scadaFillLayer: Layer = {
|
||||||
id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
|
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
|
||||||
"circle-color": createSupplyStateColor(scadaColor),
|
"circle-color": createSupplyStateColor(scadaColor),
|
||||||
@@ -71,13 +70,13 @@ const scadaFillLayer: Layer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scadaSymbolLayer: Layer = {
|
const scadaSymbolLayer: Layer = {
|
||||||
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
||||||
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true },
|
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true },
|
||||||
paint: { "icon-opacity": supplyIconOpacityExpression }
|
paint: { "icon-opacity": supplyIconOpacityExpression }
|
||||||
};
|
};
|
||||||
|
|
||||||
const scadaFallbackLayer: Layer = {
|
const scadaFallbackLayer: Layer = {
|
||||||
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
|
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
|
||||||
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
@@ -86,13 +85,13 @@ const scadaFallbackLayer: Layer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const scadaInteractionLayers: Layer[] = [
|
export const scadaInteractionLayers: Layer[] = [
|
||||||
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
|
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
|
||||||
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
|
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
|
||||||
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }
|
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }
|
||||||
];
|
];
|
||||||
|
|
||||||
const scadaHitLayer: Layer = {
|
const scadaHitLayer: Layer = {
|
||||||
id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
|
||||||
paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 }
|
paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||||
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||||
SUPPLY_LAYER_CATALOG,
|
SUPPLY_LAYER_CATALOG,
|
||||||
|
createBaseStyle,
|
||||||
createWaterNetworkSources,
|
createWaterNetworkSources,
|
||||||
SOURCE_LAYERS,
|
SOURCE_LAYERS,
|
||||||
WATER_NETWORK_SOURCE_IDS
|
WATER_NETWORK_SOURCE_IDS
|
||||||
@@ -10,46 +12,65 @@ import {
|
|||||||
describe("createWaterNetworkSources", () => {
|
describe("createWaterNetworkSources", () => {
|
||||||
it("catalogs published and pre-registered supply system layers", () => {
|
it("catalogs published and pre-registered supply system layers", () => {
|
||||||
expect(SOURCE_LAYERS).toEqual({
|
expect(SOURCE_LAYERS).toEqual({
|
||||||
pipes: "geo_pipes_mat",
|
pipes: "pipes",
|
||||||
junctions: "geo_junctions_mat",
|
junctions: "junctions",
|
||||||
valves: "geo_valves",
|
valves: "valves",
|
||||||
reservoirs: "geo_reservoirs",
|
reservoirs: "reservoirs",
|
||||||
scada: "geo_scada",
|
pumps: "pumps",
|
||||||
pumps: "geo_pumps",
|
tanks: "tanks"
|
||||||
tanks: "geo_tanks"
|
|
||||||
});
|
});
|
||||||
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
|
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
|
||||||
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available])).toEqual([
|
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available, layer.availability])).toEqual([
|
||||||
["pipes", true],
|
["pipes", true, "required"],
|
||||||
["junctions", true],
|
["junctions", true, "required"],
|
||||||
["valves", true],
|
["valves", true, "probe"],
|
||||||
["reservoirs", true],
|
["reservoirs", true, "probe"],
|
||||||
["scada", true],
|
["scada", true, "required"],
|
||||||
["pumps", false],
|
["pumps", true, "probe"],
|
||||||
["tanks", false]
|
["tanks", true, "probe"]
|
||||||
]);
|
]);
|
||||||
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
|
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
sourceLayer: "geo_valves"
|
sourceLayer: "valves"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads only available supply system layers from GeoServer WMTS", () => {
|
it("loads only available supply system layers from GeoServer WMTS", () => {
|
||||||
expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada"]);
|
expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
|
||||||
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({
|
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({
|
||||||
label: "水库",
|
label: "水库",
|
||||||
icon: "reservoir"
|
icon: "reservoir"
|
||||||
});
|
});
|
||||||
|
|
||||||
const sources = createWaterNetworkSources();
|
const sources = createWaterNetworkSources();
|
||||||
expect(Object.keys(sources)).toEqual(AVAILABLE_WATER_NETWORK_SOURCE_IDS);
|
expect(Object.keys(sources)).toEqual(GEOSERVER_WATER_NETWORK_SOURCE_IDS);
|
||||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
|
||||||
expect(sources[sourceId].tiles).toEqual([
|
expect(sources[sourceId].tiles).toEqual([
|
||||||
expect.stringContaining(`/tjwater:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`)
|
expect.stringContaining(`/tjwater_next:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`)
|
||||||
]);
|
]);
|
||||||
expect(sources[sourceId].promoteId).toBe("id");
|
expect(sources[sourceId].promoteId).toBe("id");
|
||||||
|
expect(sources[sourceId].bounds).toEqual([
|
||||||
|
121.35162408429075,
|
||||||
|
30.81049932304578,
|
||||||
|
121.77248479490075,
|
||||||
|
31.007207809222017
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
expect("pumps" in sources).toBe(false);
|
expect("pumps" in sources).toBe(true);
|
||||||
expect("tanks" in sources).toBe(false);
|
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"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,19 +6,20 @@ import {
|
|||||||
SCADA_ANALYSIS_SOURCE_ID,
|
SCADA_ANALYSIS_SOURCE_ID,
|
||||||
createEmptyScadaAnalysisCollection
|
createEmptyScadaAnalysisCollection
|
||||||
} from "./scada-analysis";
|
} from "./scada-analysis";
|
||||||
|
import { SCADA_SOURCE_ID } from "./scada";
|
||||||
|
|
||||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||||
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742]
|
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742],
|
||||||
|
bbox4326: [121.35162408429075, 30.81049932304578, 121.77248479490075, 31.007207809222017]
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SOURCE_LAYERS = {
|
export const SOURCE_LAYERS = {
|
||||||
pipes: "geo_pipes_mat",
|
pipes: "pipes",
|
||||||
junctions: "geo_junctions_mat",
|
junctions: "junctions",
|
||||||
valves: "geo_valves",
|
valves: "valves",
|
||||||
reservoirs: "geo_reservoirs",
|
reservoirs: "reservoirs",
|
||||||
scada: "geo_scada",
|
pumps: "pumps",
|
||||||
pumps: "geo_pumps",
|
tanks: "tanks"
|
||||||
tanks: "geo_tanks"
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const WATER_NETWORK_SOURCE_IDS = [
|
export const WATER_NETWORK_SOURCE_IDS = [
|
||||||
@@ -35,9 +36,10 @@ export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
|
|||||||
|
|
||||||
export type SupplyLayerCatalogItem = {
|
export type SupplyLayerCatalogItem = {
|
||||||
id: WaterNetworkSourceId;
|
id: WaterNetworkSourceId;
|
||||||
sourceLayer: (typeof SOURCE_LAYERS)[WaterNetworkSourceId];
|
sourceLayer?: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS];
|
||||||
geometry: "line" | "point";
|
geometry: "line" | "point";
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
availability: "required" | "probe";
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
};
|
};
|
||||||
@@ -48,6 +50,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.pipes,
|
sourceLayer: SOURCE_LAYERS.pipes,
|
||||||
geometry: "line",
|
geometry: "line",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "管线",
|
label: "管线",
|
||||||
icon: "pipe"
|
icon: "pipe"
|
||||||
},
|
},
|
||||||
@@ -56,6 +59,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.junctions,
|
sourceLayer: SOURCE_LAYERS.junctions,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "节点",
|
label: "节点",
|
||||||
icon: "junction"
|
icon: "junction"
|
||||||
},
|
},
|
||||||
@@ -64,6 +68,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.valves,
|
sourceLayer: SOURCE_LAYERS.valves,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "probe",
|
||||||
label: "阀门",
|
label: "阀门",
|
||||||
icon: "valve"
|
icon: "valve"
|
||||||
},
|
},
|
||||||
@@ -72,14 +77,16 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.reservoirs,
|
sourceLayer: SOURCE_LAYERS.reservoirs,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "probe",
|
||||||
label: "水库",
|
label: "水库",
|
||||||
icon: "reservoir"
|
icon: "reservoir"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "scada",
|
id: "scada",
|
||||||
sourceLayer: SOURCE_LAYERS.scada,
|
sourceLayer: undefined,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "SCADA",
|
label: "SCADA",
|
||||||
icon: "scada-pressure"
|
icon: "scada-pressure"
|
||||||
},
|
},
|
||||||
@@ -87,7 +94,8 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
id: "pumps",
|
id: "pumps",
|
||||||
sourceLayer: SOURCE_LAYERS.pumps,
|
sourceLayer: SOURCE_LAYERS.pumps,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: false,
|
available: true,
|
||||||
|
availability: "probe",
|
||||||
label: "水泵",
|
label: "水泵",
|
||||||
icon: "pump"
|
icon: "pump"
|
||||||
},
|
},
|
||||||
@@ -95,7 +103,8 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
id: "tanks",
|
id: "tanks",
|
||||||
sourceLayer: SOURCE_LAYERS.tanks,
|
sourceLayer: SOURCE_LAYERS.tanks,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: false,
|
available: true,
|
||||||
|
availability: "probe",
|
||||||
label: "水箱",
|
label: "水箱",
|
||||||
icon: "tank"
|
icon: "tank"
|
||||||
}
|
}
|
||||||
@@ -103,12 +112,14 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
|
|
||||||
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
|
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
|
||||||
(layer) => layer.available
|
(layer) => layer.available
|
||||||
).map((layer) => layer.id) as Extract<
|
).map((layer) => layer.id) as WaterNetworkSourceId[];
|
||||||
WaterNetworkSourceId,
|
|
||||||
"pipes" | "junctions" | "valves" | "reservoirs" | "scada"
|
|
||||||
>[];
|
|
||||||
|
|
||||||
export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number];
|
export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number];
|
||||||
|
export type GeoServerWaterNetworkSourceId = Exclude<AvailableWaterNetworkSourceId, "scada">;
|
||||||
|
|
||||||
|
export const GEOSERVER_WATER_NETWORK_SOURCE_IDS = AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter(
|
||||||
|
(sourceId): sourceId is GeoServerWaterNetworkSourceId => sourceId !== "scada"
|
||||||
|
);
|
||||||
|
|
||||||
export function isAvailableWaterNetworkSourceId(
|
export function isAvailableWaterNetworkSourceId(
|
||||||
value: string
|
value: string
|
||||||
@@ -116,13 +127,22 @@ export function isAvailableWaterNetworkSourceId(
|
|||||||
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
|
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(
|
return Object.fromEntries(
|
||||||
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [
|
SUPPLY_LAYER_CATALOG.flatMap((layer) =>
|
||||||
layer.id,
|
layer.available &&
|
||||||
createGeoServerVectorSource(layer.sourceLayer)
|
layer.sourceLayer !== undefined &&
|
||||||
])
|
sourceIds.includes(layer.id as GeoServerWaterNetworkSourceId)
|
||||||
) as Record<AvailableWaterNetworkSourceId, VectorSourceSpecification>;
|
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const]
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
) as Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGeoServerVectorSource(
|
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`
|
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||||
],
|
],
|
||||||
minzoom: 0,
|
minzoom: 0,
|
||||||
maxzoom: 24
|
maxzoom: 24,
|
||||||
|
bounds: [...WATER_NETWORK_GLOBAL_VIEW.bbox4326]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||||
const sources: StyleSpecification["sources"] = {
|
const sources: StyleSpecification["sources"] = {
|
||||||
|
[SCADA_SOURCE_ID]: {
|
||||||
|
type: "geojson",
|
||||||
|
promoteId: "device_id",
|
||||||
|
data: { type: "FeatureCollection", features: [] }
|
||||||
|
},
|
||||||
[SCADA_ANALYSIS_SOURCE_ID]: {
|
[SCADA_ANALYSIS_SOURCE_ID]: {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
data: createEmptyScadaAnalysisCollection()
|
data: createEmptyScadaAnalysisCollection()
|
||||||
|
|||||||
@@ -21,6 +21,192 @@ describe("WorkbenchMapController", () => {
|
|||||||
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
|
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 () => {
|
it("fits line bounds and uses responsive workbench padding", async () => {
|
||||||
const map = createMap();
|
const map = createMap();
|
||||||
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
|
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
|
||||||
@@ -56,7 +242,7 @@ describe("WorkbenchMapController", () => {
|
|||||||
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||||
|
|
||||||
const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]));
|
const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]));
|
||||||
expect(requestUrl.pathname).toBe("/geoserver/tjwater/ows");
|
expect(requestUrl.pathname).toBe("/geoserver/tjwater_next/ows");
|
||||||
expect(requestUrl.searchParams.get("request")).toBe("GetFeature");
|
expect(requestUrl.searchParams.get("request")).toBe("GetFeature");
|
||||||
expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')");
|
expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')");
|
||||||
expect(map.setFeatureState).toHaveBeenCalled();
|
expect(map.setFeatureState).toHaveBeenCalled();
|
||||||
@@ -81,7 +267,7 @@ describe("WorkbenchMapController", () => {
|
|||||||
|
|
||||||
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
||||||
source: "pipes",
|
source: "pipes",
|
||||||
"source-layer": "geo_pipes_mat"
|
"source-layer": "pipes"
|
||||||
});
|
});
|
||||||
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@@ -96,7 +282,7 @@ describe("WorkbenchMapController", () => {
|
|||||||
expect(map.setFeatureState).toHaveBeenCalledWith(
|
expect(map.setFeatureState).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
source: "pipes",
|
source: "pipes",
|
||||||
sourceLayer: "geo_pipes_mat",
|
sourceLayer: "pipes",
|
||||||
id: "P-1"
|
id: "P-1"
|
||||||
}),
|
}),
|
||||||
{ flowDirection: -1 }
|
{ flowDirection: -1 }
|
||||||
@@ -251,13 +437,13 @@ describe("WorkbenchMapController", () => {
|
|||||||
{
|
{
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
geometry: { type: "Point", coordinates: [120.7, 28] },
|
geometry: { type: "Point", coordinates: [120.7, 28] },
|
||||||
properties: { sensor_id: "MP01" }
|
properties: { device_id: "MP01" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }])
|
controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
rendered_ids: ["MP01"],
|
rendered_ids: ["MP01"],
|
||||||
fitted: true
|
fitted: true
|
||||||
@@ -281,7 +467,7 @@ function createMap() {
|
|||||||
const images = new Set<string>();
|
const images = new Set<string>();
|
||||||
return {
|
return {
|
||||||
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
|
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(),
|
setLayoutProperty: vi.fn(), setFilter: vi.fn(), removeControl: vi.fn(), triggerRepaint: vi.fn(),
|
||||||
hasImage: vi.fn((id: string) => images.has(id)),
|
hasImage: vi.fn((id: string) => images.has(id)),
|
||||||
addImage: vi.fn((id: string) => {
|
addImage: vi.fn((id: string) => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
normalizeMapFeatureCollection,
|
normalizeMapFeatureCollection,
|
||||||
parseMapFeatureQuery
|
parseMapFeatureQuery
|
||||||
} from "./map-feature-query";
|
} from "./map-feature-query";
|
||||||
|
import { DATA_DEPENDENT_GEOSERVER_SOURCE_IDS } from "./geoserver-layer-availability";
|
||||||
import {
|
import {
|
||||||
applyValueLabelStyle,
|
applyValueLabelStyle,
|
||||||
clearValueLabels,
|
clearValueLabels,
|
||||||
@@ -47,6 +48,7 @@ export type WorkbenchMapErrorCode =
|
|||||||
| "MAP_NOT_READY"
|
| "MAP_NOT_READY"
|
||||||
| "FEATURE_NOT_FOUND"
|
| "FEATURE_NOT_FOUND"
|
||||||
| "WFS_UNAVAILABLE"
|
| "WFS_UNAVAILABLE"
|
||||||
|
| "SCADA_API_UNAVAILABLE"
|
||||||
| "SCADA_FEATURES_NOT_FOUND"
|
| "SCADA_FEATURES_NOT_FOUND"
|
||||||
| "ACTION_SUPERSEDED"
|
| "ACTION_SUPERSEDED"
|
||||||
| "FLOW_UNAVAILABLE"
|
| "FLOW_UNAVAILABLE"
|
||||||
@@ -88,6 +90,7 @@ export type WorkbenchMapControllerOptions = {
|
|||||||
isReady: () => boolean;
|
isReady: () => boolean;
|
||||||
getPadding: () => PaddingOptions;
|
getPadding: () => PaddingOptions;
|
||||||
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
|
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
|
||||||
|
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
|
||||||
reducedMotion?: () => boolean;
|
reducedMotion?: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,6 +111,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
private flowState: FlowOverlayState = createFlowOverlayState();
|
private flowState: FlowOverlayState = createFlowOverlayState();
|
||||||
private flowUpdateRevision = 0;
|
private flowUpdateRevision = 0;
|
||||||
private scadaAnalysisRevision = 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) {}
|
constructor(private readonly options: WorkbenchMapControllerOptions) {}
|
||||||
|
|
||||||
@@ -119,15 +126,21 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
getSnapshot = () => this.state;
|
getSnapshot = () => this.state;
|
||||||
|
|
||||||
async zoomToFeature(target: FeatureTarget) {
|
async zoomToFeature(target: FeatureTarget) {
|
||||||
const feature = await this.resolveTarget(target);
|
const requestRevision = this.beginTargetCommand();
|
||||||
const map = this.requireMap();
|
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 (!feature || !map) return;
|
||||||
this.moveCameraToFeature(map, feature);
|
this.moveCameraToFeature(map, feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
async locateAndHighlight(target: FeatureTarget) {
|
async locateAndHighlight(target: FeatureTarget) {
|
||||||
const feature = await this.resolveTarget(target);
|
const requestRevision = this.beginTargetCommand();
|
||||||
const map = this.requireMap();
|
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 (!feature || !map) return;
|
||||||
if (!this.moveCameraToFeature(map, feature)) return;
|
if (!this.moveCameraToFeature(map, feature)) return;
|
||||||
this.setHighlight(target);
|
this.setHighlight(target);
|
||||||
@@ -163,19 +176,90 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async highlight(target: FeatureTarget) {
|
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);
|
if (feature) this.setHighlight(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearHighlight = () => {
|
clearHighlight = () => {
|
||||||
|
this.beginTargetCommand();
|
||||||
const map = this.options.getMap();
|
const map = this.options.getMap();
|
||||||
if (map && this.highlightedTarget) {
|
if (map && this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
|
||||||
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||||
}
|
}
|
||||||
this.highlightedTarget = null;
|
this.highlightedTarget = null;
|
||||||
this.patchState({ target: null, errorCode: 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) {
|
async setFlowVisible(visible: boolean) {
|
||||||
const map = this.requireMap();
|
const map = this.requireMap();
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@@ -320,16 +404,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
try {
|
try {
|
||||||
sourceCollection = await this.fetchFeatures(
|
sourceCollection = await this.fetchFeatures(
|
||||||
"scada",
|
"scada",
|
||||||
items.map((item) => item.sensor_id),
|
items.map((item) => item.device_id),
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
if (revision === this.scadaAnalysisRevision) {
|
if (revision === this.scadaAnalysisRevision) {
|
||||||
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
|
this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" });
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
revision === this.scadaAnalysisRevision
|
revision === this.scadaAnalysisRevision
|
||||||
? "WFS_UNAVAILABLE"
|
? "SCADA_API_UNAVAILABLE"
|
||||||
: "ACTION_SUPERSEDED"
|
: "ACTION_SUPERSEDED"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -440,36 +524,66 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.flowUpdateRevision += 1;
|
this.flowUpdateRevision += 1;
|
||||||
|
this.targetRequestRevision += 1;
|
||||||
|
this.pendingTargetSourceId = null;
|
||||||
const map = this.options.getMap();
|
const map = this.options.getMap();
|
||||||
if (map) hideFlowOverlay(map, this.flowState);
|
if (map) hideFlowOverlay(map, this.flowState);
|
||||||
this.listeners.clear();
|
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();
|
const map = this.requireMap();
|
||||||
if (!map) return null;
|
if (!map) return null;
|
||||||
const key = `${target.sourceId}:${target.featureId}`;
|
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;
|
if (cached) return cached;
|
||||||
|
const sourceAvailabilityGeneration = this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0;
|
||||||
|
this.pendingTargetSourceId = target.sourceId;
|
||||||
this.patchState({ pending: true, errorCode: null });
|
this.patchState({ pending: true, errorCode: null });
|
||||||
try {
|
try {
|
||||||
const collection = await this.fetchFeatures(target.sourceId, [target.featureId]);
|
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];
|
const feature = collection.features[0];
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
|
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
this.cachedFeatures.set(key, feature);
|
if (!dataDependent) this.cachedFeatures.set(key, feature);
|
||||||
this.patchState({ pending: false });
|
this.patchState({ pending: false });
|
||||||
return feature;
|
return feature;
|
||||||
} catch {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
|
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
|
||||||
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
|
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
|
||||||
|
if (sourceId === "scada") {
|
||||||
|
if (!this.options.getScadaFeatures) throw new Error("SCADA_API_UNAVAILABLE");
|
||||||
|
return this.options.getScadaFeatures(featureIds, signal);
|
||||||
|
}
|
||||||
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
|
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
|
||||||
if (!parsed.ok) throw new Error(parsed.code);
|
if (!parsed.ok) throw new Error(parsed.code);
|
||||||
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
|
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
|
||||||
@@ -484,9 +598,11 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setHighlight(target: FeatureTarget) {
|
private setHighlight(target: FeatureTarget) {
|
||||||
const map = this.requireMap();
|
const map = this.requireTargetMap(target);
|
||||||
if (!map) return;
|
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 });
|
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
|
||||||
this.highlightedTarget = target;
|
this.highlightedTarget = target;
|
||||||
this.patchState({ target, errorCode: null });
|
this.patchState({ target, errorCode: null });
|
||||||
@@ -501,6 +617,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
return map;
|
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) {
|
private setError(errorCode: WorkbenchMapErrorCode) {
|
||||||
this.patchState({ errorCode });
|
this.patchState({ errorCode });
|
||||||
}
|
}
|
||||||
@@ -516,7 +642,9 @@ function createEmptyPipeFlowSummary(): PipeFlowSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toFeatureStateTarget(target: FeatureTarget) {
|
function toFeatureStateTarget(target: FeatureTarget) {
|
||||||
return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
|
return target.sourceId === "scada"
|
||||||
|
? { source: target.sourceId, id: target.featureId }
|
||||||
|
: { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectCoordinates(geometry: Geometry | null): Position[] {
|
function collectCoordinates(geometry: Geometry | null): Position[] {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
|
|
||||||
describe("feature panel properties", () => {
|
describe("feature panel properties", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["pipes", ["id", "diameter", "length", "material"]],
|
["pipes", ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"]],
|
||||||
["junctions", ["id", "elevation", "demand", "pressure"]]
|
["junctions", ["id", "elevation", "base_demand"]]
|
||||||
] as const)("shows the configured %s fields", (layer, expectedKeys) => {
|
] as const)("shows the configured %s fields", (layer, expectedKeys) => {
|
||||||
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
||||||
|
|
||||||
@@ -20,16 +20,22 @@ describe("feature panel properties", () => {
|
|||||||
it("formats pipe diameter from GeoServer DN millimeter values", () => {
|
it("formats pipe diameter from GeoServer DN millimeter values", () => {
|
||||||
const entries = getFeaturePanelProperties("pipes", {
|
const entries = getFeaturePanelProperties("pipes", {
|
||||||
id: "P-1",
|
id: "P-1",
|
||||||
|
start_node_id: "J-1",
|
||||||
|
end_node_id: "J-2",
|
||||||
diameter: 600,
|
diameter: 600,
|
||||||
length: 42.5,
|
length: 42.5,
|
||||||
material: "DI"
|
roughness: 120,
|
||||||
|
status: "OPEN"
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(entries.map(({ label, value }) => [label, value])).toEqual([
|
expect(entries.map(({ label, value }) => [label, value])).toEqual([
|
||||||
["编号", "P-1"],
|
["编号", "P-1"],
|
||||||
|
["起点节点", "J-1"],
|
||||||
|
["终点节点", "J-2"],
|
||||||
["管径", "600 mm"],
|
["管径", "600 mm"],
|
||||||
["长度", "42.50 m"],
|
["长度", "42.50 m"],
|
||||||
["材质", "球墨铸铁"]
|
["粗糙系数", "120"],
|
||||||
|
["状态", "开启"]
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,14 +43,14 @@ describe("feature panel properties", () => {
|
|||||||
const metrics = getFeatureInsightMetrics("pipes", {
|
const metrics = getFeatureInsightMetrics("pipes", {
|
||||||
diameter: 600,
|
diameter: 600,
|
||||||
length: 42.5,
|
length: 42.5,
|
||||||
material: "DI",
|
roughness: 120,
|
||||||
status: "OPEN"
|
status: "OPEN"
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(metrics).toEqual([
|
expect(metrics).toEqual([
|
||||||
{ label: "管径", value: "600 mm" },
|
{ label: "管径", value: "600 mm" },
|
||||||
{ label: "长度", value: "42.50 m" },
|
{ label: "长度", value: "42.50 m" },
|
||||||
{ label: "材质", value: "球墨铸铁" },
|
{ label: "粗糙系数", value: "120" },
|
||||||
{ label: "状态", value: "开启" }
|
{ label: "状态", value: "开启" }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -52,56 +58,62 @@ describe("feature panel properties", () => {
|
|||||||
it("localizes common enumerated property values in the panel", () => {
|
it("localizes common enumerated property values in the panel", () => {
|
||||||
expect(getFeaturePanelProperties("valves", {
|
expect(getFeaturePanelProperties("valves", {
|
||||||
id: "V-1",
|
id: "V-1",
|
||||||
node1: "J-1",
|
start_node_id: "J-1",
|
||||||
node2: "J-2",
|
end_node_id: "J-2",
|
||||||
diameter: 300,
|
diameter: 300,
|
||||||
v_type: "PRV",
|
valve_type: "PRV",
|
||||||
setting: "OPEN"
|
setting: "OPEN",
|
||||||
|
minor_loss: 0.2
|
||||||
}).map(({ label, value }) => [label, value])).toEqual([
|
}).map(({ label, value }) => [label, value])).toEqual([
|
||||||
["编号", "V-1"],
|
["编号", "V-1"],
|
||||||
["起点节点", "J-1"],
|
["起点节点", "J-1"],
|
||||||
["终点节点", "J-2"],
|
["终点节点", "J-2"],
|
||||||
["管径", "300 mm"],
|
["管径", "300 mm"],
|
||||||
["阀门类型", "减压阀"],
|
["阀门类型", "减压阀"],
|
||||||
["阀门设定", "开启"]
|
["阀门设定", "开启"],
|
||||||
|
["局部损失系数", "0.20"]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(getFeaturePanelProperties("scada", {
|
expect(getFeaturePanelProperties("scada", {
|
||||||
id: "S-1",
|
device_id: "S-1",
|
||||||
type: "pressure",
|
device_type: "pressure",
|
||||||
associated_element_id: "J-1",
|
node_id: "J-1",
|
||||||
|
link_id: null,
|
||||||
|
api_query_id: "query-1",
|
||||||
transmission_mode: "non_realtime",
|
transmission_mode: "non_realtime",
|
||||||
transmission_frequency: "15min",
|
transmission_frequency: "15min",
|
||||||
reliability: "high"
|
reliability: "high"
|
||||||
}).map(({ label, value }) => [label, value])).toEqual([
|
}).map(({ label, value }) => [label, value])).toEqual([
|
||||||
["编号", "S-1"],
|
["设备 ID", "S-1"],
|
||||||
["类型", "压力监测"],
|
["设备类型", "压力监测"],
|
||||||
["关联资产", "J-1"],
|
["关联节点", "J-1"],
|
||||||
|
["关联连线", "暂无"],
|
||||||
|
["采集接口", "query-1"],
|
||||||
["传输方式", "非实时"],
|
["传输方式", "非实时"],
|
||||||
["传输频率", "15min"],
|
["传输频率", "15min"],
|
||||||
["可靠性", "高"]
|
["可靠性", "高"]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(getFeaturePanelProperties("scada", {
|
expect(getFeaturePanelProperties("scada", {
|
||||||
id: "S-2",
|
device_id: "S-2",
|
||||||
type: "pipe_flow"
|
device_type: "pipe_flow"
|
||||||
}).find((entry) => entry.key === "type")?.value).toBe("流量监测");
|
}).find((entry) => entry.key === "device_type")?.value).toBe("流量监测");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the map feature id when the properties omit it", () => {
|
it("uses the map feature id when the properties omit it", () => {
|
||||||
const model = getFeaturePanelModel("junctions", { demand: 3.4 }, "map-feature-id");
|
const model = getFeaturePanelModel("junctions", { base_demand: 3.4 }, "map-feature-id");
|
||||||
|
|
||||||
expect(model.id).toBe("map-feature-id");
|
expect(model.id).toBe("map-feature-id");
|
||||||
expect(model.badge).toBeUndefined();
|
expect(model.badge).toBeUndefined();
|
||||||
expect(model.attributes.map((entry) => entry.key)).toEqual(["elevation", "demand", "pressure"]);
|
expect(model.attributes.map((entry) => entry.key)).toEqual(["elevation", "base_demand"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["valves", ["id", "node1", "node2", "diameter", "v_type", "setting"]],
|
["valves", ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"]],
|
||||||
["reservoirs", ["id", "head", "pattern"]],
|
["reservoirs", ["id", "head", "pattern_id"]],
|
||||||
["scada", ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"]],
|
["scada", ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"]],
|
||||||
["pumps", ["id", "node1", "node2", "status"]],
|
["pumps", ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"]],
|
||||||
["tanks", ["id", "elevation", "init_level", "min_level", "max_level"]]
|
["tanks", ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]]
|
||||||
] as const)("localizes configured %s panel field labels", (layer, keys) => {
|
] as const)("localizes configured %s panel field labels", (layer, keys) => {
|
||||||
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
||||||
|
|
||||||
@@ -112,7 +124,7 @@ describe("feature panel properties", () => {
|
|||||||
label: getFeaturePropertyLabel(entry.key)
|
label: getFeaturePropertyLabel(entry.key)
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
expect(entries.filter((entry) => entry.key !== "id").map((entry) => entry.label))
|
expect(entries.filter((entry) => entry.key !== "id" && entry.key !== "device_id").map((entry) => entry.label))
|
||||||
.not.toContainEqual(expect.stringMatching(/^[a-z][a-z0-9_]*$/));
|
.not.toContainEqual(expect.stringMatching(/^[a-z][a-z0-9_]*$/));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
fid: "GeoServer 要素 ID",
|
fid: "GeoServer 要素 ID",
|
||||||
id: "编号",
|
id: "编号",
|
||||||
scada_id: "编号 UUID",
|
scada_id: "编号 UUID",
|
||||||
sensor_id: "传感器 ID",
|
|
||||||
sensor_name: "传感器名称",
|
|
||||||
swmm_node: "SWMM 节点",
|
swmm_node: "SWMM 节点",
|
||||||
topology_order: "拓扑序",
|
topology_order: "拓扑序",
|
||||||
distance_to_wwtp_m: "距处理厂距离",
|
distance_to_wwtp_m: "距处理厂距离",
|
||||||
@@ -48,8 +46,11 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
name: "名称",
|
name: "名称",
|
||||||
code: "编码",
|
code: "编码",
|
||||||
type: "类型",
|
type: "类型",
|
||||||
|
device_type: "设备类型",
|
||||||
node1: "起点节点",
|
node1: "起点节点",
|
||||||
node2: "终点节点",
|
node2: "终点节点",
|
||||||
|
start_node_id: "起点节点",
|
||||||
|
end_node_id: "终点节点",
|
||||||
from_node: "起点节点",
|
from_node: "起点节点",
|
||||||
to_node: "终点节点",
|
to_node: "终点节点",
|
||||||
diameter: "管径",
|
diameter: "管径",
|
||||||
@@ -60,6 +61,7 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
max_depth: "最大深度",
|
max_depth: "最大深度",
|
||||||
invert_elevation: "井底高程",
|
invert_elevation: "井底高程",
|
||||||
v_type: "阀门类型",
|
v_type: "阀门类型",
|
||||||
|
valve_type: "阀门类型",
|
||||||
setting: "阀门设定",
|
setting: "阀门设定",
|
||||||
minor_loss: "局部损失系数",
|
minor_loss: "局部损失系数",
|
||||||
orifice_type: "孔口类型",
|
orifice_type: "孔口类型",
|
||||||
@@ -76,12 +78,24 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
status: "状态",
|
status: "状态",
|
||||||
head: "水头",
|
head: "水头",
|
||||||
pattern: "模式",
|
pattern: "模式",
|
||||||
|
pattern_id: "模式",
|
||||||
|
head_curve_id: "扬程曲线",
|
||||||
|
volume_curve_id: "容积曲线",
|
||||||
init_level: "初始水位",
|
init_level: "初始水位",
|
||||||
min_level: "最低水位",
|
min_level: "最低水位",
|
||||||
max_level: "最高水位",
|
max_level: "最高水位",
|
||||||
|
initial_level: "初始水位",
|
||||||
|
minimum_level: "最低水位",
|
||||||
|
maximum_level: "最高水位",
|
||||||
|
minimum_volume: "最小容积",
|
||||||
|
overflow: "允许溢流",
|
||||||
|
power: "功率",
|
||||||
|
speed: "转速倍率",
|
||||||
material: "材质",
|
material: "材质",
|
||||||
elevation: "高程",
|
elevation: "高程",
|
||||||
demand: "需水量",
|
demand: "需水量",
|
||||||
|
base_demand: "基础需水量",
|
||||||
|
demands: "需水配置",
|
||||||
pressure: "压力",
|
pressure: "压力",
|
||||||
flow: "流量",
|
flow: "流量",
|
||||||
velocity: "流速",
|
velocity: "流速",
|
||||||
@@ -93,6 +107,9 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
point_external_id: "测点编号",
|
point_external_id: "测点编号",
|
||||||
point_name: "测点名称",
|
point_name: "测点名称",
|
||||||
associated_element_id: "关联资产",
|
associated_element_id: "关联资产",
|
||||||
|
node_id: "关联节点",
|
||||||
|
link_id: "关联连线",
|
||||||
|
api_query_id: "采集接口",
|
||||||
transmission_mode: "传输方式",
|
transmission_mode: "传输方式",
|
||||||
transmission_frequency: "传输频率",
|
transmission_frequency: "传输频率",
|
||||||
reliability: "可靠性",
|
reliability: "可靠性",
|
||||||
@@ -114,8 +131,6 @@ const PROPERTY_ORDER: Record<string, number> = {
|
|||||||
fid: 9,
|
fid: 9,
|
||||||
id: 10,
|
id: 10,
|
||||||
scada_id: 10,
|
scada_id: 10,
|
||||||
sensor_id: 10,
|
|
||||||
sensor_name: 12,
|
|
||||||
swmm_node: 13,
|
swmm_node: 13,
|
||||||
topology_order: 14,
|
topology_order: 14,
|
||||||
distance_to_wwtp_m: 15,
|
distance_to_wwtp_m: 15,
|
||||||
@@ -143,10 +158,13 @@ const PROPERTY_ORDER: Record<string, number> = {
|
|||||||
material: 23,
|
material: 23,
|
||||||
node1: 24,
|
node1: 24,
|
||||||
from_node: 24,
|
from_node: 24,
|
||||||
|
start_node_id: 24,
|
||||||
node2: 25,
|
node2: 25,
|
||||||
to_node: 25,
|
to_node: 25,
|
||||||
|
end_node_id: 25,
|
||||||
elevation: 30,
|
elevation: 30,
|
||||||
demand: 31,
|
demand: 31,
|
||||||
|
base_demand: 31,
|
||||||
pressure: 32,
|
pressure: 32,
|
||||||
flow: 33,
|
flow: 33,
|
||||||
velocity: 34,
|
velocity: 34,
|
||||||
@@ -275,13 +293,13 @@ const PROPERTY_UNITS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
|
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
|
||||||
pipes: ["id", "diameter", "length", "material"],
|
pipes: ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"],
|
||||||
junctions: ["id", "elevation", "demand", "pressure"],
|
junctions: ["id", "elevation", "base_demand"],
|
||||||
valves: ["id", "node1", "node2", "diameter", "v_type", "setting"],
|
valves: ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"],
|
||||||
reservoirs: ["id", "head", "pattern"],
|
reservoirs: ["id", "head", "pattern_id"],
|
||||||
scada: ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"],
|
scada: ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"],
|
||||||
pumps: ["id", "node1", "node2", "status"],
|
pumps: ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"],
|
||||||
tanks: ["id", "elevation", "init_level", "min_level", "max_level"]
|
tanks: ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]
|
||||||
};
|
};
|
||||||
|
|
||||||
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
||||||
@@ -299,42 +317,44 @@ const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsi
|
|||||||
pipes: [
|
pipes: [
|
||||||
{ label: "管径", key: "diameter" },
|
{ label: "管径", key: "diameter" },
|
||||||
{ label: "长度", key: "length" },
|
{ label: "长度", key: "length" },
|
||||||
{ label: "材质", key: "material" },
|
{ label: "粗糙系数", key: "roughness" },
|
||||||
{ label: "状态", key: "status" }
|
{ label: "状态", key: "status" }
|
||||||
],
|
],
|
||||||
junctions: [
|
junctions: [
|
||||||
{ label: "需水量", key: "demand" },
|
{ label: "基础需水量", key: "base_demand" },
|
||||||
{ label: "高程", key: "elevation" },
|
{ label: "高程", key: "elevation" },
|
||||||
{ label: "压力", key: "pressure" },
|
{ label: "X 坐标", key: "x" },
|
||||||
{ label: "状态", value: "在线" }
|
{ label: "Y 坐标", key: "y" }
|
||||||
],
|
],
|
||||||
valves: [
|
valves: [
|
||||||
{ label: "管径", key: "diameter" },
|
{ label: "管径", key: "diameter" },
|
||||||
{ label: "类型", key: "v_type" },
|
{ label: "类型", key: "valve_type" },
|
||||||
{ label: "设定", key: "setting" },
|
{ label: "设定", key: "setting" },
|
||||||
{ label: "局部损失", key: "minor_loss" }
|
{ label: "局部损失", key: "minor_loss" }
|
||||||
],
|
],
|
||||||
reservoirs: [
|
reservoirs: [
|
||||||
{ label: "水头", key: "head" },
|
{ label: "水头", key: "head" },
|
||||||
{ label: "模式", key: "pattern" },
|
{ label: "模式", key: "pattern_id" },
|
||||||
{ label: "状态", value: "在线" }
|
{ label: "X 坐标", key: "x" },
|
||||||
|
{ label: "Y 坐标", key: "y" }
|
||||||
],
|
],
|
||||||
scada: [
|
scada: [
|
||||||
{ label: "类型", key: "type" },
|
{ label: "类型", key: "device_type" },
|
||||||
{ label: "关联资产", key: "associated_element_id" },
|
{ label: "关联节点", key: "node_id" },
|
||||||
{ label: "传输方式", key: "transmission_mode" },
|
{ label: "关联连线", key: "link_id" },
|
||||||
{ label: "可靠性", key: "reliability" }
|
{ label: "传输方式", key: "transmission_mode" }
|
||||||
],
|
],
|
||||||
pumps: [
|
pumps: [
|
||||||
{ label: "起点", key: "node1" },
|
{ label: "功率", key: "power" },
|
||||||
{ label: "终点", key: "node2" },
|
{ label: "扬程曲线", key: "head_curve_id" },
|
||||||
{ label: "状态", key: "status" }
|
{ label: "转速倍率", key: "speed" },
|
||||||
|
{ label: "模式", key: "pattern_id" }
|
||||||
],
|
],
|
||||||
tanks: [
|
tanks: [
|
||||||
{ label: "高程", key: "elevation" },
|
{ label: "高程", key: "elevation" },
|
||||||
{ label: "初始水位", key: "init_level" },
|
{ label: "初始水位", key: "initial_level" },
|
||||||
{ label: "最高水位", key: "max_level" },
|
{ label: "最低水位", key: "minimum_level" },
|
||||||
{ label: "状态", value: "在线" }
|
{ label: "最高水位", key: "maximum_level" }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -353,7 +373,7 @@ export function getFeaturePanelProperties(
|
|||||||
): LocalizedFeatureProperty[] {
|
): LocalizedFeatureProperty[] {
|
||||||
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => {
|
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => {
|
||||||
const propertyValue = getPropertyValue(properties, key);
|
const propertyValue = getPropertyValue(properties, key);
|
||||||
const value = key === "id" || key === "scada_id" || key === "sensor_id" ? propertyValue ?? featureId : propertyValue;
|
const value = key === "id" || key === "scada_id" || key === "device_id" ? propertyValue ?? featureId : propertyValue;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
@@ -391,7 +411,7 @@ export function getFeaturePanelModel(
|
|||||||
featureId?: string
|
featureId?: string
|
||||||
): FeaturePanelModel {
|
): FeaturePanelModel {
|
||||||
const entries = getFeaturePanelProperties(layer, properties, featureId);
|
const entries = getFeaturePanelProperties(layer, properties, featureId);
|
||||||
const idKey = "id";
|
const idKey = layer === "scada" ? "device_id" : "id";
|
||||||
const id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无";
|
const id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无";
|
||||||
const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer];
|
const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer];
|
||||||
const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined;
|
const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined;
|
||||||
@@ -420,7 +440,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
|||||||
return OUTFALL_TYPE_LABELS[value.toLowerCase()] ?? value;
|
return OUTFALL_TYPE_LABELS[value.toLowerCase()] ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedKey === "gated" && value !== null && value !== undefined && value !== "") {
|
if (["gated", "overflow"].includes(normalizedKey) && value !== null && value !== undefined && value !== "") {
|
||||||
return GATED_LABELS[String(value).toLowerCase()] ?? formatValue(value);
|
return GATED_LABELS[String(value).toLowerCase()] ?? formatValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +472,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
|||||||
: formatValue(value);
|
: formatValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth", "distance_to_wwtp_m"].includes(normalizedKey)
|
if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth", "distance_to_wwtp_m", "initial_level", "minimum_level", "maximum_level"].includes(normalizedKey)
|
||||||
&& value !== null && value !== undefined && value !== "") {
|
&& value !== null && value !== undefined && value !== "") {
|
||||||
return `${formatValue(value)} m`;
|
return `${formatValue(value)} m`;
|
||||||
}
|
}
|
||||||
@@ -485,7 +505,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
|
|||||||
return STATUS_LABELS[normalizedValue];
|
return STATUS_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedKey === "v_type") {
|
if (normalizedKey === "v_type" || normalizedKey === "valve_type") {
|
||||||
return VALVE_TYPE_LABELS[normalizedValue];
|
return VALVE_TYPE_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +513,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
|
|||||||
return MATERIAL_LABELS[normalizedValue];
|
return MATERIAL_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedKey === "type") {
|
if (normalizedKey === "type" || normalizedKey === "device_type") {
|
||||||
return ASSET_TYPE_LABELS[normalizedValue];
|
return ASSET_TYPE_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +525,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
|
|||||||
return RELIABILITY_LABELS[normalizedValue];
|
return RELIABILITY_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedKey === "pattern") {
|
if (normalizedKey === "pattern" || normalizedKey === "pattern_id") {
|
||||||
return PATTERN_LABELS[normalizedValue];
|
return PATTERN_LABELS[normalizedValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ describe("Keycloak authentication", () => {
|
|||||||
expect(client.init).toHaveBeenCalledWith({
|
expect(client.init).toHaveBeenCalledWith({
|
||||||
onLoad: "login-required",
|
onLoad: "login-required",
|
||||||
flow: "standard",
|
flow: "standard",
|
||||||
pkceMethod: "S256"
|
pkceMethod: "S256",
|
||||||
|
checkLoginIframe: false
|
||||||
});
|
});
|
||||||
expect(authentication.user).toEqual({
|
expect(authentication.user).toEqual({
|
||||||
name: "张调度",
|
name: "张调度",
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export async function initializeAuthentication(
|
|||||||
const authenticated = await keycloak.init({
|
const authenticated = await keycloak.init({
|
||||||
onLoad: "login-required",
|
onLoad: "login-required",
|
||||||
flow: "standard",
|
flow: "standard",
|
||||||
pkceMethod: "S256"
|
pkceMethod: "S256",
|
||||||
|
checkLoginIframe: false
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe("runtime frontend configuration", () => {
|
|||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
|
||||||
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
|
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
|
||||||
TJWATER_ENABLE_DEV_PANEL: "true",
|
TJWATER_ENABLE_DEV_PANEL: "true",
|
||||||
TJWATER_ENABLE_MSW: "false"
|
TJWATER_ENABLE_MSW: "false"
|
||||||
@@ -23,6 +24,7 @@ describe("runtime frontend configuration", () => {
|
|||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
|
||||||
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
|
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
|
||||||
TJWATER_ENABLE_DEV_PANEL: true,
|
TJWATER_ENABLE_DEV_PANEL: true,
|
||||||
TJWATER_ENABLE_MSW: false
|
TJWATER_ENABLE_MSW: false
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ const runtimeConfigSchema = z
|
|||||||
TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"),
|
TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"),
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
||||||
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
||||||
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"),
|
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater_next"),
|
||||||
|
TJWATER_SERVER_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8000"),
|
||||||
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||||
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
||||||
TJWATER_ENABLE_MSW: runtimeBoolean(false)
|
TJWATER_ENABLE_MSW: runtimeBoolean(false)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { mockScadaApi } from "./support/mock-scada-api";
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await prepareWorkbench(page);
|
await prepareWorkbench(page);
|
||||||
@@ -113,6 +114,65 @@ test("mobile launchers and map dock keep non-overlapping touch targets", async (
|
|||||||
expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x);
|
expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mobile timeline only offers collapse after opening its detail sheet", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const timelineSummary = page.getByRole("region", { name: "移动端运行时间轴摘要" });
|
||||||
|
await expect(timelineSummary).toBeVisible();
|
||||||
|
await expect(timelineSummary.getByRole("button", { name: "隐藏运行时间轴" })).toHaveCount(0);
|
||||||
|
await timelineSummary.getByRole("button", { name: "打开完整运行时间轴" }).click();
|
||||||
|
|
||||||
|
const timelineSheet = page.getByRole("region", { name: "运行时间轴抽屉" });
|
||||||
|
await expect(timelineSheet).toBeVisible();
|
||||||
|
await timelineSheet.getByRole("button", { name: "收起时间轴" }).click();
|
||||||
|
await expect(timelineSheet).toBeHidden();
|
||||||
|
await expect(timelineSummary).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile map tool panel stays above the bottom dock", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "更多:更多地图操作" }).click();
|
||||||
|
|
||||||
|
const toolPanel = page.getByRole("region", { name: "更多" });
|
||||||
|
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
|
||||||
|
await expect(toolPanel).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const [panelBox, dockBox] = await Promise.all([
|
||||||
|
toolPanel.boundingBox(),
|
||||||
|
mapDock.boundingBox()
|
||||||
|
]);
|
||||||
|
if (!panelBox || !dockBox) return Number.POSITIVE_INFINITY;
|
||||||
|
return Math.round(panelBox.y + panelBox.height - dockBox.y);
|
||||||
|
})
|
||||||
|
.toBeLessThanOrEqual(-8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile map zoom stays above the bottom dock", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const mapZoom = page.getByRole("group", { name: "地图缩放" });
|
||||||
|
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
|
||||||
|
await expect(mapZoom).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const [zoomBox, dockBox] = await Promise.all([
|
||||||
|
mapZoom.boundingBox(),
|
||||||
|
mapDock.boundingBox()
|
||||||
|
]);
|
||||||
|
if (!zoomBox || !dockBox) return Number.POSITIVE_INFINITY;
|
||||||
|
return Math.round(zoomBox.y + zoomBox.height - dockBox.y);
|
||||||
|
})
|
||||||
|
.toBeLessThanOrEqual(-8);
|
||||||
|
|
||||||
|
const zoomBox = await mapZoom.boundingBox();
|
||||||
|
expect(Math.round(375 - zoomBox!.x - zoomBox!.width)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
test("Agent content buttons do not gain a border while pressed", async ({ page }) => {
|
test("Agent content buttons do not gain a border while pressed", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
@@ -154,7 +214,8 @@ async function prepareWorkbench(page: Page) {
|
|||||||
TJWATER_AUTH_MODE: "disabled",
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||||
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
|
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||||
TJWATER_ENABLE_DEV_PANEL: "false",
|
TJWATER_ENABLE_DEV_PANEL: "false",
|
||||||
TJWATER_ENABLE_MSW: "false"
|
TJWATER_ENABLE_MSW: "false"
|
||||||
@@ -170,4 +231,5 @@ async function prepareWorkbench(page: Page) {
|
|||||||
await page.route("**/*.riv", async (route) => {
|
await page.route("**/*.riv", async (route) => {
|
||||||
await route.abort();
|
await route.abort();
|
||||||
});
|
});
|
||||||
|
await mockScadaApi(page);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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: "{}" });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import { mockAgentApi } from "./support/mock-agent-api";
|
import { mockAgentApi } from "./support/mock-agent-api";
|
||||||
|
import { mockScadaApi } from "./support/mock-scada-api";
|
||||||
|
|
||||||
const EMPTY_RASTER_TILE = Buffer.from(
|
const EMPTY_RASTER_TILE = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
@@ -120,7 +121,8 @@ async function prepareDeterministicWorkbench(page: Page) {
|
|||||||
TJWATER_AUTH_MODE: "disabled",
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
||||||
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||||
TJWATER_ENABLE_DEV_PANEL: "false",
|
TJWATER_ENABLE_DEV_PANEL: "false",
|
||||||
TJWATER_ENABLE_MSW: "false"
|
TJWATER_ENABLE_MSW: "false"
|
||||||
@@ -142,6 +144,7 @@ async function prepareDeterministicWorkbench(page: Page) {
|
|||||||
await page.route("**/*.riv", async (route) => {
|
await page.route("**/*.riv", async (route) => {
|
||||||
await route.abort();
|
await route.abort();
|
||||||
});
|
});
|
||||||
|
await mockScadaApi(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openWorkbench(page: Page) {
|
async function openWorkbench(page: Page) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import { mockAgentApi } from "./support/mock-agent-api";
|
import { mockAgentApi } from "./support/mock-agent-api";
|
||||||
|
import { mockScadaApi } from "./support/mock-scada-api";
|
||||||
|
|
||||||
test("the default workspace docks Agent beside the persistent map", async ({ page }) => {
|
test("the default workspace docks Agent beside the persistent map", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1600, height: 900 });
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
@@ -138,7 +139,8 @@ async function prepareMainFrame(page: Page) {
|
|||||||
TJWATER_AUTH_MODE: "disabled",
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||||
TJWATER_MAP_URL: "https://workspace-map.invalid/geoserver",
|
TJWATER_MAP_URL: "https://workspace-map.invalid/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||||
TJWATER_ENABLE_DEV_PANEL: "true",
|
TJWATER_ENABLE_DEV_PANEL: "true",
|
||||||
TJWATER_ENABLE_MSW: "false"
|
TJWATER_ENABLE_MSW: "false"
|
||||||
@@ -148,4 +150,5 @@ async function prepareMainFrame(page: Page) {
|
|||||||
await page.route("https://workspace-map.invalid/**", async (route) => {
|
await page.route("https://workspace-map.invalid/**", async (route) => {
|
||||||
await route.fulfill({ status: 204 });
|
await route.fulfill({ status: 204 });
|
||||||
});
|
});
|
||||||
|
await mockScadaApi(page);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@ function renderRuntimeConfig(values: Record<string, string>) {
|
|||||||
TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater",
|
TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
|
TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
|
||||||
TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
|
TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater",
|
TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater_next",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: values.TJWATER_SERVER_API_BASE_URL || "http://127.0.0.1:8000",
|
||||||
TJWATER_AGENT_API_BASE_URL: values.TJWATER_AGENT_API_BASE_URL || "http://127.0.0.1:8787",
|
TJWATER_AGENT_API_BASE_URL: values.TJWATER_AGENT_API_BASE_URL || "http://127.0.0.1:8787",
|
||||||
TJWATER_ENABLE_DEV_PANEL: values.TJWATER_ENABLE_DEV_PANEL || "false",
|
TJWATER_ENABLE_DEV_PANEL: values.TJWATER_ENABLE_DEV_PANEL || "false",
|
||||||
TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false"
|
TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false"
|
||||||
|
|||||||
Reference in New Issue
Block a user