From de72eb2281fbb6116a535f6dcc66f8f83255cb05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=88=E5=85=86=E5=A2=9E?= <你的邮箱@example.com> Date: Wed, 3 Jun 2026 15:03:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E6=95=B4=E4=BD=93=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E4=BC=98=E5=8C=96=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=A2=AF?= =?UTF-8?q?=E7=BA=A7=E5=9B=BE=E6=98=BE=E7=A4=BA=E5=92=8C=E5=BC=B9=E6=A1=86?= =?UTF-8?q?=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/docs/地图模块现状与改造方案.md | 1158 +++++++++++++++ frontend/docs/地图模块重构实施清单.md | 380 +++++ frontend/src/api/map.ts | 26 +- frontend/src/assets/icons/gth.svg | 1 + .../components/BaseLayerSwitcher/index.vue | 10 +- frontend/src/components/RightDrawer/index.vue | 63 +- frontend/src/components/gis/GisView.vue | 392 +---- .../src/components/gis/TjCascadeChart.vue | 673 +++++++++ frontend/src/components/gis/TjLayerModal.vue | 538 +++++++ frontend/src/components/gis/map.cesium.ts | 8 +- frontend/src/components/gis/map.class.ts | 12 +- frontend/src/components/gis/map.d.ts | 10 +- frontend/src/components/gis/map.ol.ts | 1305 ++++------------- .../components/gis/ol/point-layer-manager.ts | 325 ++++ .../src/components/gis/ol/popup-manager.ts | 290 ++++ .../components/gis/ol/region-mask-manager.ts | 341 +++++ .../mapController/LayerController.vue | 192 +-- .../src/components/mapController/index.vue | 27 +- frontend/src/components/mapFilter/index.vue | 230 ++- frontend/src/components/mapLegend/index.vue | 146 +- frontend/src/docs/地图加载逻辑.md | 260 ---- frontend/src/layout/components/AppMain.vue | 14 +- .../map/application/map-orchestrator.ts | 425 ++++++ .../src/modules/map/domain/legend-deriver.ts | 147 ++ .../src/modules/map/domain/map-layer-rules.ts | 80 + .../modules/map/stores/map-config.store.ts | 205 +++ .../src/modules/map/stores/map-data.store.ts | 208 +++ .../src/modules/map/stores/map-view.store.ts | 92 ++ frontend/src/store/modules/map.ts | 1188 +++++++-------- frontend/src/utils/enumeration.ts | 196 ++- frontend/src/utils/popupHtmlGenerator.ts | 70 +- frontend/src/utils/request.ts | 2 +- frontend/src/views/login-sjtb/index.vue | 509 ++++--- 33 files changed, 6659 insertions(+), 2864 deletions(-) create mode 100644 frontend/docs/地图模块现状与改造方案.md create mode 100644 frontend/docs/地图模块重构实施清单.md create mode 100644 frontend/src/assets/icons/gth.svg create mode 100644 frontend/src/components/gis/TjCascadeChart.vue create mode 100644 frontend/src/components/gis/TjLayerModal.vue create mode 100644 frontend/src/components/gis/ol/point-layer-manager.ts create mode 100644 frontend/src/components/gis/ol/popup-manager.ts create mode 100644 frontend/src/components/gis/ol/region-mask-manager.ts delete mode 100644 frontend/src/docs/地图加载逻辑.md create mode 100644 frontend/src/modules/map/application/map-orchestrator.ts create mode 100644 frontend/src/modules/map/domain/legend-deriver.ts create mode 100644 frontend/src/modules/map/domain/map-layer-rules.ts create mode 100644 frontend/src/modules/map/stores/map-config.store.ts create mode 100644 frontend/src/modules/map/stores/map-data.store.ts create mode 100644 frontend/src/modules/map/stores/map-view.store.ts diff --git a/frontend/docs/地图模块现状与改造方案.md b/frontend/docs/地图模块现状与改造方案.md new file mode 100644 index 00000000..181bb0e0 --- /dev/null +++ b/frontend/docs/地图模块现状与改造方案.md @@ -0,0 +1,1158 @@ +# 地图模块现状与改造方案 + +## 1. 文档目的 + +本文档用于完整梳理当前地图模块的技术实现、业务能力、数据流转、关键规则、已知问题与性能瓶颈,并给出可分阶段落地的重构方案。目标是: + +- 让地图模块职责边界清晰,便于后续新增功能; +- 降低组件、Store、地图引擎之间的耦合; +- 优化基础图层、锚点、图例、筛选、基地裁切等功能的响应速度; +- 为后续 2D/3D 扩展、复杂专题图层、更多筛选维度、更多交互能力预留稳定结构。 + +--- + +## 2. 当前模块范围 + +当前地图模块覆盖以下业务能力: + +- 地图初始化与 2D/3D 切换; +- GeoServer 基础底图、叠加图层、区域矢量图层加载; +- Java 后端锚点数据请求、缓存、渲染; +- 右侧图层树勾选控制图层显示与隐藏; +- 左侧图例基于图层选中结果动态生成; +- 图例项点击控制锚点显隐; +- 基地选择后底图裁切与区域外锚点隐藏; +- 地图悬停 Popup、点击详情弹窗; +- 搜索、时间筛选、容量筛选、基地筛选; +- 某些业务图层的互斥显示与特殊联动; +- 局部专题菜单下的缩放级别联动动态图层。 + +--- + +## 3. 当前技术实现概览 + +## 3.1 技术栈 + +- 地图引擎:OpenLayers 作为 2D 主引擎,Cesium 作为 3D 引擎入口; +- 状态管理:Pinia; +- UI 框架:Vue 3 + `script setup` + Ant Design Vue; +- 数据来源: + - GeoServer:基础底图、WMTS、XYZ、GeoJSON 边界; + - Java 后端接口:图层配置、图例配置、锚点数据; +- 业务配置: + - 图层配置与图例配置来自后端; + - 锚点接口映射规则由 `src/store/modules/GisUrlList.ts` 维护。 + +## 3.2 核心文件职责 + +- `src/components/gis/GisView.vue` + + - 地图容器入口; + - 地图初始化; + - 拉取图层配置与图例配置; + - 监听菜单切换、基地切换、缩放联动; + - 挂载 `MapLegend`、`MapFilter`、`MapController`、`BaseLayerSwitcher`。 + +- `src/components/gis/map.class.ts` + + - 地图能力门面类; + - 对外屏蔽 OpenLayers / Cesium 差异; + - 当前主要起到转发作用。 + +- `src/components/gis/map.ol.ts` + + - OpenLayers 主实现; + - 负责底图、锚点图层、Popup、图层显隐、区域裁切、量算、定位等; + - 当前承担了过多业务规则与渲染控制逻辑。 + +- `src/store/modules/map.ts` + + - 地图图层树、图例、锚点、缓存的核心 Store; + - 负责图层选中状态、图例选中状态、锚点请求、缓存、图例联动、图层联动; + - 当前是业务与渲染耦合最重的文件之一。 + +- `src/components/mapController/LayerController.vue` + + - 图层树 UI; + - 负责初始基础图层加载; + - 勾选事件直接调用 Store 更新图层与图例状态; + - 包含多个互斥业务规则。 + +- `src/components/mapLegend/index.vue` + + - 图例展示; + - 基于当前选中图层动态显示; + - 图例点击后直接驱动地图锚点显隐。 + +- `src/components/mapFilter/index.vue` + + - 搜索、基地筛选、容量筛选、时间筛选; + - 直接依赖 `mapStore` 和地图实例; + - 部分筛选逻辑通过直接修改图例和锚点实现。 + +- `src/components/BaseLayerSwitcher/index.vue` + + - 底图模式切换器; + - 切换矢量 / 地形 / 影像底图。 + +- `src/components/gis/gisUtils.ts` + + - 图层结构拍平; + - 基础图层 URL 处理; + - 图例数据转对象; + - 若干地图布局相关工具。 + +- `src/api/map.ts` + - 拉取地图配置和图例配置。 + +--- + +## 4. 当前业务能力与逻辑链路 + +## 4.1 初始化流程 + +当前初始化主要发生在 `GisView.vue`: + +1. 调用 `MapClass.init()` 初始化 OpenLayers; +2. 调用 `initPopupOverlay()` 挂载悬停 Popup; +3. 调用 `fetchMapConfigs()` 并行请求: + - 地图图层配置; + - 全量图例配置; + - 当前页面图例配置; +4. `mapStore.setLegendData()` 初始化图例原始数据与映射; +5. `mapStore.setLayerData()` 初始化图层树和选中图层 key; +6. `mapStore.loadAllLayerData()` 递归加载所有有 URL 的图层数据; +7. `loadLayerData()` 将锚点数据写入缓存并直接调用 `mapClass.addInitDataLayer()` 预创建锚点图层; +8. 全部锚点加载完成后,再调用 `updateLayerData()` 统一控制初始显示状态; +9. 图例根据当前选中的图层动态生成并展示。 + +### 现状特点 + +- 当前是“初始化阶段一次性预加载大多数锚点图层,再通过显隐控制”的策略; +- 图例显示被设计为“锚点完成后再显示”; +- 地图实例、Store、UI 组件三方互相直接调用。 + +## 4.2 基础图层加载与切换 + +基础底图主要由 `LayerController.vue` 和 `map.ol.ts` 协同完成: + +- `LayerController.vue` 中监听图层树配置; +- 对类型为 `GISMap` 的节点解析 `paramJson`,通过 `getMapConfig()` 补齐 GeoServer 地址; +- 调用 `mapClass.addBaseDataLayer()` 初始化基础图层; +- 同时调用 `controlBaseLayerTreeShowAndHidden()` 控制显隐; +- `BaseLayerSwitcher` 再通过 `baseLayerSwitcher()` 在矢量 / 地形 / 影像底图之间切换。 + +当前基础图层类型主要包括: + +- WMTS:GeoServer WMTS 瓦片; +- XYZ:例如 DEM 或其他切片图层; +- vector:部分矢量底图或区域配置。 + +### 自然保护区图层特点 + +- 属于叠加在底图之上的 GIS 图层; +- 显隐仍通过基础图层控制逻辑处理; +- 与区域裁切场景存在较强耦合。 + +## 4.3 图层树勾选控制 + +右侧图层树由 `LayerController.vue` 驱动: + +- 树节点勾选结果通过 `broadcast()` 调用 `mapStore.updateLayerData()`; +- 图层树中包含多种特殊规则: + - 视频监控站与 AI 视频监控站互斥; + - 环保设施和环保设施在建互斥; + - 珍稀鱼类与沿程鱼类互斥; +- 初始选中状态通过递归遍历图层树生成。 + +### 现状问题 + +- 互斥逻辑分散在 UI 组件和 Store 两处; +- “图层树状态”与“地图真实状态”并非单向流; +- 图层节点既承担展示,又携带业务配置与运行态字段。 + +## 4.4 锚点数据加载 + +锚点数据由 `mapStore.loadAllLayerData()` 和 `loadLayerData()` 负责: + +- 遍历图层树中全部带 URL 的图层; +- 使用 `GisUrlList.ts` 将图层 key / title / url 映射成真实请求地址、筛选条件、排序规则; +- 请求 Java 接口; +- 将返回记录补充: + - `iconCode` + - `code` + - `_id` + - `tm` +- 然后缓存到: + - `pointDataCache[layerKey]` + - `layer.data` + - 合并后的 `pointData` +- 若地图上还不存在该图层,则直接调用 `mapClass.addInitDataLayer()` 创建 `VectorLayer`。 + +### 当前锚点渲染方式 + +- 每个 `pointMap` 图层对应一个 `VectorLayer`; +- 每个点被转为 `Feature`; +- Feature 上维护多个运行态字段: + - `_iconUrl` + - `_labelText` + - `_legendVisible` + - `_regionVisible` + - `_layerKey` +- 样式函数 `createPointStyle()` 在渲染时综合判断: + - 图例是否显示; + - 是否在基地区域内; + - 当前缩放级别; + - 点之间距离阈值; + - 是否悬停。 + +## 4.5 图例动态生成与点击联动 + +图例数据来源于两部分: + +- `legendDataOriginal`:后端返回的全量图例; +- `legendDataSelected`:根据当前选中的图层动态筛选后的图例结构。 + +当前机制: + +1. 初始化时拉取全部图例; +2. 通过 `layerCode` 与图层树选中结果建立关联; +3. `mapStore.updateLayerData()` 重新计算当前选中的图例树; +4. `MapLegend` 监听 `legendDataSelected` 进行展示; +5. 点击图例项后: + - 更新 Store 中图例 checked 状态; + - 若是锚点图层,则调用 `mapClass.setLegendPointVisible()`; + - 若是 GIS 图层,则调用 `controlBaseLayerTreeShowAndHidden()`。 + +### 环保设施特殊逻辑 + +- 勾选 `fp_point`、`eq_point`、`fb_point`、`vp_point`、`va_point`、`sg_point`、`dw_point` 中任意图层时,图例自动补充“环保设施”分组; +- 全部取消时自动移除该图例分组。 + +## 4.6 筛选与搜索 + +`MapFilter` 负责以下功能: + +- 装机容量筛选; +- 锚点关键字搜索; +- 时间筛选; +- 基地筛选; +- 个别专题下的鱼类分布筛选。 + +当前实现方式: + +- 搜索下拉列表从 `pointData` 里计算; +- 只展示当前已选中图层中的锚点; +- 容量筛选直接修改图例状态,并调用 `map.mdLayerShowOrHidden()` 控制 feature 显隐; +- 时间筛选通过 `mapStore.updateSearchTimeRange()` 清缓存、删图层、重新请求; +- 基地切换通过监听 `JidiSelectEventStore.selectedItem` 联动地图区域裁切。 + +## 4.7 基地选择与区域裁切 + +基地逻辑入口在 `GisView.vue` 监听器中: + +- 当基地变化时,调用 `mapClass.jdPanelControlShowAndHidden()`; +- `map.ol.ts` 内会: + - 更新当前 `BASEID`; + - 请求基地 GeoJSON 边界; + - 隐藏所有锚点; + - 通过射线法判断锚点是否在区域内; + - 标记 `_regionVisible`; + - 调整视角; + - 对底图应用遮罩。 + +### 该能力的业务效果 + +- 只显示当前基地区域内的锚点; +- 底图裁切到当前基地范围; +- 全选时取消遮罩并显示全部锚点。 + +## 4.8 Popup 与点击详情 + +当前交互如下: + +- `pointermove` 时调用 `detectFeatureAtPixel()` 检查命中点要素; +- 若命中图标区域,则: + - 更新鼠标指针; + - 更新 `hoveredFeatureId`; + - 调用 `showPopup()` 展示悬停浮窗; + - 重绘所有点图层; +- `click` 命中点要素时,打开详情弹窗并将 feature 属性写入 `modelStore`。 + +### 当前特点 + +- Popup 内容由 `popupHtml` 或 `generatePopupHtml(props)` 生成; +- 命中检测采用逐像素回调加距离二次判断; +- 所有点图层在 hover 状态切换时都会 `layer.changed()`。 + +## 4.9 菜单与缩放级别联动 + +在水电开发状况菜单下: + +- 地图缩放级别大于等于 12 时自动添加一组动态图层; +- 缩回 12 以下时自动移除; +- 菜单切换时会根据当前层级重新判断是否添加。 + +当前动态图层 key 包括: + +- `dw_point` +- `stinfo_video_point` +- `stinfo_gjllz_point` +- `wt_point` +- `wq_ownWq_point` +- `wq_countryWq_point` +- `fp_point` +- `eq_point` +- `fb_point` +- `vp_point` +- `va_point` +- `sg_point` + +--- + +## 5. 当前数据流与调用关系 + +当前主数据流可概括为: + +1. `GisView.vue` 拉取配置; +2. `mapStore` 保存图层树、图例、缓存; +3. `LayerController.vue` / `MapLegend.vue` / `MapFilter.vue` 直接操作 `mapStore`; +4. `mapStore` 又直接调用 `mapClass`; +5. `mapClass` 继续转发到 `map.ol.ts`; +6. `map.ol.ts` 内部维护图层注册表和 Feature 运行态; +7. 地图状态变化后,组件又通过监听 Store 再次更新 UI。 + +### 现有结构本质 + +当前是“多入口、多方向写状态”的结构: + +- 组件可以改 Store; +- 组件可以直接调地图; +- Store 可以直接调地图; +- 地图内部又维护一套运行态; +- 图例、图层树、地图显隐存在多份状态镜像。 + +这会导致: + +- 状态同步成本高; +- 某些场景下容易出现 UI 勾选状态与实际地图状态不一致; +- 新增规则时只能继续在现有逻辑上叠加 `if/else`。 + +--- + +## 6. 当前存在的主要问题 + +## 6.1 架构层面问题 + +### 1. UI、业务编排、地图渲染耦合过深 + +- `LayerController.vue`、`MapLegend.vue`、`MapFilter.vue` 都直接调 Store 和地图实例; +- `map.ts` 同时负责: + - 请求; + - 缓存; + - 图例结构整理; + - 图层勾选; + - 地图显隐; + - 业务互斥; +- `map.ol.ts` 同时负责: + - OpenLayers 适配; + - 业务过滤; + - 区域裁切; + - 弹窗; + - 图例控制; + - 锚点显示策略。 + +### 2. 缺乏清晰的单向数据流 + +- 图层树 checked、图例 checked、Feature `_legendVisible`、Layer visible 都是状态源; +- 任何一个点修改后都可能触发联动,难以推断最终状态; +- 某些流程依赖顺序正确才能表现正常。 + +### 3. 业务规则分散 + +- 互斥规则分散在 `LayerController.vue` 和 `map.ts`; +- 环保设施图例补充逻辑在 `map.ts`; +- 装机容量筛选逻辑在 `MapFilter.vue`; +- 基地区域过滤逻辑在 `map.ol.ts`; +- 这些规则没有统一归口,新增业务会继续扩大分散度。 + +## 6.2 性能问题 + +### 1. 初始化阶段预加载过重 + +- `loadAllLayerData()` 会递归处理大量图层; +- 即使图层未勾选,也会提前请求并建图层; +- 页面首次进入时网络压力和首屏渲染压力较大。 + +### 2. 递归遍历频繁 + +- 图层树查找 `findLayerByKey()` 多次递归; +- 图例生成 `getlegendData()` 递归; +- `updateLayerData()` 每次变化都要递归更新 checked 状态; +- 搜索项计算 `anchorPointOptions` 也会递归收集图层 key。 + +### 3. 锚点显隐是全量 feature 遍历 + +- 图例点击时逐个遍历 feature; +- 基地裁切时逐个 feature 做点在面内判断; +- hover 时所有点图层重绘; +- 缩放变化会重复执行样式计算。 + +### 4. 底图切换会 remove / add 图层 + +- `baseLayerSwitcher()` 每次切换都移除旧底图再新增; +- 对 WMTS / XYZ 来说会造成重复创建图层对象和重复请求开销。 + +## 6.3 一致性与可靠性问题 + +### 1. 存在明显临时代码与未收敛逻辑 + +- `GisView.vue` 中 `fetchMapConfigs()` 里对图层做了 `splice(0, 2)` 临时截断; +- 日志仍显示“临时限制”,说明线上逻辑可能仍处于测试状态。 + +### 2. 存在未定义引用 + +- `GisView.vue` 切换地图后调用 `fetchPointData()`,但项目中未找到定义; +- `map.ts` 中使用了 `wq_lineTime`、`wt_lineTime`,但项目中未找到定义。 + +### 3. 请求去重逻辑未真正落地 + +- `map.ts` 中生成了 `requestIdentifier`,但未实际用于请求去重; +- `requestParamsCopy` 也未参与有效逻辑。 + +### 4. 图层状态与渲染状态容易失同步 + +- `layer.checked`、`checkedLayerKeys`、图例 checked、Feature 可见状态是多份数据; +- 其中任意一份被跳过更新,就可能出现视觉不一致。 + +### 5. 部分代码存在明显遗留痕迹 + +- `addInitDataLayer()` 中存在无意义语句 `3;`; +- 有重复日志; +- 有注释掉的大量旧逻辑; +- 说明代码迁移过程中缺乏结构性收口。 + +## 6.4 可维护性问题 + +### 1. Store 过大 + +- `map.ts` 既是数据仓库,又是调度器,又是业务规则中心; +- 后续新增图层类型、筛选类型、专题逻辑时,文件会持续膨胀。 + +### 2. MapOl 职责过多 + +- 当前 `map.ol.ts` 既是 OpenLayers 适配器,又是图层经理、锚点经理、Popup 经理、区域裁切经理; +- 单文件理解和修改成本已经偏高。 + +### 3. 缺少规范化类型系统 + +- 大量 `any`; +- 图层配置、图例配置、锚点数据、请求映射结构都没有稳定类型; +- 特殊图层规则依赖字符串常量散落在各处。 + +--- + +## 7. 改造目标 + +本次改造建议达成以下目标: + +### 7.1 结构目标 + +- 建立清晰的四层结构:视图层、应用编排层、领域状态层、地图渲染层; +- 收拢业务规则,不再散落到多个 UI 组件和地图引擎实现中; +- 建立稳定的数据模型与类型定义。 + +### 7.2 性能目标 + +- 首屏只加载必要图层; +- 选中图层优先加载,未选中图层延后或懒加载;(待考虑) +- 图例切换、基地切换、搜索筛选尽量使用索引和差量更新; +- 减少重复递归、重复建图层、重复样式重算。 + +### 7.3 维护目标 + +- 新增图层类型、专题规则、图例规则时有固定扩展点; +- 2D/3D 共享统一业务层,底层只替换渲染适配器; +- 可以较容易增加埋点、日志、调试工具和单元测试。 + +--- + +## 8. 目标架构设计 + +建议将地图模块拆分为四层: + +## 8.1 视图层 + +只负责展示和用户交互,不直接编写复杂业务逻辑。 + +建议保留的组件: + +- `GisView.vue` +- `LayerController.vue` +- `MapLegend.vue` +- `MapFilter.vue` +- `BaseLayerSwitcher.vue` + +视图层职责: + +- 展示树、图例、筛选项、底图切换器; +- 分发用户动作; +- 订阅 Store 派生结果; +- 不直接调用 `map.ol.ts` 里的业务方法。 + +## 8.2 应用编排层 + +新增地图编排层,建议命名为: + +- `src/modules/map/application/map-orchestrator.ts` +- 或 `src/components/gis/core/orchestrator.ts` + +职责: + +- 承接 UI 动作; +- 统一处理“图层勾选、图例勾选、基地切换、时间筛选、搜索定位、底图切换”等命令; +- 控制加载顺序; +- 根据状态差异驱动地图渲染层; +- 管理互斥规则和特殊规则。 + +### 推荐模式 + +所有 UI 动作统一转换成 Command: + +- `toggleLayer(layerKey)` +- `toggleLegend(layerKey, legendKey)` +- `switchBaseMap(baseMapKey)` +- `changeBaseRegion(baseId)` +- `changeTimeRange(range)` +- `focusPoint(pointId)` + +## 8.3 领域状态层 + +保留 Pinia,但将当前大 Store 拆分为多个职责明确的 Store / Service: + +- `mapConfigStore` + + - 保存图层树、图例原始配置、页面配置; + +- `mapViewStateStore` + + - 保存当前选中图层、选中图例、当前底图、当前基地、当前时间范围、当前缩放级别; + +- `mapDataStore` + + - 保存锚点缓存、请求状态、加载状态、错误状态; + +- `mapRuleEngine` + - 保存互斥规则、派生规则、特殊专题规则。 + +## 8.4 地图渲染层 + +保留 `MapClass` 作为统一门面,但将 `MapOl` 拆分成多个 Manager: + +- `ol-map-adapter.ts` + + - 只负责地图实例生命周期; + +- `ol-base-layer-manager.ts` + + - 负责 WMTS / XYZ / GeoJSON / 底图切换; + +- `ol-point-layer-manager.ts` + + - 负责锚点图层创建、更新、索引、显隐; + +- `ol-popup-manager.ts` + + - 负责 hover / click / popup; + +- `ol-region-mask-manager.ts` + + - 负责基地裁切、遮罩、点在面内过滤; + +- `ol-interaction-manager.ts` + - 负责缩放监听、量算、定位等。 + +这样做后,`MapOl` 只保留聚合职责,不再直接承载全部细节。 + +--- + +## 9. 详细改造方案 + +## 9.1 统一领域模型 + +首先建立类型定义,避免继续依赖 `any`。 + +建议新增: + +- `types/map-layer.ts` +- `types/map-legend.ts` +- `types/map-point.ts` +- `types/map-rule.ts` + +### 图层类型建议 + +- `BaseMapLayer` +- `OverlayLayer` +- `PointLayer` +- `RegionMaskLayer` +- `DynamicLayer` + +### 图层运行态建议(待考虑) + +将运行态从后端配置中剥离,不再直接把运行态写回原始配置: + +- `config`: 后端返回配置,只读; +- `runtime`: + - `visible` + - `loaded` + - `loading` + - `error` + - `featureCount` + - `lastRequestKey` + - `lastUpdatedAt` + +## 9.2 统一图层树、图例、地图显隐状态源 + +建议明确一个唯一状态源: + +- 图层是否勾选:只认 `selectedLayerKeys`; +- 图例是否勾选:只认 `selectedLegendKeys`; +- 地图渲染层不再存业务真相,只接收派生后的可见性差量。 + +### 派生关系建议 + +- `selectedLayerKeys` -> 派生当前显示的图例组; +- `selectedLayerKeys + selectedLegendKeys + currentBaseId` -> 派生 Feature 可见性; +- `selectedBaseMapKey` -> 派生当前底图组合; +- `timeRange + filterConditions` -> 派生图层请求签名。 + +## 9.3 建立图层索引与图例索引 + +为提升速度,初始化配置后立即建立索引: + +- `layerByKey` +- `legendByNameEn` +- `legendByLayerCode` +- `childrenByParentKey` +- `layerDescendantsByKey` +- `featureIndexByLayerAndLegendKey` + +### 优化收益 + +- 避免每次 `findLayerByKey()` 递归; +- 图例切换时直接定位对应图层和对应 feature 集合; +- 图层树勾选联动时只处理差异节点。 + +## 9.4 抽离请求解析器 + +当前 `GisUrlList.ts` 的匹配逻辑散落在 `loadLayerData()` 中,建议拆出: + +- `map-layer-request-resolver.ts` + +职责: + +- 根据 `layer.key` / `layer.title` / `layer.url` 解析真实请求; +- 生成标准请求结构: + - `url` + - `method` + - `filter` + - `sort` + - `cacheKey` + +### 进一步建议 + +- 在应用启动时将 `GisUrlList.ts` 预编译成多索引字典; +- 不要在每次 `loadLayerData()` 内重新构建 `urlListDict`; +- 请求签名应真正参与去重和缓存。 + +## 9.5 重新设计锚点加载策略(待考虑) + +建议从“全量预加载”改为“分层加载”: + +### 第一阶段 + +- 首屏只加载默认勾选图层; +- 图例也只基于已加载的默认勾选图层生成; + +### 第二阶段(待考虑) + +- 对常用图层做后台低优先级预取; +- 非常规图层在首次勾选时再加载; + +### 第三阶段(待考虑) + +- 对时间敏感或高频筛选图层使用缓存失效策略: + - `cacheKey = layerKey + requestSignature` + - 基于时间范围、基地、专题条件生成。 + +### 配套措施(待考虑) + +- 增加并发控制,例如同时最多 4 到 6 个接口请求; +- 增加可取消请求,避免菜单切换时旧请求回写新页面; +- 记录每个图层单独 loading 和 error 状态。 + +## 9.6 重构点图层渲染管理 + +建议将点图层改造成“图层级管理 + 索引级更新”模式。 + +### 当前问题 + +- 图例点击靠遍历全量 feature; +- 基地裁切靠遍历全量 feature; +- hover 时所有点图层都重绘。 + +### 改造建议 + +在 `PointLayerManager` 中维护以下索引: + +- `featuresByLayerKey` +- `featuresByLegendKey` +- `featuresByBaseId` +- `featureById` + +### 改造后的显隐策略 + +- 图例点击时,只处理该图例对应 feature 集合; +- 基地切换时,优先按 `baseId` 做粗筛,再对边界交叉区域做点在面内判断; +- 图层勾选时只切换对应 `VectorLayer.visible`; +- 非必要不重新 `addFeatures()`。 + +### 样式策略建议 + +- 保留 style function,但减少在 style function 内做复杂业务判断; +- 将 `_legendVisible`、`_regionVisible`、`_layerVisible` 预先计算为单个 `renderVisible` 标记; +- 样式函数只做: + - 是否可见; + - 当前缩放级别; + - 当前 hover 状态; + - 图标与文本样式计算。 + +## 9.7 底图与叠加层管理重构(待考虑) + +建议明确区分三类图层: + +- 主底图:矢量 / 地形 / 影像三选一; +- 基础叠加层:自然保护区、行政边界等可附加; +- 专题锚点层:水电工程、生态流量、水质站、视频站等。 + +### 当前问题 + +- 底图切换会 remove / add `customBaseLayer`; +- 主底图和叠加底图概念混杂; +- `controlBaseLayerTreeShowAndHidden()` 实际控制的不全是底图。 + +### 改造建议 + +- 维护 `currentBaseMapKey`; +- 主底图预创建,切换时只改 visible; +- 叠加层作为独立 overlay 管理; +- 自然保护区等图层明确归类为 overlay,不与主底图复用同一逻辑。 + +## 9.8 图例模块重构 + +建议把“图例结构”和“图例选中态”彻底分离: + +- `legendSchema`: 后端原始图例树; +- `legendVisibleTree`: 根据当前图层选择派生; +- `legendCheckedSet`: 当前选中的图例 key 集。 + +### 这样做的好处 + +- 后端原始图例树不再被运行态污染; +- 图例点击只更新 `legendCheckedSet`; +- 图层变化只重算当前显示哪些图例,不直接修改原始结构; +- 更容易实现“重置图例”、“记忆用户图例偏好”。 + +## 9.9 基地裁切与区域过滤重构 + +建议将基地逻辑完全收口到 `RegionMaskManager`。 + +### 改造重点 + +- 缓存基地 GeoJSON; +- 一次解析边界,多次复用; +- 对点位先按 `baseId` 粗筛,再按 polygon 精筛; +- 避免每次基地切换都重新请求已缓存边界; +- 底图遮罩和锚点区域过滤使用同一份区域上下文。 + +### 进一步优化 + +- 若后端能返回基地与锚点的归属关系,可优先直接按 `baseId` 过滤; +- 点在面内判断只作为兜底校验; +- 对非常大的多边形可预处理 bbox,先做 bbox 命中,再做射线法。 - +- 有一个 baseID 来传值 直接请求接口数据加载 +- const url = this.hydropBaseConfig.geojson_url + `&cql_filter=BASEID='${this.BASEID}'`; console.log('正在请求裁切数据:', url); + + // 等待数据加载 + const geoJsonData = await this.loadGeoJsonData(url); + +## 9.10 Popup 与交互重构 + +建议将 Popup 逻辑从 `MapOl` 中抽离: + +- `PopupManager` 负责: + - hover 命中检测; + - popup 位置; + - popup 内容渲染; + - click 详情回调; + +### 优化方向 + +- 命中检测尽量只针对当前可见的点图层; +- 对 hover 更新增加最小移动阈值; +- 只重绘当前命中 feature 所在图层,而不是所有点图层; +- Popup 内容生成应支持模板注册,不直接在地图层拼接业务 HTML。 + +## 9.11 业务规则引擎化 + +建议把分散的特殊规则抽到统一规则层。 + +### 当前应纳入规则引擎的规则 + +- 视频监控站与 AI 视频监控站互斥; +- 环保设施与环保设施在建互斥; +- 珍稀鱼类与沿程鱼类互斥; +- 环保设施图例自动补充; +- 水电开发状况菜单的缩放级别动态图层; +- 水质断面多接口拼接; +- 生态流量三级数据结构处理。 + +### 建议实现方式 + +定义规则接口: + +```ts +interface MapBusinessRule { + id: string; + when(context: RuleContext): boolean; + apply(context: RuleContext): RulePatch; +} +``` + +这样新增业务时只加规则文件,不再继续往组件和 Store 塞判断。 + +## 9.12 组件层改造建议 + +### `GisView.vue` + +- 只保留地图容器、初始化、模块挂载; +- 配置请求交给 `mapOrchestrator.initialize(pageKey)`; +- 菜单切换、基地切换也只发送命令。 + +### `LayerController.vue` + +- 只处理树控件展示与事件抛出; +- 互斥逻辑不要写在组件中; +- 初始底图加载不要放在组件 watch 里,改到编排层统一处理。 + +### `MapLegend.vue` + +- 只负责展示图例树和发出切换事件; +- 不直接调用 `mapClass`。 + +### `MapFilter.vue` + +- 搜索、容量、时间、基地变化全部改为发送标准命令; +- 过滤后的锚点候选列表由 Store 派生,不在组件里自行拼装业务逻辑。 + +### `BaseLayerSwitcher.vue` + +- 只负责切换主底图; +- 不关心图层树中 `customBaseLayer` 的内部结构。 + +## 9.13 日志、监控与调试能力(待考虑) + +建议补充以下调试能力: + +- 图层加载耗时; +- 单图层接口耗时; +- 首屏加载耗时; +- 当前可见图层列表; +- 当前可见锚点数量; +- 当前选中图例数量; +- 当前缓存命中率; +- 当前基地裁切耗时。 + +可以在开发模式下提供一个简易的地图调试面板,便于排查性能与状态同步问题。 + +--- + +## 10. 分阶段落地方案 + +建议采用“四阶段渐进式改造”,避免一次性大改导致风险过高。 + +## 第 1 阶段:收口现状与止血 + +目标:不改变功能表现,先把明显风险收口。 + +建议内容: + +- 去掉测试遗留逻辑,如 `splice(0, 2)`; +- 修复未定义方法和未定义变量; +- 清理重复日志、无效代码、注释垃圾; +- 补充类型定义; +- 建立 `layerByKey`、`legendByNameEn` 等基础索引; +- 将互斥规则从组件内搬到 Store 或规则层统一处理; +- 实现真实请求去重和请求取消。 + +## 第 2 阶段:拆 Store 与编排层 + +目标:建立单向数据流。 + +建议内容: + +- 新增 `mapOrchestrator`; +- 拆 `map.ts` 为配置、状态、数据三个模块; +- UI 组件只发送命令; +- 图例派生、图层派生、筛选派生全部收口到编排层。 + +## 第 3 阶段:拆地图渲染层 + +目标:把 `MapOl` 大文件拆开。 + +建议内容: + +- 抽 `BaseLayerManager`; +- 抽 `PointLayerManager`; +- 抽 `PopupManager`; +- 抽 `RegionMaskManager`; +- 明确地图引擎层只负责“渲染执行”,不处理业务真相。 + +## 第 4 阶段:性能强化与能力扩展 + +目标:让模块适合长期迭代。 + +建议内容: + +- 实现懒加载与后台预取; +- feature 索引化; +- 地图交互性能优化; +- 支持规则插件化; +- 补充测试与性能指标看板; +- 为 3D 共用业务层做适配。 + +--- + +## 11. 推荐目录结构 + +建议重构后目录大致如下: + +```text +src/ + modules/ + map/ + application/ + map-orchestrator.ts + map-commands.ts + map-rule-engine.ts + domain/ + types/ + map-layer.ts + map-legend.ts + map-point.ts + map-filter.ts + services/ + layer-request-resolver.ts + legend-deriver.ts + layer-deriver.ts + infrastructure/ + stores/ + map-config.store.ts + map-view-state.store.ts + map-data.store.ts + repositories/ + map-config.repository.ts + map-point.repository.ts + render/ + map.class.ts + ol/ + ol-map-adapter.ts + ol-base-layer-manager.ts + ol-point-layer-manager.ts + ol-popup-manager.ts + ol-region-mask-manager.ts + ol-interaction-manager.ts + components/ + gis/ + GisView.vue + mapController/ + LayerController.vue + mapLegend/ + index.vue + mapFilter/ + index.vue + BaseLayerSwitcher/ + index.vue +``` + +--- + +## 12. 重点业务的改造策略 + +## 12.1 基础图层 + +策略: + +- 单独定义主底图与叠加图层; +- 预创建主底图; +- 切换时仅改 visible; +- `customBaseLayer` 不再作为特殊魔法 key 到处透传。 + +## 12.2 水电工程锚点 + +策略: + +- 作为标准 `PointLayer` 处理; +- 保留当前图标、标签、距离阈值、Popup 规则; +- 在 `PointLayerManager` 中建立 feature 索引。 + +## 12.3 生态流量三级数据 + +策略: + +- 在领域层定义树结构转换器; +- Store 不直接关心层级深浅; +- 图层树组件只拿已经标准化后的树。 + +## 12.4 水质监测断面三接口拼接 + +策略: + +- 不在 UI 或 Store 中散写拼接逻辑; +- 建立专题数据聚合器,例如 `water-quality-point-aggregator.ts`; +- 对外输出统一的 `PointLayerData`。 + +## 12.5 视频监控站与 AI 视频监控站互斥 + +策略: + +- 写成标准业务规则; +- 由规则引擎统一裁剪勾选结果; +- UI 不再内嵌业务互斥判断。 + +## 12.6 自然保护区与区域底图 + +策略: + +- 统一归类为 `OverlayLayer`; +- 与主底图切换分离; +- 与基地裁切共享区域上下文。 + +--- + +## 13. 测试与验收建议 + +## 13.1 功能验收清单 + +- 页面首次进入,默认勾选图层正确显示; +- 图层树勾选与取消勾选,地图表现与图例表现一致; +- 图例点击后,仅影响对应锚点; +- 基地切换后,区域外锚点正确隐藏; +- 底图切换不影响锚点和图例状态; +- 时间筛选后相关图层正确重载; +- 视频监控站与 AI 视频监控站互斥稳定; +- 菜单切换后图层、图例、筛选项正确重置; +- 悬停 Popup 与点击详情正常。 + +## 13.2 性能验收指标 + +建议至少跟踪: + +- 首屏地图可交互时间; +- 默认勾选图层加载总耗时; +- 单图层首次加载耗时; +- 图层勾选后锚点出现时间; +- 图例点击后的显隐响应时间; +- 基地切换后的裁切完成时间; +- 缓存命中率; +- 页面切换时旧请求是否被取消。 + +## 13.3 自动化测试建议 + +建议补充三类测试: + +- 单元测试: + + - 图例派生; + - 图层互斥规则; + - 请求解析器; + - 缓存 key 生成; + +- 集成测试: + + - 图层勾选 -> 图例变化 -> 地图显隐; + - 基地切换 -> 区域裁切 -> 搜索列表更新; + +- 端到端测试: + - 地图初始化; + - 图层切换; + - 图例切换; + - 筛选联动; + - Popup 和详情弹窗。 + +--- + +## 14. 优先级最高的首批改造项 + +如果本轮改造只做最有价值的一批,建议优先按以下顺序处理: + +1. 修复明显问题: + + - 去掉 `splice(0, 2)`; + - 修复 `fetchPointData()` 未定义; + - 修复 `wq_lineTime` / `wt_lineTime` 未定义; + - 清理无效代码与重复日志。 + +2. 建立统一状态源: + + - `selectedLayerKeys` + - `selectedLegendKeys` + - `currentBaseId` + - `currentBaseMapKey` + +3. 抽离请求解析器和规则引擎: + + - URL 映射规则统一; + - 互斥规则统一; + - 特殊图例补充规则统一。 + +4. 拆 `map.ts`: + + - 配置状态; + - 运行状态; + - 数据缓存; + - 行为编排。 + +5. 拆 `map.ol.ts`: + - 底图管理; + - 锚点管理; + - Popup 管理; + - 区域裁切管理。 + +--- + +## 15. 改造后的预期收益 + +完成改造后,地图模块应达到以下效果: + +- 新增图层和专题能力时,不需要修改多个组件和多个文件; +- 图层树、图例、地图显隐的状态不再互相打架; +- 首屏加载更快,图层切换更轻; +- 地图代码从“堆逻辑”变成“可扩展的稳定结构”; +- 后续增加 3D 共用逻辑、专题聚合图层、更多筛选条件时,改造成本明显下降。 + +--- + +## 16. 结论 + +当前地图模块功能已经较完整,但代码结构处于“功能可用、结构偏重、规则分散、性能隐患较多”的阶段。继续在现有结构上叠加新需求,后续维护成本会快速上升。 + +建议本次改造不要只做局部优化,而应围绕以下核心原则整体推进: + +- 单向数据流; +- 业务规则收口; +- 渲染层职责下沉; +- 类型与索引先行; +- 先止血,再拆层,再提速。 + +按照本文档的分阶段方案推进,可以在不一次性推翻现有功能的前提下,逐步完成地图模块的结构升级。 diff --git a/frontend/docs/地图模块重构实施清单.md b/frontend/docs/地图模块重构实施清单.md new file mode 100644 index 00000000..0a977c48 --- /dev/null +++ b/frontend/docs/地图模块重构实施清单.md @@ -0,0 +1,380 @@ +# 地图模块重构实施清单 + +## 1. 使用说明 + +本文档是地图模块重构的执行清单,只保留当前已经明确、可以直接落地的实施项。 + +以下内容暂不纳入本清单: + +- 仍需进一步讨论的方案; +- 依赖后端配合后才能确定的优化项; +- 暂未确定收益与改造成本的预研项; +- 调试面板、埋点体系、长期性能平台化建设等扩展项。 + +执行原则: + +- 先止血,再拆层,再提速; +- 每一步都要保证现有业务可用; +- 每个阶段结束后都要做功能回归; +- 不在同一阶段同时改动 UI、Store、地图渲染层的大块逻辑。 + +--- + +## 2. 本轮重构目标 + +本轮只完成以下确定目标: + +- 收口当前明显缺陷和遗留代码; +- 建立基础类型和索引; +- 将分散在组件中的核心业务规则收口; +- 拆分地图 Store,建立更清晰的数据职责; +- 抽离统一编排层; +- 拆分 `map.ol.ts` 的核心管理职责; +- 保证图层树、图例、锚点显隐链路更清晰; +- 为后续继续重构预留稳定结构。 + +--- + +## 3. 阶段一:现状收口与问题修复 + +## 3.1 清理明显遗留问题 + +- [ ] 删除 `map.ol.ts` 中无意义代码和重复日志 +- [ ] 清理大段失效注释,保留必要说明 +- [ ] 统一地图相关文件中的临时 `console.log` + +涉及文件: + +- `src/components/gis/map.ol.ts` + +验收标准: + +- 页面可正常编译; +- 首次进入地图页面不报错; +- 控制台不再出现未定义函数或变量错误; +- 默认图层、图例、锚点仍可正常显示。 + +## 3.2 补充基础类型定义 + +- [ ] 新增地图图层类型定义 +- [ ] 新增图例结构类型定义 +- [ ] 新增锚点数据类型定义 +- [ ] 新增筛选参数类型定义 +- [ ] 为 `map.ts`、`map.class.ts`、`map.ol.ts` 替换第一批高频 `any` + +建议新增文件: + +- `src/modules/map/types/map-layer.ts` +- `src/modules/map/types/map-legend.ts` +- `src/modules/map/types/map-point.ts` +- `src/modules/map/types/map-filter.ts` + +验收标准: + +- 核心地图模块类型边界可读; +- 新增类型后不影响现有运行; +- `map.ts`、`map.ol.ts` 中关键方法参数不再全部使用 `any`。 + +## 3.3 建立基础索引 + +- [x] 建立 `layerByKey` 索引 +- [x] 建立 `legendByNameEn` 索引 +- [x] 建立 `legendByLayerCode` 索引 +- [x] 将 `findLayerByKey()` 的高频调用逐步替换为索引访问 +- [x] 将图例派生中的重复递归改为“初始化索引 + 增量查询” + +优先改造位置: + +- `src/store/modules/map.ts` + +验收标准: + +- 图层查找和图例查找不再完全依赖全量递归; +- 图层勾选、图例切换后响应速度不下降; +- 功能表现与现状一致。 + +--- + +## 4. 阶段二:规则收口 + +## 4.1 收口互斥规则 + +- [x] 将图层互斥逻辑从 `LayerController.vue` 中移出 +- [x] 将视频监控站与 AI 视频监控站互斥统一到规则函数 +- [x] 将环保设施与环保设施在建互斥统一到规则函数 +- [x] 将珍稀鱼类与沿程鱼类互斥统一到规则函数 +- [x] 保证互斥规则只有一个入口执行 + +建议新增文件: + +- `src/modules/map/domain/map-layer-rules.ts` + +涉及文件: + +- `src/components/mapController/LayerController.vue` +- `src/store/modules/map.ts` + +验收标准: + +- 图层树勾选时互斥结果稳定; +- 不再同时在组件和 Store 两处维护同一套互斥规则; +- 勾选结果、图例结果、地图显示结果保持一致。 + +## 4.2 收口图例派生规则 + +- [x] 将“图层选中后生成图例”逻辑提炼为独立函数 +- [x] 将“环保设施图例自动补充”逻辑提炼为独立规则 +- [x] 分离图例原始结构与图例选中状态 +- [x] 保证 `legendDataOriginal` 不再被运行态逻辑污染 + +建议新增文件: + +- `src/modules/map/domain/legend-deriver.ts` + +涉及文件: + +- `src/store/modules/map.ts` +- `src/components/mapLegend/index.vue` + +验收标准: + +- 图例显示仍按当前选中图层正确联动; +- 图例点击后锚点显隐正确; +- 图例原始结构与运行时状态职责更清晰。 + +--- + +## 5. 阶段三:拆分 Store + +## 5.1 拆分配置状态 + +- [ ] 抽离图层树配置状态 +- [ ] 抽离图例原始配置状态 +- [ ] 抽离页面地图配置加载逻辑 + +建议新增文件: + +- `src/modules/map/stores/map-config.store.ts` + +当前迁移来源: + +- `src/store/modules/map.ts` +- `src/api/map.ts` + +## 5.2 拆分运行状态 + +- [ ] 抽离当前选中图层 key +- [ ] 抽离当前选中图例 key +- [ ] 抽离当前基地、时间范围、缩放级别等运行态 +- [ ] 统一“当前地图状态”的唯一来源 + +建议新增文件: + +- `src/modules/map/stores/map-view.store.ts` + +## 5.3 拆分数据缓存 + +- [ ] 抽离锚点缓存 +- [ ] 抽离图层加载状态 +- [ ] 抽离锚点合并数据 `pointData` +- [ ] 为每个图层维护独立 `loading / loaded / error` + +建议新增文件: + +- `src/modules/map/stores/map-data.store.ts` + +验收标准: + +- 原 `map.ts` 体积明显下降; +- 配置、运行态、数据缓存职责分离; +- 现有组件仍可通过兼容方式读取数据; +- 默认图层、图例、锚点加载不受影响。 + +--- + +## 6. 阶段四:建立统一编排层 + +## 6.1 新增地图编排器 + +- [x] 新增地图模块统一编排入口 +- [x] 将地图初始化流程迁移到编排器 +- [x] 将图层勾选、图例勾选、基地切换、时间筛选、搜索定位改为统一命令 +- [x] 收口 `GisView.vue` 中的配置加载与监听联动逻辑 + +建议新增文件: + +- `src/modules/map/application/map-orchestrator.ts` + +## 6.2 统一入口命令 + +- [x] 定义 `initialize(pageKey)` +- [x] 定义 `toggleLayer(layerKey)` +- [x] 定义 `toggleLegend(layerKey, legendKey)` +- [x] 定义 `changeBaseId(baseId)` +- [x] 定义 `changeTimeRange(range)` +- [x] 定义 `focusPoint(pointId)` + +涉及文件: + +- `src/components/gis/GisView.vue` +- `src/components/mapController/LayerController.vue` +- `src/components/mapLegend/index.vue` +- `src/components/mapFilter/index.vue` + +验收标准: + +- 地图相关 UI 组件不再直接拼复杂业务逻辑; +- 组件交互都通过统一入口触发; +- 图层树、图例、筛选的联动链路更容易追踪。 + +--- + +## 7. 阶段五:拆分 OpenLayers 渲染层 + +## 7.1 拆分点图层管理 + +- [ ] 从 `map.ol.ts` 中抽离锚点图层创建逻辑 +- [ ] 从 `map.ol.ts` 中抽离锚点显隐逻辑 +- [ ] 从 `map.ol.ts` 中抽离图例控制锚点逻辑 +- [ ] 在点图层管理模块中维护图层注册表 + +建议新增文件: + +- `src/components/gis/ol/point-layer-manager.ts` + +当前迁移来源: + +- `src/components/gis/map.ol.ts` + +## 7.2 拆分 Popup 管理 + +- [ ] 抽离 `initPopupOverlay()` +- [ ] 抽离 `detectFeatureAtPixel()` +- [ ] 抽离 `showPopup()` +- [ ] 收口 hover 和 click 交互逻辑 + +建议新增文件: + +- `src/components/gis/ol/popup-manager.ts` + +## 7.3 拆分区域过滤管理 + +- [x] 抽离基地切换后的区域裁切逻辑 +- [x] 抽离点在面内判断逻辑 +- [x] 抽离区域外锚点显隐逻辑 +- [x] 抽离遮罩应用与清理逻辑 + +建议新增文件: + +- `src/components/gis/ol/region-mask-manager.ts` + +验收标准: + +- `map.ol.ts` 文件长度和职责明显收缩; +- 点图层、Popup、基地裁切三类逻辑各自独立; +- 现有地图交互行为不变。 + +--- + +## 8. 阶段六:组件瘦身 + +## 8.1 `GisView.vue` + +- [x] 仅保留地图容器、地图初始化调用、组件挂载 +- [x] 移除配置请求和复杂 watch 编排逻辑 +- [x] 只通过编排器触发初始化和页面切换 + +## 8.2 `LayerController.vue` + +- [x] 只保留树渲染与勾选事件 +- [x] 移除互斥规则 +- [x] 移除基础图层初始化编排逻辑 + +## 8.3 `MapLegend.vue` + +- [x] 只保留图例渲染与点击事件分发 +- [x] 不直接调用地图实例 + +## 8.4 `MapFilter.vue` + +- [x] 只保留表单与用户输入 +- [x] 搜索、容量、时间、基地变化全部通过编排器分发 +- [x] 不直接修改图例运行态和锚点显隐 + +验收标准: + +- 组件文件职责单一; +- UI 组件中不再出现大段业务逻辑; +- 地图相关行为主要集中在编排层和 Store。 + +--- + +## 9. 阶段七:性能优化 + +## 9.1 请求与缓存优化 + +- [x] 将 `GisUrlList.ts` 预编译为索引字典,而不是每次请求时重建 +- [x] 实现真实请求去重 +- [x] 增加请求取消能力,避免菜单切换时旧请求回写 +- [x] 对图层加载增加并发控制 +- [x] 将缓存 key 与图层 key、筛选条件绑定 + +## 9.2 锚点显示优化 + +- [x] 图例切换时只处理目标图例对应的锚点集合 +- [x] 图层切换时优先切换图层可见性,不重复加点 +- [x] 减少 hover 时全量点图层重绘 +- [x] 减少样式函数中的业务判断复杂度 + +验收标准: + +- 首屏加载时长不高于当前; +- 图层勾选、图例勾选、基地切换响应速度优于当前; +- 菜单快速切换时不出现旧数据回写。 + +--- + +## 10. 回归测试清单 + +每完成一个阶段,至少回归以下功能: + +- [x] 地图初始化正常 +- [x] 默认基础图层正常显示 +- [x] 图层树勾选与取消勾选正常 +- [x] 图例随图层选中动态显示 +- [x] 图例点击能正确控制锚点显隐 +- [x] 基地切换后区域裁切和锚点过滤正常 +- [x] 锚点 hover Popup 正常 +- [x] 锚点点击详情弹窗正常 +- [x] 时间筛选、容量筛选、搜索定位正常 +- [x] 视频监控站与 AI 视频监控站互斥正常 +- [x] 菜单切换后地图数据不串页 + +--- + +## 11. 实施顺序建议 + +推荐严格按以下顺序执行: + +1. 阶段一:现状收口与问题修复 +2. 阶段二:规则收口 +3. 阶段三:拆分 Store +4. 阶段四:建立统一编排层 +5. 阶段五:拆分 OpenLayers 渲染层 +6. 阶段六:组件瘦身 +7. 阶段七:性能优化 + +不建议直接跳到渲染层拆分或性能优化,否则会在旧结构上重复返工。 + +--- + +## 12. 本轮交付标准 + +当本清单全部完成后,应达到以下结果: + +- 地图模块核心职责划分清晰; +- 图层树、图例、锚点、筛选、基地切换链路可追踪; +- 关键规则不再散落在多个组件里; +- `map.ts` 和 `map.ol.ts` 体积明显下降; +- 新增地图业务时有明确扩展点; +- 后续继续做底图体系优化、长期性能建设时不需要再推翻本轮结构。 diff --git a/frontend/src/api/map.ts b/frontend/src/api/map.ts index 36d1d99b..dfad5540 100644 --- a/frontend/src/api/map.ts +++ b/frontend/src/api/map.ts @@ -10,7 +10,6 @@ export function getModuleMapLegendList(params?: { moduleId?: string }) { method: 'get' }); } - // 获取地图配置列表 export function getMapList(data: any) { return request({ @@ -19,3 +18,28 @@ export function getMapList(data: any) { data }); } +// 获取梯级流域地图 +export function getQgcRvcd(data: any) { + return request({ + url: '/api/wmp-eng-server/eng/rsvrcscdb/getQgcRvcd', + method: 'post', + data + }); +} +// 获取梯级流域下拉框列表 +export function getRvcdList(data: any) { + return request({ + url: '/api/wmp-eng-server/eng/rsvrcscdb/rvcd', + method: 'post', + data + }); +} + +// 获取梯级流域下拉框图表数据 +export function getKendoList(data: any) { + return request({ + url: '/api/wmp-eng-server/eng/rsvrcscdb/GetKendoList', + method: 'post', + data + }); +} diff --git a/frontend/src/assets/icons/gth.svg b/frontend/src/assets/icons/gth.svg new file mode 100644 index 00000000..d756cbfc --- /dev/null +++ b/frontend/src/assets/icons/gth.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/BaseLayerSwitcher/index.vue b/frontend/src/components/BaseLayerSwitcher/index.vue index c6497fa7..e73d5f91 100644 --- a/frontend/src/components/BaseLayerSwitcher/index.vue +++ b/frontend/src/components/BaseLayerSwitcher/index.vue @@ -22,7 +22,8 @@ @@ -51,7 +52,7 @@ const handleToggle = () => { height: 100%; position: relative; box-sizing: border-box; - pointer-events: all; + pointer-events: all; .drawerController1 { width: 18px; @@ -104,10 +105,12 @@ const handleToggle = () => { justify-content: center; align-items: center; } -.text_she{ +.text_she { font-size: 14px; - color:#262626; + color: #262626; font-variant: tabular-nums; - font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, + Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, + Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; } diff --git a/frontend/src/components/gis/GisView.vue b/frontend/src/components/gis/GisView.vue index 0be10300..e75f22d1 100644 --- a/frontend/src/components/gis/GisView.vue +++ b/frontend/src/components/gis/GisView.vue @@ -1,24 +1,19 @@ diff --git a/frontend/src/components/gis/TjCascadeChart.vue b/frontend/src/components/gis/TjCascadeChart.vue new file mode 100644 index 00000000..1d2d1c33 --- /dev/null +++ b/frontend/src/components/gis/TjCascadeChart.vue @@ -0,0 +1,673 @@ + + + + + diff --git a/frontend/src/components/gis/TjLayerModal.vue b/frontend/src/components/gis/TjLayerModal.vue new file mode 100644 index 00000000..695daf06 --- /dev/null +++ b/frontend/src/components/gis/TjLayerModal.vue @@ -0,0 +1,538 @@ + + + + + diff --git a/frontend/src/components/gis/map.cesium.ts b/frontend/src/components/gis/map.cesium.ts index e5c61180..a31ae0f0 100644 --- a/frontend/src/components/gis/map.cesium.ts +++ b/frontend/src/components/gis/map.cesium.ts @@ -122,7 +122,11 @@ export class MapCesium implements MapInterface { checked: boolean ): void {} mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {} - setLegendPointVisible(layerKey: string, anchoPointState: string, checked: boolean): void {} + setLegendPointVisible( + layerKey: string, + anchoPointState: string, + checked: boolean + ): void {} addInitDataLayer(pointData: any[], layerType: any, mdoptions?: any): void {} switchView(type: any): void {} fitBounds(bounds: any): void {} @@ -133,7 +137,7 @@ export class MapCesium implements MapInterface { outlineColor: any, datas: any ): void {} - removeTertiarybasinLayer(layer: layer): void {} + hideTertiarybasinLayer(layer: layer): void {} zoomToggle(type: 'out' | 'in'): void { if (!this.viewer) return; const factor = 1.5; diff --git a/frontend/src/components/gis/map.class.ts b/frontend/src/components/gis/map.class.ts index 0b286029..9c0ca759 100644 --- a/frontend/src/components/gis/map.class.ts +++ b/frontend/src/components/gis/map.class.ts @@ -53,12 +53,12 @@ export class MapClass implements MapClassInterface { mdLayerShowOrHidden( layerType: string, - baseid: string, key?: string, + baseid: string, checked?: boolean, isAll?: boolean ): void { - this.service.mdLayerShowOrHidden(layerType, baseid, key, checked, isAll); + this.service.mdLayerShowOrHidden(layerType, key, baseid, checked, isAll); } // 添加基础数据图层 addBaseDataLayer(layer: any, isShow: boolean): void { @@ -103,8 +103,8 @@ export class MapClass implements MapClassInterface { this.service.addTertiarybasinLayer(layer, fillcolor, outlineColor, datas); } // 移除梯级流域图 - removeTertiarybasinLayer(layer: layer): void { - this.service.removeTertiarybasinLayer?.(layer); + hideTertiarybasinLayer(layer: layer): void { + this.service.hideTertiarybasinLayer?.(layer); } // 缩放 zoomToggle(type: 'out' | 'in') { @@ -136,6 +136,10 @@ export class MapClass implements MapClassInterface { hasLayer(layerKey: string): void { return this.service.hasLayer(layerKey); } + // 删除描点图层 + removePointLayer(layerKey: string): void { + this.service.removePointLayer?.(layerKey); + } // 销毁地图 destroy(): void { this.service.destroy(); diff --git a/frontend/src/components/gis/map.d.ts b/frontend/src/components/gis/map.d.ts index b0b87d9c..1e8bc2a6 100644 --- a/frontend/src/components/gis/map.d.ts +++ b/frontend/src/components/gis/map.d.ts @@ -163,8 +163,8 @@ export interface MapInterface { */ mdLayerShowOrHidden( layerType: string, - baseid: string, key?: string, + baseid: string, checked?: boolean, isAll?: boolean ): void; @@ -187,6 +187,12 @@ export interface MapInterface { */ mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void; + /** + * 删除描点图层 + * @param layerKey 图层 key + */ + removePointLayer(layerKey: string): void; + /** * 根据 anchoPointState 控制单个图例的锚点显示隐藏 * @param layerKey 图层 key @@ -228,7 +234,7 @@ export interface MapInterface { * 移除梯级流域图 * @param layer */ - removeTertiarybasinLayer(layer: layer): void; + hideTertiarybasinLayer(layer: layer): void; /** * 移除地图对象 */ diff --git a/frontend/src/components/gis/map.ol.ts b/frontend/src/components/gis/map.ol.ts index 151e3046..dadb485b 100644 --- a/frontend/src/components/gis/map.ol.ts +++ b/frontend/src/components/gis/map.ol.ts @@ -1,6 +1,7 @@ import { MapInterface } from './map'; import OlMap from 'ol/Map'; import View from 'ol/View'; +import Overlay from 'ol/Overlay'; import TileLayer from 'ol/layer/Tile'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; @@ -8,15 +9,11 @@ import GeoJSON from 'ol/format/GeoJSON'; import Style from 'ol/style/Style'; import Fill from 'ol/style/Fill'; import Stroke from 'ol/style/Stroke'; -// ✅ 新增导入 import Icon from 'ol/style/Icon'; import Text from 'ol/style/Text'; import Circle from 'ol/style/Circle'; import RegularShape from 'ol/style/RegularShape'; import Cluster from 'ol/source/Cluster'; - -// import LayerGroup from 'ol/layer/Group'; -// import OSM from 'ol/source/OSM'; import WMTS from 'ol/source/WMTS'; import WMTSTileGrid from 'ol/tilegrid/WMTS'; import { get as getProjection, fromLonLat } from 'ol/proj'; @@ -30,21 +27,20 @@ import MouseWheelZoom from 'ol/interaction/MouseWheelZoom'; import { servers } from './mapurlManage'; import { XYZ } from 'ol/source'; import Feature from 'ol/Feature'; -import Point from 'ol/geom/Point'; // ✅ 新增导入 import LineString from 'ol/geom/LineString'; -// import Polygon from 'ol/geom/Polygon'; -import Overlay from 'ol/Overlay'; import { getLength as getSphericalLength, getArea as getSphericalArea } from 'ol/sphere'; import { unByKey } from 'ol/Observable'; import { MDOptions } from './map.class'; -import { getIconPath } from '@/utils/index'; // ✅ 确保引入获取图标路径的工具函数 -import { generatePopupHtml } from '@/utils/popupHtmlGenerator'; import { useModelStore } from '@/store/modules/model'; +import { PointLayerManager } from './ol/point-layer-manager'; +import { PopupManager } from './ol/popup-manager'; +import { RegionMaskManager } from './ol/region-mask-manager'; const modelStore = useModelStore(); +const VITE_APP_MAP_URL = import.meta.env.VITE_APP_MAP_URL; // 定义与 leaflet 中相同的常量 const CENTER_positionCN = [114.17112499999996, 38]; // OpenLayers 使用 [lon, lat] @@ -59,33 +55,40 @@ const BOUNDS_NE = [180.00000000000074, 60.06349386538693]; export class MapOl implements MapInterface { map: OlMap | null = null; view: View | null = null; - // ✅ 新增:存储 key -> layer 实例,用于控制显示隐藏和切换 private layerRegistry: Map = new Map(); - // ✅ 新增:记录当前正在显示的“切换 Key”,用于判断是否需要重新加载或只是切换显隐 - // 基础图层配置 - private baseLayerConfig: TileLayer | null = null; - // 基地配置 + private baseLayerConfig: any | null = null; private hydropBaseConfig: any | null = null; private REGISTRY_KEY = 'customBaseLayer'; - - // ✅ 新增:量算相关属性 + private activeBaseLayerKey = 's_province_boundaries'; + private readonly rasterBaseLayerKeys = ['BASEMAP-white', 'BASEMAP-img']; private drawInteraction: any = null; private measureLayer: VectorLayer | null = null; private measureSource: VectorSource | null = null; - // ✅ 新增:存储点图层的 Registry,key 为 layerType,value 为 VectorLayer - private pointLayerRegistry: Map = new Map(); + private pointLayerManager: PointLayerManager; geoJsonData: any = null; geoJsonData1: any = null; - // ✅ 新增:声明用于存储 mask 事件监听器的引用,以便后续移除 - private _maskPrerender: any = null; - private _maskPostrender: any = null; private BASEID = ''; - // ✅ 新增:记录当前鼠标悬停的 Feature ID + private currentClipGeoJson: any | null = null; private hoveredFeatureId: string | number | null = null; - // ✅ 新增:用于显示自定义内容的 Overlay - private popupOverlay: Overlay | null = null; - private popupElement: HTMLElement | null = null; - constructor() {} + private popupManager: PopupManager; + private regionMaskManager: RegionMaskManager; + constructor() { + this.pointLayerManager = new PointLayerManager({ + map: null, + createStyle: feature => this.createPointStyle(feature) + }); + this.popupManager = new PopupManager({ + map: null, + getPointLayers: () => this.pointLayerManager.getLayers() + }); + this.regionMaskManager = new RegionMaskManager({ + map: null, + view: null, + pointLayerManager: this.pointLayerManager, + defaultCenter: CENTER_positionCN as [number, number], + defaultZoom: INITIAL_ZOOM + }); + } private async loadGeoJsonData(url): Promise { try { const response = await fetch(url); @@ -98,9 +101,6 @@ export class MapOl implements MapInterface { //地图初始化 init(container: HTMLElement): Promise { try { - console.log('OL init初始化container', container); - - // 1. 将经纬度边界转换为 Web Mercator (EPSG:3857) 坐标,用于限制视图范围 const minCoordinate = fromLonLat(BOUNDS_SW); const maxCoordinate = fromLonLat(BOUNDS_NE); const extent = [ @@ -110,7 +110,6 @@ export class MapOl implements MapInterface { maxCoordinate[1] ]; - // 2. 创建视图 (View) this.view = new View({ center: fromLonLat(CENTER_positionCN), // 设置中心点 zoom: INITIAL_ZOOM, @@ -121,18 +120,12 @@ export class MapOl implements MapInterface { smoothExtentConstraint: false // 平滑边界约束 }); - // ✅ 3. 自定义鼠标滚轮交互 - // Leaflet 的 wheelPxPerZoomLevel: 180 意味着缩放比较“慢/平滑”。 - // OpenLayers 默认每次滚动 delta 为 1/3 级。 - // 我们可以减小 delta 值来让缩放更平滑(需要更多滚动次数才能变一级), - // 或者使用 constrainResolution: false 允许非整数缩放,获得更丝滑的效果。 const mouseWheelInteraction = new MouseWheelZoom({ duration: 100, // 缩放动画持续时间 (ms),Leaflet 默认也有动画 maxDelta: 2, constrainResolution: false // ✅ 关键:false 允许缩放到非整数级别 (如 4.5, 4.6),实现平滑缩放 }); - // 4. 创建地图实例 this.map = new OlMap({ target: container, layers: [], @@ -145,86 +138,34 @@ export class MapOl implements MapInterface { pinchRotate: false // 通常禁用旋转,除非需要 }).extend([mouseWheelInteraction]) }); + this.pointLayerManager.setMap(this.map); + this.popupManager.setMap(this.map); + this.regionMaskManager.setContext(this.map, this.view); - // 5. 监听缩放结束事件 (对应 Leaflet 的 'zoomend') - this.view.on('change:resolution', () => { - // OpenLayers 没有直接的 'zoomend' 事件,通常监听 resolution 变化 - // 如果需要防抖处理,可以添加 debounce - console.log('当前缩放级别', this.view?.getZoom()); - }); - // 点击弹出弹框 + this.view.on('change:resolution', () => {}); this.map.on('click', evt => { - const pixel = evt.pixel; - - // 使用共享方法检测要素 - const { detectedFeature, isHitIcon } = this.detectFeatureAtPixel(pixel); - - if (detectedFeature && isHitIcon) { + this.popupManager.handleMapClick(evt.pixel, detectedFeature => { modelStore.modalVisible = true; modelStore.params = detectedFeature.values_; modelStore.title = detectedFeature.values_.titleName + ' 详情信息'; - } + }); }); - // ✅ 新增:监听鼠标移动,动态改变光标样式 - let lastHoveredId: string | number | null = null; - let animationFrameId: number | null = null; - // pointermove this.map.on('pointermove', evt => { - const pixel = evt.pixel; + this.popupManager.handlePointerMove(evt.pixel, payload => { + this.hoveredFeatureId = payload.hoveredId; - // 判断地图层级,4.7 以上才进行 popup 显示 - const zoom = this.map.getView().getZoom(); - if (zoom !== undefined && zoom < 4.7) { - return; - } - - // 取消之前的动画帧,确保只处理最新的一次移动 - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - - animationFrameId = requestAnimationFrame(() => { - // 使用共享方法检测要素 - const { detectedFeature, isHitIcon } = - this.detectFeatureAtPixel(pixel); - - // 3. 根据是否命中图标来决定行为 - const newHoveredId = - detectedFeature && isHitIcon ? detectedFeature.getId() : null; - const detectedCoordinate = - detectedFeature && isHitIcon - ? detectedFeature.getGeometry()?.getCoordinates() - : undefined; - - // ✅ 关键优化:立即更新状态,不再使用 setTimeout 防抖,以解决移出延时问题 - // 如果希望防止极速移动时的闪烁,可以保留极短的 debounce (如 10ms),但通常 requestAnimationFrame 已足够平滑 - if (newHoveredId !== lastHoveredId) { - lastHoveredId = newHoveredId; - this.hoveredFeatureId = newHoveredId; - - // 更新光标 - const targetElement = this.map?.getTargetElement() as HTMLElement; - if (targetElement) { - targetElement.style.cursor = newHoveredId ? 'pointer' : ''; - } - - // 显示/隐藏 Popup - this.showPopup(detectedFeature, detectedCoordinate); - - // 重绘图层以更新图标样式(如高亮) - this.pointLayerRegistry.forEach(layer => { - layer.changed(); - }); - - // 触发地图渲染 - this.map?.render(); + const targetElement = this.map?.getTargetElement() as HTMLElement; + if (targetElement) { + targetElement.style.cursor = payload.hoveredId ? 'pointer' : ''; } + + this.showPopup(payload.detectedFeature, payload.coordinate); }); }); return Promise.resolve(this.map); } catch (e) { - console.log('OL Init Error', e); + console.error('OL Init Error', e); return Promise.reject(e); } } @@ -238,149 +179,16 @@ export class MapOl implements MapInterface { addInitDataLayer( pointData: any, layerType: any, - mdoptions?: MDOptions + _mdoptions?: MDOptions ): void { - console.log('addInitDataLayer', pointData, layerType, mdoptions); - if (!this.map) return; - - let dataArray: any[] = []; - let targetLayerKey: string = layerType; - - // 1. 参数解析 - if (Array.isArray(pointData)) { - dataArray = pointData; - } else { - dataArray = pointData.data || []; - targetLayerKey = pointData.key || layerType; - } - - if (!targetLayerKey) { - console.warn('缺少图层 Key,无法加载描点'); - return; - } - - // 2. 获取或创建 VectorLayer - let vectorLayer = this.pointLayerRegistry.get(targetLayerKey); - let vectorSource: VectorSource; - - // const shouldClear = mdoptions?.isRemove !== false; - - if (!vectorLayer) { - vectorSource = new VectorSource(); - vectorLayer = new VectorLayer({ - source: vectorSource, - zIndex: 100, // 确保点在底图之上 - // declutter: false, - // renderOrder: (a, b) => - // (b.get('distance') || 0) - (a.get('distance') || 0), - style: (feature: any) => this.createPointStyle(feature) // 使用动态样式函数 - // style: (feature: any) => this.createPointStyle1(feature) // 使用动态样式函数 - }); - this.pointLayerRegistry.set(targetLayerKey, vectorLayer); - this.map.addLayer(vectorLayer); - } else { - vectorSource = vectorLayer.getSource() as VectorSource; - // if (shouldClear) { - // vectorSource.clear(); - // } - } - - if (dataArray.length === 0) { - return; - } - - console.log( - `开始加载 OL 图层 [${targetLayerKey}] 的描点,数量: ${dataArray.length}` - ); - - // 3. 遍历数据生成 Feature - const features: Feature[] = []; - - dataArray.forEach((item: any) => { - const { lgtd, lttd, stnm, iconCode, titleName, ennm } = item; - - if (lgtd == null || lttd == null) { - return; - } - - // 4. 确定图标和文字 - let iconUrl = ''; - - // 获取图标 URL (参考 Leaflet 逻辑) - if (iconCode) { - iconUrl = getIconPath(iconCode); - } - // 如果没有 iconCode,可以根据 anchoPointState 或其他逻辑获取,这里暂略 - - if (!iconUrl) { - // 默认图标,防止报错 - iconUrl = getIconPath('default') || ''; - } - - // 显示的文字优先使用 titleName,其次 ennm,最后 stnm - const labelText = titleName || stnm || ennm || ''; - if (lgtd == null || lttd == null) return; - const lon = Number(lgtd); - const lat = Number(lttd); - - if (isNaN(lon) || isNaN(lat) || !isFinite(lon) || !isFinite(lat)) return; - if (Math.abs(lon) > 180 || Math.abs(lat) > 90) return; - const coord = fromLonLat([lon, lat]); - if (!isFinite(coord[0]) || !isFinite(coord[1])) return; - const geometry = new Point(coord); - // 5. 创建 Feature - const feature = new Feature({ - geometry: geometry, // ✅ 关键:坐标转换 EPSG:4326 -> EPSG:3857 - ...item, // 保留原始数据以便点击事件使用 - _layerKey: targetLayerKey - }); - const uniqueId = item.stcd || item._id; - - feature.setId(uniqueId); - - // 将自定义属性绑定到 feature,以便样式函数访问 - feature.set('_iconUrl', iconUrl); - feature.set('_labelText', labelText); - feature.set('_legendVisible', true); // 默认显示 - feature.set('_sttpMap', item.sttpMap); // 默认显示 - if (item.popupHtml) { - feature.set('popupHtml', item.popupHtml); - } - - features.push(feature); - }); - - // 6. 批量添加要素 - if (features.length > 0) { - vectorSource.addFeatures(features); - } - - console.log(`图层 [${targetLayerKey}] 描点加载完成`); - console.log(`图层 [${targetLayerKey}] 描点加载完成`); + this.pointLayerManager.addDataLayer(pointData, layerType); } /** * 初始化 Popup Overlay * @param container 父组件传入的 DOM 容器 */ initPopupOverlay(container: HTMLElement) { - this.popupElement = container; - - this.popupOverlay = new Overlay({ - element: this.popupElement, - positioning: 'bottom-center', // 气泡在点位上方 - offset: [0, -10], // 向上偏移一点 - stopEvent: false, // 允许点击事件穿透到地图(如果需要点击地图关闭) - autoPan: false - // autoPan: { - // animation: { - // duration: 250 - // } - // } - }); - - if (this.map) { - this.map.addOverlay(this.popupOverlay); - } + this.popupManager.initPopupOverlay(container); } /** * 在指定像素位置检测要素(共享方法) @@ -390,57 +198,9 @@ export class MapOl implements MapInterface { private detectFeatureAtPixel(pixel: number[]): { detectedFeature: Feature | undefined; isHitIcon: boolean; + coordinate?: number[]; } { - let detectedFeature: Feature | undefined = undefined; - let isHitIcon = false; - - // 1. 获取当前视口下的所有点图层 - const pointLayers = Array.from(this.pointLayerRegistry.values()); - - // 2. 遍历检测,但我们需要更精细的判断 - this.map?.forEachFeatureAtPixel( - pixel, - (feature, layer) => { - // 只关心点图层 - if (pointLayers.includes(layer)) { - detectedFeature = feature as Feature; - - // ✅ 关键优化:判断是否命中图标区域 - // OpenLayers 的 Icon 样式通常有 anchor [0.5, 0.5] - // 我们可以获取图标的尺寸,判断鼠标是否在图标矩形范围内 - const style = (layer as VectorLayer).getStyle(); - const iconSize = { width: 0, height: 0 }; - - // 尝试从样式中获取图标尺寸(如果样式是函数,可能需要调用它,这里简化处理,假设固定大小或从 feature 获取) - // 更通用的做法是:计算鼠标点到 Feature 坐标投影后的像素距离 - const geom = feature.getGeometry(); - if (geom && geom.getType() === 'Point') { - const coord = (geom as Point).getCoordinates(); - const iconPixel = this.map?.getPixelFromCoordinate(coord); - - if (iconPixel) { - // 计算鼠标像素与图标中心像素的距离 - const dx = pixel[0] - iconPixel[0]; - const dy = pixel[1] - iconPixel[1]; - const distance = Math.sqrt(dx * dx + dy * dy); - - // 假设图标大致大小为 30x30 或 40x40,半径设为 15-20 - // 你可以根据实际 icon 大小调整这个阈值,例如 15 - const hitRadius = 15; - if (distance <= hitRadius) { - isHitIcon = true; - } - } - } - - return true; // 找到第一个点图层要素即停止 - } - return false; - }, - { hitTolerance: 0 } // 设置为 0,完全依赖上面的距离计算 - ); - - return { detectedFeature, isHitIcon }; + return this.popupManager.detectFeatureAtPixel(pixel); } /** @@ -449,114 +209,46 @@ export class MapOl implements MapInterface { * @param coordinate 地理坐标 */ showPopup(feature: Feature | undefined, coordinate: number[] | undefined) { - if (!this.popupOverlay || !this.popupElement) return; - - if (feature && coordinate) { - const props = feature.getProperties(); - - const popupHtml = props.popupHtml || generatePopupHtml(props); - - if (popupHtml) { - this.popupElement.innerHTML = popupHtml; - this.popupOverlay.setPosition(coordinate); - this.popupElement.style.display = 'block'; - } else { - // this.popupElement.innerHTML = `
${ - // props.stnm || props.titleName || '未知' - // }
`; - // this.popupOverlay.setPosition(coordinate); - // this.popupElement.style.display = 'block'; - } - } else { - this.popupOverlay.setPosition(undefined); - this.popupElement.style.display = 'none'; - } + this.popupManager.showPopup(feature, coordinate); } /** * 创建点样式 (模拟 Leaflet 的 DivIcon 效果,支持随缩放动态调整大小) */ private createPointStyle(feature: Feature): Style { const iconUrl = feature.get('_iconUrl') as string; - const sttpMap = feature.get('_sttpMap') as string; const labelText = feature.get('_labelText') as string; const legendVisible = feature.get('_legendVisible'); const regionVisible = feature.get('_regionVisible'); - // 如果没图标,返回空样式 if (!iconUrl) { - return new Style(); + return null; } - // 如果图例设置为隐藏,返回空样式 if (legendVisible === false) { - return new Style(); + return null; } - // 如果区域外,返回空样式 if (regionVisible === false) { - return new Style(); + return null; } - // ✅ 1. 获取当前地图分辨率 - // 注意:如果样式函数在地图未完全初始化时调用,view 可能为 null - // const currentResolution = this.view ? this.view.getResolution() : 1000; - - // ✅ 2. 定义基准分辨率和基准缩放比例 - // INITIAL_ZOOM (4.5) 对应的分辨率大约是 3000-4000 左右 (取决于投影) - // 这里我们用一个经验公式:分辨率越小(zoom越大),scale 越大 - // 假设在初始分辨率下 scale 为 0.7 - // const baseResolution = 3000; // 这是一个估算值,你可以根据实际效果微调 - // const baseScale = 0.7; - - // 计算动态缩放比例: - // 如果 currentResolution 变小 (放大地图),ratio 变大 -> icon 变大 - // 限制最大和最小缩放,防止过大或过小 - // let dynamicScale = baseScale * (baseResolution / (currentResolution || 1)); const currentZoom: any = this.view ? this.view.getZoom() : 4.5; - const distance = feature.get('distance'); // 你通过计算赋值的字段 - // ✅ 分级过滤规则(根据实际业务调整阈值) - if (distance !== undefined && distance !== null) { - let minDistanceRequired = 0; - if (currentZoom <= 6) { - // 极远视图:只显示最孤立的站点(distance >= 50000000 或 1500000?根据你的数据分布定) - // 假设 50000000 和 1500000 属于高值,都显示;400000 和 50000 不显示 - minDistanceRequired = 1500000; // 只显示 >=150万 的点 - } else if (currentZoom <= 8) { - minDistanceRequired = 800000; // 显示 >=80万 的点 - } else if (currentZoom <= 10.5) { - minDistanceRequired = 400000; // 显示 >=40万 的点 - } else { - minDistanceRequired = 0; // 放大到14级以上显示所有点(包括5万的) - } - if (distance <= minDistanceRequired) { - return new Style(); // 不满足,隐藏 - } + if (!this.shouldRenderFeatureByDistance(feature, currentZoom)) { + return null; } - let dynamicScale = 0.7 + (currentZoom - 4.5) * 0.08; - // 限制范围:最小 0.5,最大 3.0 (可根据需求调整) - dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale)); + const dynamicScale = this.getDynamicPointScale(currentZoom); + const fontSize = this.getPointFontSize(dynamicScale); - // ✅ 3. 动态字体大小 - // 基础字体 12px,随缩放比例线性变化 - const fontSize = Math.max(10, Math.min(24, 12 * dynamicScale)); - - const currentFeatureId = feature.getId() || (feature as any).ol_uid; - const isHovered = this.hoveredFeatureId === currentFeatureId; - const zIndex = isHovered ? 100 : 0; const styleOptions: any = { image: new Icon({ src: iconUrl, scale: dynamicScale, anchor: [0.5, 0.5], crossOrigin: 'anonymous', - declutterMode: 'obstacle' // 图标参与碰撞,可被隐藏 + declutterMode: 'declutter' }) - - // zIndex: zIndex // ✅ 设置样式的 zIndex }; - // ✅ 4. 只有在悬停时才添加 Text 样式 - // if (isHovered && labelText) { styleOptions.text = new Text({ text: labelText, offsetY: -20 * dynamicScale, @@ -564,41 +256,70 @@ export class MapOl implements MapInterface { fill: new Fill({ color: '#fff' }), stroke: new Stroke({ color: 'rgba(0, 0, 0, .9)', width: 2 }), textAlign: 'center', - declutterMode: 'declutter' // 避免标签重叠 + declutterMode: 'declutter' }); - // } return new Style(styleOptions); } + + /** + * 根据缩放级别计算锚点图标缩放比例,统一复用,减少样式函数中的重复判断。 + */ + private getDynamicPointScale(currentZoom: number): number { + let dynamicScale = 0.7 + (currentZoom - 4.5) * 0.08; + dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale)); + return dynamicScale; + } + + /** + * 根据图标缩放比例统一计算文字字号,避免样式函数中散落重复计算。 + */ + private getPointFontSize(dynamicScale: number): number { + return Math.max(10, Math.min(24, 12 * dynamicScale)); + } + + /** + * 统一根据缩放级别和点位距离判断要素是否需要参与渲染,减少样式函数中的业务判断复杂度。 + */ + private shouldRenderFeatureByDistance( + feature: Feature, + currentZoom: number + ): boolean { + const distance = feature.get('distance'); + if (distance === undefined || distance === null) { + return true; + } + + let minDistanceRequired = 0; + if (currentZoom <= 6) { + minDistanceRequired = 1500000; + } else if (currentZoom <= 8) { + minDistanceRequired = 800000; + } else if (currentZoom <= 10.5) { + minDistanceRequired = 400000; + } + + return distance > minDistanceRequired; + } private createPointStyle1(feature: Feature): Style { const color = (feature.get('iconCode') as string) || '#3399CC'; const labelText = feature.get('titleName') as string; - const buildType = (feature.get('BuildType') as number) ?? 1; // 默认完整圆 + const buildType = (feature.get('BuildType') as number) ?? 1; - // 缩放相关动态计算 const currentZoom = this.view ? this.view.getZoom() : 4.5; let dynamicScale = 0.7 + (currentZoom - 4.5) * 0.08; dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale)); - // 底图 const fontSize = Math.max(16, Math.min(24, 12 * dynamicScale)); const radius = Math.max(6, Math.min(18, 6 * dynamicScale)); - // const fontSize = Math.max(46, Math.min(24, 12 * dynamicScale)); - // const radius = Math.max(14, Math.min(18, 6 * dynamicScale)); - const currentFeatureId = feature.getId() || (feature as any).ol_uid; - const isHovered = this.hoveredFeatureId === currentFeatureId; - - // 根据 BuildType 选择图形样式 let imageStyle; if (buildType === 0) { - // 半圆(横向,开口朝右) const canvas = this.createSemiCircleCanvas(radius, color); imageStyle = new Icon({ img: canvas, imgSize: [canvas.width, canvas.height], - anchor: [0.5, 0.5] // 锚点居中,使半圆定位准确 + anchor: [0.5, 0.5] }); } else { - // 完整圆 imageStyle = new Circle({ radius: radius, fill: new Fill({ color: color }), @@ -610,10 +331,8 @@ export class MapOl implements MapInterface { image: imageStyle }; - // 悬停时添加文本标签(与原逻辑相同,可根据半圆形状微调 offsetY) if (labelText) { let offsetY = -20 * dynamicScale; - // 特殊文本的偏移处理(保持原有逻辑) const specialLabels = [ '枕头坝二级', '老鹰岩二级', @@ -658,7 +377,14 @@ export class MapOl implements MapInterface { ): void { if (!this.map) return; - console.log('OL addBaseDataLayer', layer); + // 备注:同 key 的底图重复添加前先移除旧实例,避免注册表覆盖后旧图层仍残留在地图上。 + const existingLayer = layer?.key + ? this.layerRegistry.get(layer.key as string) + : null; + if (existingLayer) { + this.map.removeLayer(existingLayer); + this.layerRegistry.delete(layer.key); + } if (layer.type === 'wmts') { if (!layer.url) return; @@ -667,13 +393,9 @@ export class MapOl implements MapInterface { if (layer.key === this.REGISTRY_KEY) { this.baseLayerConfig = layer; } - // 1. 解析参数 - // 注意:如果 layer.url 已经是完整 URL,可能需要调整解析逻辑。 - // 这里假设 layer.url 包含查询参数,或者我们直接使用硬编码的配置来确保正确性 - const layerName: string = urlParams.get('LAYER') || ''; // 根据你的 Leaflet 代码硬编码或从 layer 对象获取 - const matrixSetName: any = urlParams.get('TILEMATRIXSET'); // TileMatrixSet 名称 + const layerName: string = urlParams.get('LAYER') || ''; + const matrixSetName: any = urlParams.get('TILEMATRIXSET'); - // 2. 获取标准 EPSG:3857 投影信息 const projection = getProjection('EPSG:3857'); if (!projection) { console.error('无法获取 EPSG:3857 投影'); @@ -681,40 +403,32 @@ export class MapOl implements MapInterface { } const projectionExtent = projection.getExtent(); - // 3. 计算分辨率和矩阵ID - // GeoServer 的 TileMatrixSet 通常基于标准 Web Mercator,但 ID 格式可能不同 const size = getWidth(projectionExtent) / 256; - const maxZoom = 13; // 根据 Leaflet 中的 maxNativeZoom: 12 或 13 设定 + const maxZoom = 13; const resolutions = new Array(maxZoom + 1); const matrixIds = new Array(maxZoom + 1); for (let z = 0; z <= maxZoom; ++z) { - // 生成标准分辨率 resolutions[z] = size / Math.pow(2, z); - - // 【关键修复】:构造符合 GeoServer 要求的 Matrix ID - // 通常格式为: "TileMatrixSetName:ZoomLevel" 或 "EPSG:3857:ZoomLevel" - // 根据你的 URL 推测,ID 可能是 "EPSG:3857_qgc_qsj_arcgistiles_l13:0" matrixIds[z] = `${matrixSetName}:${z}`; } - // 4. 创建 WMTS 图层 const wmtsLayer = new TileLayer({ source: new WMTS({ - url: layer.url.split('?')[0], // 使用代理路径 + url: layer.url.split('?')[0], layer: layerName, matrixSet: matrixSetName, format: 'image/png', - projection: projection, // ✅ 使用标准的 EPSG:3857 投影对象 + projection: projection, tileGrid: new WMTSTileGrid({ - origin: getTopLeft(projectionExtent), // ✅ 使用标准投影的左上角原点 + origin: getTopLeft(projectionExtent), resolutions: resolutions, - matrixIds: matrixIds // ✅ 使用上面生成的带前缀的 IDs + matrixIds: matrixIds }), style: 'default', - wrapX: true, // 允许水平方向上的循环 - crossOrigin: 'anonymous' // 允许跨域 + wrapX: true, + crossOrigin: 'anonymous' }) }); @@ -724,9 +438,7 @@ export class MapOl implements MapInterface { wmtsLayer.setZIndex(-100); } wmtsLayer.type = 'layer'; - // 5. 注册与添加 wmtsLayer.setVisible(isShow); - console.log('基础图层', wmtsLayer); this.layerRegistry.set(layer.key, wmtsLayer); this.map.addLayer(wmtsLayer); layer._layer = wmtsLayer; @@ -747,23 +459,86 @@ export class MapOl implements MapInterface { if (layer.key === 'hydropBase') { this.hydropBaseConfig = layer; } - console.log(`矢量图层 [${layer.key}] 已加载: ${layer.url}`); } } - // 在 addBaseDataLayer 方法的 vector 分支末尾,或者专门提供一个方法来激活遮罩 - enableNortheastMask(): void { - // 1. 获取全量底图图层 + /** + * 根据 key 设置底图图层显隐 + * @param layerKey 图层 key + * @param visible 是否显示 + */ + private setBaseLayerVisible(layerKey: string, visible: boolean): void { + const layer = this.layerRegistry.get(layerKey); + if (layer) { + layer.setVisible(visible); + } + } + /** + * 同步当前激活底图源的显隐,保证树勾选和下方图源切换使用同一套可见性规则 + * @param checked 是否显示当前激活底图 + */ + private syncActiveBaseLayerVisibility(checked: boolean): void { + this.setBaseLayerVisible(this.REGISTRY_KEY, checked); + + this.rasterBaseLayerKeys.forEach(key => { + const shouldShow = + checked && + this.activeBaseLayerKey !== 's_province_boundaries' && + key === this.activeBaseLayerKey; + this.setBaseLayerVisible(key, shouldShow); + }); + } + /** + * 获取当前应应用基地裁切的底图图层集合 + * @returns 当前可见底图图层列表 + */ + private getCurrentMaskTargetLayers(): TileLayer[] { + const layers: TileLayer[] = []; + const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY); + + if (baseLayer instanceof TileLayer) { + layers.push(baseLayer); + } + + if (this.activeBaseLayerKey !== 's_province_boundaries') { + const activeRasterLayer = this.layerRegistry.get(this.activeBaseLayerKey); + if ( + activeRasterLayer instanceof TileLayer && + activeRasterLayer !== baseLayer + ) { + layers.push(activeRasterLayer); + } + } + + return layers; + } + /** + * 重新把当前基地裁切绑定到最新底图实例,保证切换图源后边界裁切持续生效 + */ + private reapplyCurrentBaseMask(): void { + if (!this.currentClipGeoJson) { + return; + } + + const targetLayers = this.getCurrentMaskTargetLayers(); + if (targetLayers.length === 0) { + return; + } + + this.regionMaskManager.applyMapMaskToLayers( + targetLayers, + this.currentClipGeoJson + ); + } + enableNortheastMask(): void { const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY); - // 2. 检查是否已加载东北边界数据 if (!this.geoJsonData1) { console.warn('东北边界数据尚未加载'); return; } if (baseLayer && baseLayer instanceof TileLayer) { - console.log('正在应用东北区域遮罩...'); - this.applyMapMask(baseLayer, this.geoJsonData1); + this.regionMaskManager.applyMapMask(baseLayer, this.geoJsonData1); } else { console.warn('未找到全量底图图层或图层类型错误'); } @@ -782,194 +557,39 @@ export class MapOl implements MapInterface { return; } - // 1. 更新当前选中的区域 ID this.BASEID = _regionId; - console.log(`切换区域至: ${this.BASEID}`); if (!isAll) { - // ✅ 情况 A: isAll == false -> 清空裁切,显示所有锚点 - this.clearMapMask(); - this.showAllPoints(); + this.currentClipGeoJson = null; + this.regionMaskManager.clearMapMask(); + this.regionMaskManager.showAllPoints(); return; } - // 2. 获取底图图层 (用于应用遮罩的目标) - const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY) as TileLayer; - if (!baseLayer) { + const baseLayers = this.getCurrentMaskTargetLayers(); + if (baseLayers.length === 0) { console.warn('未找到底图图层,无法应用遮罩'); return; } - // ✅ 关键优化:在加载新区域数据前,先隐藏所有锚点,避免闪烁 - this.hideAllPoints(); - + this.regionMaskManager.hideAllPoints(); try { const url = this.hydropBaseConfig.geojson_url + `&cql_filter=BASEID='${this.BASEID}'`; - console.log('正在请求裁切数据:', url); - // 等待数据加载 const geoJsonData = await this.loadGeoJsonData(url); - console.log('获取到的原始 GeoJSON:', geoJsonData); - - // ✅ 先根据区域边界过滤锚点(此时锚点还是隐藏的) - this.filterPointsByRegion(geoJsonData); - - // ✅ 再让地图视角聚焦到该区域 - this.fitViewToGeoJson(geoJsonData); - - console.log('基础图层', baseLayer); - // 应用遮罩 - this.applyMapMask(baseLayer, geoJsonData); + this.currentClipGeoJson = geoJsonData; + this.regionMaskManager.filterPointsByRegion(geoJsonData); + this.regionMaskManager.fitViewToGeoJson(geoJsonData); + this.regionMaskManager.applyMapMaskToLayers(baseLayers, geoJsonData); } catch (error) { + this.currentClipGeoJson = null; console.error('加载裁切数据失败:', error); - // 出错时也清空遮罩,保证用户体验 - this.clearMapMask(); - this.showAllPoints(); + this.regionMaskManager.clearMapMask(); + this.regionMaskManager.showAllPoints(); } } - - /** - * 隐藏所有锚点 - */ - private hideAllPoints(): void { - this.pointLayerRegistry.forEach(layer => { - const vectorSource = layer.getSource() as VectorSource; - if (!vectorSource) return; - - const features = vectorSource.getFeatures(); - features.forEach((feature: Feature) => { - feature.set('_regionVisible', false); - feature.changed(); - }); - }); - } - - /** - * 显示所有锚点 - */ - private showAllPoints(): void { - this.pointLayerRegistry.forEach(layer => { - const vectorSource = layer.getSource() as VectorSource; - if (!vectorSource) return; - - const features = vectorSource.getFeatures(); - features.forEach((feature: Feature) => { - feature.set('_regionVisible', true); - feature.changed(); - }); - }); - } - - /** - * 根据区域边界过滤锚点显示 - * 使用射线法判断点是否在多边形内 - */ - private filterPointsByRegion(geoJson: any): void { - // 解析 GeoJSON 获取区域多边形坐标 - const regionCoords = this.extractPolygonCoords(geoJson); - if (!regionCoords || regionCoords.length === 0) { - console.warn('无法解析区域边界,显示所有锚点'); - this.showAllPoints(); - return; - } - - // 遍历所有锚点图层 - this.pointLayerRegistry.forEach(layer => { - const vectorSource = layer.getSource() as VectorSource; - if (!vectorSource) return; - - const features = vectorSource.getFeatures(); - features.forEach((feature: Feature) => { - const props = feature.getProperties(); - const lon = props.lgtd; - const lat = props.lttd; - - if (lon == null || lat == null) { - feature.set('_regionVisible', false); - feature.changed(); - return; - } - - // 判断点是否在区域内 - const isInside = this.isPointInPolygon(lon, lat, regionCoords); - feature.set('_regionVisible', isInside); - feature.changed(); - }); - }); - } - - /** - * 从 GeoJSON 中提取多边形坐标 - */ - private extractPolygonCoords(geoJson: any): number[][][] { - if (!geoJson || !geoJson.features || geoJson.features.length === 0) { - return []; - } - - const coords: number[][][] = []; - geoJson.features.forEach((feature: any) => { - const geometry = feature.geometry; - if (!geometry) return; - - if (geometry.type === 'Polygon') { - // Polygon: [[[lon, lat], ...], ...] - coords.push(...geometry.coordinates); - } else if (geometry.type === 'MultiPolygon') { - // MultiPolygon: [[[[lon, lat], ...], ...], ...] - geometry.coordinates.forEach((polygon: number[][][]) => { - coords.push(...polygon); - }); - } - }); - - return coords; - } - - /** - * 射线法判断点是否在多边形内 - * @param lon 经度 - * @param lat 纬度 - * @param polygons 多边形坐标数组 [[[lon, lat], ...], ...] - */ - private isPointInPolygon( - lon: number, - lat: number, - polygons: number[][][] - ): boolean { - for (const polygon of polygons) { - if (this.isPointInRing(lon, lat, polygon)) { - return true; - } - } - return false; - } - - /** - * 射线法判断点是否在单个环内 - */ - private isPointInRing(lon: number, lat: number, ring: number[][]): boolean { - let inside = false; - const n = ring.length; - - for (let i = 0, j = n - 1; i < n; j = i++) { - const xi = ring[i][0]; - const yi = ring[i][1]; - const xj = ring[j][0]; - const yj = ring[j][1]; - - const intersect = - yi > lat !== yj > lat && - lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; - - if (intersect) { - inside = !inside; - } - } - - return inside; - } /** * 基础图层显示影隐藏方法 * @param layerType 图层类型 (备用,优先使用 key) @@ -982,9 +602,6 @@ export class MapOl implements MapInterface { checked: boolean ): void { if (!this.map) return; - console.log('controlBaseLayerTreeShowAndHidden', layerType, key, checked); - - // 1. 确定查找的 Key:优先使用传入的 key,如果为空则尝试使用 layerType const registryKey = key || layerType; if (!registryKey) { @@ -993,18 +610,13 @@ export class MapOl implements MapInterface { ); return; } - // 2. 从注册表中获取图层实例 + if (registryKey === this.REGISTRY_KEY) { + this.syncActiveBaseLayerVisibility(checked); + return; + } const layerInstance = this.layerRegistry.get(registryKey as string); - console.log(layerInstance); if (layerInstance) { layerInstance.setVisible(checked); - // 3. 设置可见性 - // OpenLayers 的 Layer.setVisible(boolean) 方法 - // layerInstance.setVisible(checked); - - console.log( - `图层 [${registryKey}] 已设置为: ${checked ? '显示' : '隐藏'}` - ); } else { console.warn( `未找到标识为 [${registryKey}] 的图层实例,当前注册表 keys:`, @@ -1012,22 +624,12 @@ export class MapOl implements MapInterface { ); } } - // 图层树控制描点数据显示隐藏方法 mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void { - console.log('mdLayerTreeShowOrHidden', layerType, checked); - const layerInstance: any = this.pointLayerRegistry.get(layerType); - console.log('mdLayerTreeShowOrHidden', layerInstance); - if (layerInstance) { - layerInstance.setVisible(checked); - } - // this.service.mdLayerTreeShowOrHidden(layerType, checked) + this.pointLayerManager.setLayerVisible(layerType, checked); } hasLayer(layerType: string): void { - console.log('hasLayer', layerType); - const layerInstance: any = this.pointLayerRegistry.get(layerType); - console.log('hasLayer', layerInstance); - return this.pointLayerRegistry.has(layerType as string); + return this.pointLayerManager.hasLayer(layerType as string); } /** @@ -1073,27 +675,28 @@ export class MapOl implements MapInterface { */ baseLayerSwitcher(key: string, checked: boolean): void { if (!this.map || !this.view) return; - - console.log('OL 切换底图 key:', key); - - // 1. 如果注册表中已存在 "customBaseLayer",先将其从地图中移除 + this.activeBaseLayerKey = key; const oldLayer = this.layerRegistry.get(this.REGISTRY_KEY); if (oldLayer) { this.map.removeLayer(oldLayer); this.layerRegistry.delete(this.REGISTRY_KEY); } - // 2. 根据传入的业务 key 创建新的图层实例 if (key == 's_province_boundaries') { this.addBaseDataLayer(this.baseLayerConfig, checked); - // 隐藏 BASEMAP-white 和 BASEMAP-img 图层(如果存在) - this.hideBaseMapLayers(['BASEMAP-white', 'BASEMAP-img']); + this.hideBaseMapLayers(this.rasterBaseLayerKeys); } else { this.addBaseDataLayer(this.baseLayerConfig, checked, true); - this.addBaseDataLayer(servers.BaseLayer[key], 1); - // 显示 BASEMAP-white 和 BASEMAP-img 图层(如果存在) - this.showBaseMapLayers(['BASEMAP-white', 'BASEMAP-img']); + this.rasterBaseLayerKeys.forEach(layerKey => { + if (layerKey === key) { + this.addBaseDataLayer(servers.BaseLayer[layerKey], checked); + } else { + this.setBaseLayerVisible(layerKey, false); + } + }); } + + this.reapplyCurrentBaseMask(); } /** * 隐藏指定的底图图层 @@ -1136,12 +739,8 @@ export class MapOl implements MapInterface { ): void { if (!this.map || !this.view) return; - // 1. 检查是否已存在该图层,避免重复添加 + // 1. 检查是否已存在该图层,已存在时直接切换为显示并刷新样式 const existingLayer = this.layerRegistry.get(layer.key); - if (existingLayer) { - this.map.removeLayer(existingLayer); - this.layerRegistry.delete(layer.key); - } // 2. 创建样式函数 const visibleStyle = new Style({ @@ -1154,34 +753,60 @@ export class MapOl implements MapInterface { stroke: new Stroke({ color: 'rgba(0,0,0,0)' }) }); - // 3. 创建矢量源 (VectorSource) 并直接加载数据 - const vectorSource = new VectorSource({ - features: new GeoJSON().readFeatures(this.geoJsonData, { - featureProjection: 'EPSG:3857' // 确保转换到地图使用的投影 - }) - }); + if (existingLayer) { + const allowedIds = Array.isArray(datas) ? datas : []; + const existingFeatures = + existingLayer.getSource?.()?.getFeatures?.() ?? []; - // 4. 数据过滤逻辑 - const allowedIds = Array.isArray(datas) ? datas : []; - const features = vectorSource.getFeatures(); + existingFeatures.forEach(feature => { + const rvcd = feature.get('RVCD'); + const isVisible = + allowedIds.length > 0 ? rvcd && allowedIds.includes(rvcd) : true; - features.forEach(feature => { - // 获取属性中的 RVCD - const rvcd = feature.get('RVCD'); + feature.setStyle(isVisible ? visibleStyle : hiddenStyle); + }); - let isVisible = false; + existingLayer.setVisible(layer.visible !== false); + return; + } - // 逻辑:如果 datas 为空,通常意味着显示所有(或者都不显示,视具体需求而定) - // 这里假设:datas 有值时严格过滤;datas 为空时显示所有 - if (allowedIds.length > 0) { - isVisible = rvcd && allowedIds.includes(rvcd); - } else { - isVisible = true; - } + // 3. 创建矢量源 (VectorSource),并通过 layer.geojson_url 加载数据 + const vectorSource = new VectorSource(); - // 应用样式 - feature.setStyle(isVisible ? visibleStyle : hiddenStyle); - }); + if (layer?.geojson_url) { + this.loadGeoJsonData(VITE_APP_MAP_URL + layer.geojson_url).then( + geoJsonData => { + if (!geoJsonData) return; + + const features = new GeoJSON().readFeatures(geoJsonData, { + featureProjection: 'EPSG:3857' // 确保转换到地图使用的投影 + }); + + vectorSource.addFeatures(features); + + // 4. 数据过滤逻辑 + const allowedIds = Array.isArray(datas) ? datas : []; + + features.forEach(feature => { + // 获取属性中的 RVCD + const rvcd = feature.get('RVCD'); + + let isVisible = false; + + // 逻辑:如果 datas 为空,通常意味着显示所有(或者都不显示,视具体需求而定) + // 这里假设:datas 有值时严格过滤;datas 为空时显示所有 + if (allowedIds.length > 0) { + isVisible = rvcd && allowedIds.includes(rvcd); + } else { + isVisible = true; + } + + // 应用样式 + feature.setStyle(isVisible ? visibleStyle : hiddenStyle); + }); + } + ); + } // 5. 创建矢量图层 (VectorLayer) const vectorLayer = new VectorLayer({ @@ -1196,28 +821,30 @@ export class MapOl implements MapInterface { this.map.addLayer(vectorLayer); } /** - * 移除梯级流域图 + * 隐藏梯级流域图 * @param layer 图层配置对象 (需包含 key 属性) */ - removeTertiarybasinLayer(layer: any): void { + hideTertiarybasinLayer(layer: any): void { if (!this.map || !layer || !layer.key) { - console.warn('removeTertiarybasinLayer: 无效的图层或地图未初始化'); + console.warn('hideTertiarybasinLayer: 无效的图层或地图未初始化'); return; } // 1. 从注册表中获取图层实例 const layerInstance = this.layerRegistry.get(layer.key); if (layerInstance) { - // 2. 从地图中移除图层 - this.map.removeLayer(layerInstance); - // 3. 从注册表中删除引用 - this.layerRegistry.delete(layer.key); - - console.log(`梯级流域图层 [${layer.key}] 已移除`); + layerInstance.setVisible(false); } else { console.warn(`未找到标识为 [${layer.key}] 的梯级流域图层实例`); } } + /** + * 删除描点图层 + * @param layerKey 图层 key + */ + removePointLayer(layerKey: string): void { + this.pointLayerManager.removeLayer(layerKey); + } /** * 地图打印/导出 * 将当前地图视图导出为 PNG 图片,背景强制为白色 @@ -1227,10 +854,6 @@ export class MapOl implements MapInterface { console.warn('地图未初始化,无法打印'); return; } - - console.log('开始生成地图打印图片...'); - - // 1. 获取地图容器中的 canvas 元素 const mapElement = this.map.getTargetElement(); const canvas = mapElement.querySelector('canvas'); @@ -1240,7 +863,6 @@ export class MapOl implements MapInterface { } try { - // 2. 创建一个离屏 Canvas 用于合成白色背景 const width = canvas.width; const height = canvas.height; @@ -1253,27 +875,19 @@ export class MapOl implements MapInterface { throw new Error('无法获取 Canvas 上下文'); } - // 3. 填充白色背景 ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, width, height); - // 4. 将原地图 Canvas 绘制到离屏 Canvas 上 ctx.drawImage(canvas, 0, 0); - // 5. 将合成后的 Canvas 转换为 Data URL const dataURL = offscreenCanvas.toDataURL('image/png'); - // 6. 创建临时链接并触发下载 const link = document.createElement('a'); link.download = `map_export_${new Date().getTime()}.png`; link.href = dataURL; - - // 模拟点击 document.body.appendChild(link); link.click(); document.body.removeChild(link); - - console.log('地图图片已生成并触发下载(背景已白化)'); } catch (e) { console.error('地图导出失败', e); alert('地图导出失败,请检查控制台错误信息。'); @@ -1328,8 +942,6 @@ export class MapOl implements MapInterface { ): void { if (!this.map || !this.view) return; - console.log(`启动${geomType === 'LineString' ? '长度' : '面积'}量算模式`); - // 1. 初始化图层 if (!this.measureLayer) { this.measureSource = new VectorSource(); @@ -1446,8 +1058,6 @@ export class MapOl implements MapInterface { setTimeout(() => { this.toggleDoubleClickZoom(true); }, 0); - - console.log('测量结束'); }); } /** @@ -1545,8 +1155,6 @@ export class MapOl implements MapInterface { this.map?.removeInteraction(this.drawInteraction); this.drawInteraction = null; } - - console.log('已清除所有量算结果'); } /** @@ -1620,246 +1228,6 @@ export class MapOl implements MapInterface { container.appendChild(closeBtn); this.map.addOverlay(overlay); } - /** - * 为栅格图层添加基于矢量的裁切效果 - * @param rasterLayer 需要被裁切的底图图层 (TileLayer) - * @param clipGeoJson GeoJSON 格式的裁切边界数据 - */ - private applyMapMask(rasterLayer: TileLayer, clipGeoJson: any): void { - if (!rasterLayer || !clipGeoJson) return; - console.log('地图遮罩开始'); - - // 1. 解析 GeoJSON 为 OL Features (EPSG:3857) - const features = new GeoJSON().readFeatures(clipGeoJson, { - dataProjection: 'EPSG:4326', - featureProjection: 'EPSG:3857' - }); - - if (!features || features.length === 0) { - console.warn('裁切数据为空,无法应用遮罩'); - return; - } - - // 2. 移除旧的监听器,防止重复绑定 - if (this._maskPrerender) { - rasterLayer.un('prerender', this._maskPrerender); - } - if (this._maskPostrender) { - rasterLayer.un('postrender', this._maskPostrender); - } - - // 3. 定义 prerender 事件处理函数 - // OpenLayers 在 prerender 时,context 已经包含了当前帧的状态 - const maskPrerender = (event: any) => { - const context = event.context; - const frameState = event.frameState; - - if (!context || !frameState || !this.map) return; - - // ✅ 关键步骤 1: 保存当前 Canvas 状态 - context.save(); - - // ✅ 关键步骤 2: 重置变换矩阵,确保我们操作的是屏幕像素坐标 - // OpenLayers 默认可能应用了平移/缩放,我们需要手动控制 - context.setTransform(1, 0, 0, 1, 0, 0); - - context.beginPath(); - - let hasValidPath = false; - - // 遍历所有特征,绘制路径 - for (const feature of features) { - const geom = feature.getGeometry(); - if (!geom) continue; - - const type = geom.getType(); - - // 处理 Polygon - if (type === 'Polygon') { - const rings = (geom as any).getCoordinates() as number[][][]; - for (const ring of rings) { - if (this.drawRingToContext(context, ring, frameState)) { - hasValidPath = true; - } - } - } - // 处理 MultiPolygon - else if (type === 'MultiPolygon') { - const polygons = (geom as any).getCoordinates() as number[][][][]; - for (const polygonRings of polygons) { - for (const ring of polygonRings) { - if (this.drawRingToContext(context, ring, frameState)) { - hasValidPath = true; - } - } - } - } - } - - // ✅ 关键步骤 3: 如果绘制了有效路径,则执行裁剪 - if (hasValidPath) { - // 闭合路径(虽然 drawRing 里做了 closePath,但为了保险) - - context.save(); - // 先用外描边创建裁剪区域 - context.lineWidth = 6; - context.strokeStyle = '#6D64DF'; - context.stroke(); - context.clip(); // 裁剪到外描边的内部区域 - context.strokeStyle = '#CCC9F4'; - context.lineWidth = 5; - context.stroke(); - // 执行裁剪!此后所有绘制操作仅在此路径内可见 - context.clip(); - - // 可选:如果你希望裁剪区域外有半透明黑色遮罩,可以在这里绘制一个全屏矩形 - // 但通常我们只希望“隐藏”外部,所以只需 clip 即可,OL 会自动绘制瓦片 - } else { - console.warn('未能生成有效的裁切路径'); - } - }; - - // 4. 定义 postrender 事件处理函数 - // 用于恢复 Canvas 状态,避免影响其他图层或后续帧 - const maskPostrender = (event: any) => { - if (event.context) { - event.context.restore(); - } - }; - - // 5. 绑定新的事件监听器 - this._maskPrerender = maskPrerender; - this._maskPostrender = maskPostrender; - - rasterLayer.on('prerender', this._maskPrerender); - rasterLayer.on('postrender', this._maskPostrender); - - // 6. 强制触发重绘 - rasterLayer.changed(); - - console.log('地图遮罩已应用'); - } - - /** - * 将单个环(Ring)绘制到 Canvas 上下文 - * @returns boolean 是否成功绘制了有效路径 - */ - private drawRingToContext( - context: CanvasRenderingContext2D, - ring: number[][], - frameState: any - ): boolean { - if (!ring || ring.length < 3) return false; // 至少需要3个点构成面 - - const pixelRatio = frameState.pixelRatio || window.devicePixelRatio || 1; - let moved = false; - - for (let i = 0; i < ring.length; i++) { - const coord = ring[i]; - - // 坐标有效性检查 - if (!coord || coord.length < 2 || typeof coord[0] !== 'number') continue; - - // 获取 CSS 像素坐标 - const cssPixel = this.map?.getPixelFromCoordinate( - coord as [number, number] - ); - - if (!cssPixel) continue; - - // 转换为物理像素坐标 - const canvasX = cssPixel[0] * pixelRatio; - const canvasY = cssPixel[1] * pixelRatio; - - if (!moved) { - context.moveTo(canvasX, canvasY); - moved = true; - } else { - context.lineTo(canvasX, canvasY); - } - } - - if (moved) { - context.closePath(); - return true; - } - return false; - } - /** - * 根据 GeoJSON 数据调整地图视野,使其居中并适配大小 - * @param geoJson GeoJSON 对象 - */ - private fitViewToGeoJson(geoJson: any): void { - if (!this.map || !this.view) return; - - try { - // 1. 将 GeoJSON 转换为 OpenLayers 的 Feature 数组 - // 注意:这里假设输入的是 EPSG:4326,需要转换到地图使用的 EPSG:3857 - const features = new GeoJSON().readFeatures(geoJson, { - dataProjection: 'EPSG:4326', - featureProjection: 'EPSG:3857' - }); - - if (!features || features.length === 0) return; - - // 2. 创建一个临时的矢量源来计算所有要素的包围盒 - const source = new VectorSource({ - features: features - }); - - // 3. 获取包围盒 [minX, minY, maxX, maxY] - const extent = source.getExtent(); - - // 检查包围盒是否有效 - if (extent[0] === Infinity || extent[0] === -Infinity) { - console.warn('无法计算有效的地图包围盒'); - return; - } - - // 4. 调整视图 - // padding: 设置四周留白,防止图形贴边 - // duration: 动画持续时间 - // maxZoom: 限制最大缩放级别,防止放大过度导致像素化 - this.view.fit(extent, { - padding: [100, 200, 50, 50], // 上右下左留白 - duration: 1000, // 1秒动画 - maxZoom: 13 // 根据底图精度调整,避免过度放大 - }); - - console.log('地图视野已调整至裁切区域'); - } catch (e) { - console.error('调整地图视野失败:', e); - } - } - /** - * 清除地图遮罩(恢复底图正常显示) - */ - private clearMapMask(): void { - const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY) as TileLayer; - if (!baseLayer) return; - - console.log('正在清除地图遮罩...'); - - if (this._maskPrerender) { - baseLayer.un('prerender', this._maskPrerender); - this._maskPrerender = null; - } - if (this._maskPostrender) { - baseLayer.un('postrender', this._maskPostrender); - this._maskPostrender = null; - } - - // 强制重绘以移除遮罩效果 - baseLayer.changed(); - if (this.view) { - this.view.animate({ - center: fromLonLat(CENTER_positionCN), - zoom: INITIAL_ZOOM, - duration: 1000 // 动画持续时间,保持与 fitViewToGeoJson 一致 - }); - console.log('地图视野已重置为初始状态'); - } - } /** * 格式化长度输出 (米) */ @@ -1876,60 +1244,28 @@ export class MapOl implements MapInterface { const areaSqKm = areaSqMeters / 1000000; return '面积:' + areaSqKm.toFixed(3) + 'km²'; } - // 图例和基地面板控制描点数据显示隐藏方法 /** * 图例和基地面板控制描点数据显示隐藏方法 * @param layerType 图层类型 - * @param key 图层标识符 + * @param key 图层标识符(锚点状态 nameEn) * @param baseid 基地ID * @param checked 是否选中(显示/隐藏) * @param isAll 是否全部操作 */ mdLayerShowOrHidden( layerType: string, - baseid: string, key?: string, + baseid: string, checked?: boolean, isAll?: boolean ): void { - console.log('mdLayerShowOrHidden', layerType, key, baseid, checked, isAll); - // 确定查找的 Key:优先使用传入的 key,如果为空则尝试使用 layerType - const registryKey = key || layerType; - - if (isAll) { - // 如果是全部操作,则对所有相应类型的图层进行处理 - if (layerType === 'point') { - // 处理所有点图层 - this.pointLayerRegistry.forEach((layer, key) => { - if (layer) { - layer.setVisible(checked); - } - }); - } else { - // 处理其他类型的图层 - this.layerRegistry.forEach((layer, key) => { - if (key.startsWith(layerType as string)) { - layer.setVisible(checked); - } - }); - } - } else { - // 单独操作特定图层 - let targetLayer = null; - - // 先尝试在点图层注册表中查找 - targetLayer = this.pointLayerRegistry.get(registryKey as string); - - // 如果没找到,再在普通图层注册表中查找 - if (!targetLayer) { - targetLayer = this.layerRegistry.get(registryKey as string); - } - - // 如果指定了基地ID,可以进一步筛选 - if (targetLayer) { - targetLayer.setVisible(checked); - } - } + void baseid; + void isAll; + this.pointLayerManager.setLegendVisibleByField( + layerType, + key || '', + !!checked + ); } /** @@ -1943,33 +1279,14 @@ export class MapOl implements MapInterface { anchoPointState: string, checked: boolean ): void { - const vectorLayer = this.pointLayerRegistry.get(layerKey); - if (!vectorLayer) return; - - const vectorSource = vectorLayer.getSource() as VectorSource; - if (!vectorSource) return; - - const features = vectorSource.getFeatures(); - features.forEach((feature: Feature) => { - const props = feature.getProperties(); - // 如果 anchoPointState 为空,隐藏所有锚点 - if (!anchoPointState) { - feature.set('_legendVisible', false); - feature.changed(); - } else if (props.anchoPointState === anchoPointState) { - // 根据 anchoPointState 匹配 - // 设置 feature 的可见性 - feature.set('_legendVisible', checked); - // 触发重新渲染 - feature.changed(); - } - // 不匹配的 feature 不做处理,保持原样 - }); + this.pointLayerManager.setLegendVisibleByField( + layerKey, + anchoPointState, + checked + ); } - switchView(type): void { - console.log(type); - } + switchView(_type): void {} fitBounds(): void {} /** * 飞行定位到指定经纬度和缩放级别 @@ -2047,73 +1364,51 @@ export class MapOl implements MapInterface { * 移除地图对象,释放资源 */ destroy(): void { - console.log('销毁地图实例...'); - - // 1. 清除量算相关资源 this.removeQueryLayer(); + this.regionMaskManager.destroy(); - this._maskPrerender = null; - this._maskPostrender = null; - - // ✅ 关键:先清除所有图层的源,再移除图层,这能更有效地中断网络请求 const clearLayer = (layer: any) => { if (layer && layer.getSource) { const source = layer.getSource(); if (source && source.clear) { - source.clear(); // 清除缓存 - } - // 对于 WMTS/XYZ,OL 内部有 TileQueue,dispose source 有助于清理 - if (source && source.dispose) { - // 注意:某些 OL 版本 source.dispose 可能不是公开 API,但 clear 是必须的 + source.clear(); } } if (this.map && this.map.getLayers().getArray().includes(layer)) { this.map.removeLayer(layer); } - // 强制解除引用 if (layer.setMap) layer.setMap(null); }; - // 2. 清除通过 layerRegistry 管理的图层 if (this.layerRegistry) { this.layerRegistry.forEach(clearLayer); this.layerRegistry.clear(); } - // 3. 清除点图层 Registry - if (this.pointLayerRegistry) { - this.pointLayerRegistry.forEach(clearLayer); - this.pointLayerRegistry.clear(); - } + this.popupManager.destroy(); + this.pointLayerManager.destroy(); - // 4. 清除地图实例 if (this.map) { - // 先清除交互和覆盖物 this.map.getInteractions().clear(); this.map.getOverlays().clear(); - - // 设置 target 为 undefined 会从 DOM 移除 canvas this.map.setTarget(undefined); - - // 销毁地图实例 this.map.dispose(); this.map = null; } + this.popupManager.setMap(null); + this.pointLayerManager.setMap(null); + this.regionMaskManager.setContext(null, null); - // 5. 清除视图引用 if (this.view) { this.view.dispose(); this.view = null; } - // 6. 清除其他引用 this.baseLayerConfig = null; this.measureSource = null; this.measureLayer = null; this.drawInteraction = null; this.geoJsonData = null; this.geoJsonData1 = null; - - console.log('地图实例已销毁'); } } diff --git a/frontend/src/components/gis/ol/point-layer-manager.ts b/frontend/src/components/gis/ol/point-layer-manager.ts new file mode 100644 index 00000000..e32353fc --- /dev/null +++ b/frontend/src/components/gis/ol/point-layer-manager.ts @@ -0,0 +1,325 @@ +import Feature from 'ol/Feature'; +import Point from 'ol/geom/Point'; +import OlMap from 'ol/Map'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import { fromLonLat } from 'ol/proj'; +import { getIconPath } from '@/utils/index'; + +type PointLayerStyleFactory = (feature: Feature) => any; + +type PointLayerManagerOptions = { + map: OlMap | null; + createStyle: PointLayerStyleFactory; +}; + +export class PointLayerManager { + private map: OlMap | null; + private createStyle: PointLayerStyleFactory; + private layerRegistry: Map> = new Map(); + private layerFeatureIndexes: Map< + string, + Map> + > = new Map(); + + constructor(options: PointLayerManagerOptions) { + this.map = options.map; + this.createStyle = options.createStyle; + } + + // 备注:同步更新地图实例,供地图初始化后把真实 map 注入点图层管理器。 + setMap(map: OlMap | null) { + this.map = map; + } + + // 备注:统一返回当前点图层注册表,供外层做遍历或清理。 + getRegistry() { + return this.layerRegistry; + } + + // 备注:按图层 key 获取已注册的点图层实例。 + getLayer(layerKey: string) { + return this.layerRegistry.get(layerKey); + } + + // 备注:返回当前所有点图层实例,供命中检测和批量刷新使用。 + getLayers(): VectorLayer[] { + return Array.from(this.layerRegistry.values()); + } + + // 备注:判断指定图层 key 的点图层是否已存在。 + hasLayer(layerKey: string): boolean { + return this.layerRegistry.has(layerKey); + } + + // 备注:按需获取或创建点图层实例,并自动完成注册与挂载。 + ensureLayer(layerKey: string, layerType?: string) { + if (!this.map || !layerKey) return null; + + let vectorLayer = this.layerRegistry.get(layerKey); + if (vectorLayer) { + return vectorLayer; + } + + const vectorSource = new VectorSource(); + vectorLayer = new VectorLayer({ + source: vectorSource, + zIndex: 100, + declutter: true, + style: feature => this.createStyle(feature as Feature) + }); + (vectorLayer as any).type = layerType || layerKey; + this.layerRegistry.set(layerKey, vectorLayer); + this.map.addLayer(vectorLayer); + return vectorLayer; + } + + // 备注:把后端点位数组转换为 OpenLayers Feature,并写入指定点图层。 + addDataLayer(pointData: any, layerType: string): void { + if (!this.map) return; + + let dataArray: any[] = []; + let targetLayerKey = layerType; + + if (Array.isArray(pointData)) { + dataArray = pointData; + } else { + dataArray = pointData?.data || []; + targetLayerKey = pointData?.key || layerType; + } + + if (!targetLayerKey) { + console.warn('缺少图层 Key,无法加载描点'); + return; + } + + const vectorLayer = this.ensureLayer(targetLayerKey, layerType); + if (!vectorLayer) return; + + const vectorSource = vectorLayer.getSource(); + if (!vectorSource) { + return; + } + + vectorSource.clear(); + this.resetLayerFeatureIndexes(targetLayerKey); + + if (dataArray.length === 0) { + return; + } + + const features: Feature[] = []; + + dataArray.forEach((item: any) => { + const feature = this.createFeature(item, targetLayerKey); + if (feature) { + this.indexFeature(targetLayerKey, feature); + features.push(feature); + } + }); + + if (features.length > 0) { + vectorSource.addFeatures(features); + } + } + + // 备注:统一控制整个点图层显隐,供图层树勾选联动使用。 + setLayerVisible(layerKey: string, visible?: boolean): void { + const vectorLayer = this.layerRegistry.get(layerKey); + if (vectorLayer && vectorLayer.getVisible() !== visible) { + vectorLayer.setVisible(visible); + } + } + + // 备注:按图层 key 删除点图层并清空其 source 数据。 + removeLayer(layerKey: string): void { + if (!this.map || !layerKey) { + console.warn('removePointLayer: 无效的图层 key 或地图未初始化'); + return; + } + + const vectorLayer = this.layerRegistry.get(layerKey); + if (!vectorLayer) { + console.warn(`未找到标识为 [${layerKey}] 的描点图层实例`); + return; + } + + const source = vectorLayer.getSource(); + if (source) { + source.clear(); + } + this.map.removeLayer(vectorLayer); + this.layerRegistry.delete(layerKey); + this.layerFeatureIndexes.delete(layerKey); + } + + // 备注:按图层遍历所有点图层实例,供 hover 刷新和区域裁切复用。 + forEachLayer( + callback: (layer: VectorLayer, layerKey: string) => void + ): void { + this.layerRegistry.forEach((layer, layerKey) => { + callback(layer, layerKey); + }); + } + + // 备注:遍历所有点要素,供区域裁切或批量状态更新使用。 + forEachFeature( + callback: ( + feature: Feature, + layer: VectorLayer, + layerKey: string + ) => void + ): void { + this.forEachLayer((layer, layerKey) => { + const source = layer.getSource(); + if (!source) return; + source.getFeatures().forEach(feature => { + callback(feature as Feature, layer, layerKey); + }); + }); + } + + // 备注:按图例字段值统一控制要素显隐,供图例和容量筛选联动复用。 + setLegendVisibleByField( + layerKey: string, + matchValue: string, + checked: boolean, + fieldKey: string = 'anchoPointState' + ): void { + const vectorLayer = this.layerRegistry.get(layerKey); + if (!vectorLayer) return; + + const source = vectorLayer.getSource(); + if (!source) return; + + if (!matchValue) { + source.getFeatures().forEach(feature => { + if (feature.get('_legendVisible') !== false) { + feature.set('_legendVisible', false); + feature.changed(); + } + }); + vectorLayer.changed(); + return; + } + + const targetFeatures = this.getIndexedFeatures( + layerKey, + fieldKey, + matchValue + ); + if (targetFeatures.length === 0) return; + + targetFeatures.forEach(feature => { + if (feature.get('_legendVisible') !== checked) { + feature.set('_legendVisible', checked); + feature.changed(); + } + }); + vectorLayer.changed(); + } + + // 备注:统一销毁所有点图层并清空注册表,供地图销毁阶段复用。 + destroy(): void { + this.forEachLayer(layer => { + const source = layer.getSource(); + if (source) { + source.clear(); + } + if (this.map && this.map.getLayers().getArray().includes(layer)) { + this.map.removeLayer(layer); + } + if ((layer as any).setMap) { + (layer as any).setMap(null); + } + }); + this.layerRegistry.clear(); + this.layerFeatureIndexes.clear(); + } + + // 备注:统一把业务点位数据转换为 Feature,并补齐样式依赖字段。 + private createFeature(item: any, targetLayerKey: string): Feature | null { + const { lgtd, lttd, stnm, iconCode, titleName, ennm } = item; + + if (lgtd == null || lttd == null) { + return null; + } + + const lon = Number(lgtd); + const lat = Number(lttd); + if (isNaN(lon) || isNaN(lat) || !isFinite(lon) || !isFinite(lat)) { + return null; + } + if (Math.abs(lon) > 180 || Math.abs(lat) > 90) { + return null; + } + + const coord = fromLonLat([lon, lat]); + if (!isFinite(coord[0]) || !isFinite(coord[1])) { + return null; + } + + let iconUrl = iconCode ? getIconPath(iconCode) : ''; + if (!iconUrl) { + iconUrl = getIconPath('default') || ''; + } + + const feature = new Feature({ + geometry: new Point(coord), + ...item, + _layerKey: targetLayerKey + }); + + feature.setId(item.stcd || item._id); + feature.set('_iconUrl', iconUrl); + feature.set('_labelText', titleName || stnm || ennm || ''); + feature.set('_legendVisible', true); + feature.set('_sttpMap', item.sttpMap); + if (item.popupHtml) { + feature.set('popupHtml', item.popupHtml); + } + + return feature; + } + + // 备注:重置单个图层的字段索引,供重新灌入数据或删除图层时复用。 + private resetLayerFeatureIndexes(layerKey: string): void { + this.layerFeatureIndexes.set(layerKey, new Map()); + } + + // 备注:按字段值给要素建立索引,供图例切换时快速命中目标锚点集合。 + private indexFeature(layerKey: string, feature: Feature): void { + const fieldIndexMap = + this.layerFeatureIndexes.get(layerKey) || + new Map>(); + + const legendFieldKey = 'anchoPointState'; + const legendFieldValue = feature.get(legendFieldKey); + if (legendFieldValue != null) { + const valueMap = + fieldIndexMap.get(legendFieldKey) || new Map(); + const normalizedValue = String(legendFieldValue); + const targetFeatures = valueMap.get(normalizedValue) || []; + targetFeatures.push(feature); + valueMap.set(normalizedValue, targetFeatures); + fieldIndexMap.set(legendFieldKey, valueMap); + } + + this.layerFeatureIndexes.set(layerKey, fieldIndexMap); + } + + // 备注:根据图层 key、字段名和值快速返回目标要素集合,避免图例切换时整层遍历。 + private getIndexedFeatures( + layerKey: string, + fieldKey: string, + matchValue: string + ): Feature[] { + const fieldIndexMap = this.layerFeatureIndexes.get(layerKey); + if (!fieldIndexMap) return []; + + const valueMap = fieldIndexMap.get(fieldKey); + if (!valueMap) return []; + + return valueMap.get(String(matchValue)) || []; + } +} diff --git a/frontend/src/components/gis/ol/popup-manager.ts b/frontend/src/components/gis/ol/popup-manager.ts new file mode 100644 index 00000000..e9a947ee --- /dev/null +++ b/frontend/src/components/gis/ol/popup-manager.ts @@ -0,0 +1,290 @@ +import Feature from 'ol/Feature'; +import Point from 'ol/geom/Point'; +import OlMap from 'ol/Map'; +import Overlay from 'ol/Overlay'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import { generatePopupHtml } from '@/utils/popupHtmlGenerator'; + +type PopupManagerOptions = { + map: OlMap | null; + getPointLayers: () => VectorLayer[]; +}; + +type PopupHitResult = { + detectedFeature: Feature | undefined; + isHitIcon: boolean; + coordinate?: number[]; +}; + +type PointerMoveChangePayload = { + hoveredId: string | number | null; + detectedFeature: Feature | undefined; + coordinate?: number[]; +}; + +export class PopupManager { + private map: OlMap | null; + private popupOverlay: Overlay | null = null; + private popupElement: HTMLElement | null = null; + private lastHoveredId: string | number | null = null; + private animationFrameId: number | null = null; + private getPointLayers: () => VectorLayer[]; + private popupMouseEnterHandler: (() => void) | null = null; + private popupMouseMoveHandler: (() => void) | null = null; + private hoverChangeHandler: + | ((payload: PointerMoveChangePayload) => void) + | undefined; + + constructor(options: PopupManagerOptions) { + this.map = options.map; + this.getPointLayers = options.getPointLayers; + } + + // 备注:同步地图实例,供地图初始化和销毁后更新 Popup 管理器上下文。 + setMap(map: OlMap | null) { + this.map = map; + } + + // 备注:初始化 Popup Overlay 并挂载到地图实例。 + initPopupOverlay(container: HTMLElement) { + this.popupElement = container; + this.popupElement.style.setProperty('pointer-events', 'none', 'important'); + this.popupMouseEnterHandler = () => { + this.forceHidePopup(); + }; + this.popupMouseMoveHandler = () => { + this.forceHidePopup(); + }; + this.popupElement.addEventListener( + 'mouseenter', + this.popupMouseEnterHandler + ); + this.popupElement.addEventListener('mousemove', this.popupMouseMoveHandler); + this.popupOverlay = new Overlay({ + element: this.popupElement, + positioning: 'bottom-center', + offset: [0, -10], + stopEvent: false, + autoPan: false + }); + + if (this.map) { + this.map.addOverlay(this.popupOverlay); + } + } + + // 备注:统一处理地图点击命中检测,命中图标时通过回调把要素抛给外层。 + handleMapClick( + pixel: number[], + onHit?: (feature: Feature, coordinate?: number[]) => void + ) { + const { detectedFeature, isHitIcon, coordinate } = + this.detectFeatureAtPixel(pixel); + if (detectedFeature && isHitIcon) { + onHit?.(detectedFeature, coordinate); + } + } + + // 备注:统一处理鼠标悬停命中检测与节流,只有 hover 目标变化时才通知外层刷新。 + handlePointerMove( + pixel: number[], + onHoverChange?: (payload: PointerMoveChangePayload) => void + ) { + if (!this.map) return; + this.hoverChangeHandler = onHoverChange; + + const zoom = this.map.getView().getZoom(); + if (zoom !== undefined && zoom < 4.7) { + return; + } + + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + } + + this.animationFrameId = requestAnimationFrame(() => { + const { detectedFeature, isHitIcon, coordinate } = + this.detectFeatureAtPixel(pixel); + const hoveredId = + detectedFeature && isHitIcon ? (detectedFeature.getId() as any) : null; + const nextFeature = hoveredId ? detectedFeature : undefined; + const nextCoordinate = hoveredId ? coordinate : undefined; + + if (hoveredId !== this.lastHoveredId) { + this.emitHoverChange({ + hoveredId, + detectedFeature: nextFeature, + coordinate: nextCoordinate + }); + } + }); + } + + // 备注:在指定像素位置检测点图层要素,并判断是否命中图标区域。 + detectFeatureAtPixel(pixel: number[]): PopupHitResult { + let detectedFeature: Feature | undefined = undefined; + let isHitIcon = false; + let coordinate: number[] | undefined; + const pointLayers = this.getPointLayers(); + + if (this.isPixelInsidePopup(pixel)) { + return { + detectedFeature: undefined, + isHitIcon: false, + coordinate: undefined + }; + } + + this.map?.forEachFeatureAtPixel( + pixel, + (feature, layer) => { + if (pointLayers.includes(layer as VectorLayer)) { + detectedFeature = feature as Feature; + const geom = feature.getGeometry(); + if (geom && geom.getType() === 'Point') { + coordinate = (geom as Point).getCoordinates(); + const iconPixel = this.map?.getPixelFromCoordinate(coordinate); + if (iconPixel) { + const dx = pixel[0] - iconPixel[0]; + const dy = pixel[1] - iconPixel[1]; + if (this.isPixelInsideIconArea(dx, dy)) { + isHitIcon = true; + } + } + } + + return true; + } + return false; + }, + { hitTolerance: 0 } + ); + + return { detectedFeature, isHitIcon, coordinate }; + } + + // 备注:仅把图标本体区域作为 hover 命中范围,避免上方文字标签被误判为图标命中。 + private isPixelInsideIconArea(dx: number, dy: number): boolean { + if (!this.map) return false; + + const zoom = this.map.getView().getZoom() ?? 4.5; + let dynamicScale = 0.7 + (zoom - 4.5) * 0.08; + dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale)); + + const halfWidth = 12 * dynamicScale; + const topReach = 8 * dynamicScale; + const bottomReach = 12 * dynamicScale; + + return ( + dx >= -halfWidth && + dx <= halfWidth && + dy >= -topReach && + dy <= bottomReach + ); + } + + // 备注:根据当前悬停要素统一显示或隐藏 Popup 内容。 + showPopup(feature: Feature | undefined, coordinate: number[] | undefined) { + if (!this.popupOverlay || !this.popupElement) return; + + if (feature && coordinate) { + const props = feature.getProperties(); + const popupHtml = props.popupHtml || generatePopupHtml(props); + + if (popupHtml) { + this.popupElement.innerHTML = popupHtml; + this.popupOverlay.setPosition(coordinate); + this.popupElement.style.display = 'block'; + return; + } + } + + this.popupOverlay.setPosition(undefined); + this.popupElement.style.display = 'none'; + } + + // 备注:统一重置 hover 和 Popup 状态,供地图销毁和切换时复用。 + reset() { + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + this.emitHoverChange({ + hoveredId: null, + detectedFeature: undefined, + coordinate: undefined + }); + } + + // 备注:在弹窗层意外接管鼠标时,通过统一 hover 回调链路立即清空当前悬停状态。 + private forceHidePopup() { + this.emitHoverChange({ + hoveredId: null, + detectedFeature: undefined, + coordinate: undefined + }); + } + + // 备注:统一派发 hover 变化,确保地图侧的 cursor、Popup 和图层刷新走同一条处理链路。 + private emitHoverChange(payload: PointerMoveChangePayload) { + this.lastHoveredId = payload.hoveredId; + this.hoverChangeHandler?.(payload); + if (!this.hoverChangeHandler) { + this.showPopup(payload.detectedFeature, payload.coordinate); + } + } + + // 备注:当鼠标进入当前 Popup 可见区域时,禁止再把该区域重新识别为图标 hover。 + private isPixelInsidePopup(pixel: number[]): boolean { + if (!this.map || !this.popupElement) { + return false; + } + + if (this.popupElement.style.display === 'none') { + return false; + } + + const popupRect = this.popupElement.getBoundingClientRect(); + if (popupRect.width <= 0 || popupRect.height <= 0) { + return false; + } + + const mapRect = this.map.getTargetElement().getBoundingClientRect(); + const clientX = mapRect.left + pixel[0]; + const clientY = mapRect.top + pixel[1]; + + return ( + clientX >= popupRect.left && + clientX <= popupRect.right && + clientY >= popupRect.top && + clientY <= popupRect.bottom + ); + } + + // 备注:销毁 Popup 管理器内部状态和 Overlay 引用。 + destroy() { + this.reset(); + if (this.popupElement && this.popupMouseEnterHandler) { + this.popupElement.removeEventListener( + 'mouseenter', + this.popupMouseEnterHandler + ); + } + if (this.popupElement && this.popupMouseMoveHandler) { + this.popupElement.removeEventListener( + 'mousemove', + this.popupMouseMoveHandler + ); + } + if (this.map && this.popupOverlay) { + this.map.removeOverlay(this.popupOverlay); + } + this.popupMouseEnterHandler = null; + this.popupMouseMoveHandler = null; + this.hoverChangeHandler = undefined; + this.popupOverlay = null; + this.popupElement = null; + this.map = null; + } +} diff --git a/frontend/src/components/gis/ol/region-mask-manager.ts b/frontend/src/components/gis/ol/region-mask-manager.ts new file mode 100644 index 00000000..5ca3de85 --- /dev/null +++ b/frontend/src/components/gis/ol/region-mask-manager.ts @@ -0,0 +1,341 @@ +import OlMap from 'ol/Map'; +import View from 'ol/View'; +import TileLayer from 'ol/layer/Tile'; +import GeoJSON from 'ol/format/GeoJSON'; +import VectorSource from 'ol/source/Vector'; +import { fromLonLat } from 'ol/proj'; +import { PointLayerManager } from './point-layer-manager'; + +type RegionMaskManagerOptions = { + map: OlMap | null; + view: View | null; + pointLayerManager: PointLayerManager; + defaultCenter: [number, number]; + defaultZoom: number; +}; + +export class RegionMaskManager { + private map: OlMap | null; + private view: View | null; + private pointLayerManager: PointLayerManager; + private defaultCenter: [number, number]; + private defaultZoom: number; + private maskBindings: Array<{ + layer: TileLayer; + prerender: (event: any) => void; + postrender: (event: any) => void; + }> = []; + + constructor(options: RegionMaskManagerOptions) { + this.map = options.map; + this.view = options.view; + this.pointLayerManager = options.pointLayerManager; + this.defaultCenter = options.defaultCenter; + this.defaultZoom = options.defaultZoom; + } + + // 备注:同步地图和视图上下文,供地图初始化、销毁和重建后复用区域过滤能力。 + setContext(map: OlMap | null, view: View | null) { + this.map = map; + this.view = view; + } + + // 备注:统一隐藏所有点位的区域显示状态,供基地裁切前的预处理复用。 + hideAllPoints(): void { + this.pointLayerManager.forEachFeature(feature => { + feature.set('_regionVisible', false); + feature.changed(); + }); + } + + // 备注:统一恢复所有点位的区域显示状态,供取消裁切或异常兜底时复用。 + showAllPoints(): void { + this.pointLayerManager.forEachFeature(feature => { + feature.set('_regionVisible', true); + feature.changed(); + }); + } + + // 备注:根据 GeoJSON 边界批量更新点位的区域显隐状态。 + filterPointsByRegion(geoJson: any): void { + const regionCoords = this.extractPolygonCoords(geoJson); + if (!regionCoords.length) { + console.warn('无法解析区域边界,显示所有锚点'); + this.showAllPoints(); + return; + } + + this.pointLayerManager.forEachFeature(feature => { + const props = feature.getProperties(); + const lon = Number(props.lgtd); + const lat = Number(props.lttd); + + if (!isFinite(lon) || !isFinite(lat)) { + feature.set('_regionVisible', false); + feature.changed(); + return; + } + + feature.set('_regionVisible', this.isPointInPolygon(lon, lat, regionCoords)); + feature.changed(); + }); + } + + // 备注:根据 GeoJSON 范围拟合地图视野,供基地切换后快速聚焦到目标区域。 + fitViewToGeoJson(geoJson: any): void { + if (!this.map || !this.view) return; + + try { + const features = new GeoJSON().readFeatures(geoJson, { + dataProjection: 'EPSG:4326', + featureProjection: 'EPSG:3857' + }); + + if (!features.length) return; + + const source = new VectorSource({ + features + }); + const extent = source.getExtent(); + + if (extent[0] === Infinity || extent[0] === -Infinity) { + console.warn('无法计算有效的地图包围盒'); + return; + } + + this.view.fit(extent, { + padding: [100, 200, 50, 50], + duration: 1000, + maxZoom: 13 + }); + } catch (error) { + console.error('调整地图视野失败:', error); + } + } + + // 备注:把 GeoJSON 边界绑定到底图 prerender/postrender 事件,实现区域裁切遮罩。 + applyMapMask(rasterLayer: TileLayer, clipGeoJson: any): void { + this.applyMapMaskToLayers(rasterLayer ? [rasterLayer] : [], clipGeoJson); + } + + // 备注:把 GeoJSON 边界同时绑定到多个底图图层,保证多底图叠加场景下裁切边界保持一致。 + applyMapMaskToLayers(rasterLayers: TileLayer[] = [], clipGeoJson: any): void { + if (!clipGeoJson) return; + + const targetLayers = rasterLayers.filter(Boolean); + if (targetLayers.length === 0) return; + + const features = new GeoJSON().readFeatures(clipGeoJson, { + dataProjection: 'EPSG:4326', + featureProjection: 'EPSG:3857' + }); + + if (!features.length) { + console.warn('裁切数据为空,无法应用遮罩'); + return; + } + + this.clearMaskEvents(); + + targetLayers.forEach(rasterLayer => { + const maskPrerender = (event: any) => { + const context = event.context; + const frameState = event.frameState; + + if (!context || !frameState || !this.map) return; + + context.save(); + context.setTransform(1, 0, 0, 1, 0, 0); + context.beginPath(); + + let hasValidPath = false; + + for (const feature of features) { + const geometry = feature.getGeometry(); + if (!geometry) continue; + + if (geometry.getType() === 'Polygon') { + const rings = (geometry as any).getCoordinates() as number[][][]; + for (const ring of rings) { + if (this.drawRingToContext(context, ring, frameState)) { + hasValidPath = true; + } + } + } else if (geometry.getType() === 'MultiPolygon') { + const polygons = (geometry as any).getCoordinates() as number[][][][]; + for (const polygonRings of polygons) { + for (const ring of polygonRings) { + if (this.drawRingToContext(context, ring, frameState)) { + hasValidPath = true; + } + } + } + } + } + + if (!hasValidPath) { + console.warn('未能生成有效的裁切路径'); + return; + } + + context.save(); + context.lineWidth = 6; + context.strokeStyle = '#6D64DF'; + context.stroke(); + context.clip(); + context.strokeStyle = '#CCC9F4'; + context.lineWidth = 5; + context.stroke(); + context.clip(); + }; + + const maskPostrender = (event: any) => { + if (event.context) { + event.context.restore(); + event.context.restore(); + } + }; + + this.maskBindings.push({ + layer: rasterLayer, + prerender: maskPrerender, + postrender: maskPostrender + }); + + rasterLayer.on('prerender', maskPrerender); + rasterLayer.on('postrender', maskPostrender); + rasterLayer.changed(); + }); + } + + // 备注:清除当前底图遮罩并按需恢复默认视野,供切换基地和异常兜底复用。 + clearMapMask(resetView: boolean = true): void { + const maskedLayers = this.maskBindings.map(binding => binding.layer); + this.clearMaskEvents(); + + maskedLayers.forEach(layer => layer.changed()); + + if (resetView && this.view) { + this.view.animate({ + center: fromLonLat(this.defaultCenter), + zoom: this.defaultZoom, + duration: 1000 + }); + } + } + + // 备注:统一销毁区域过滤相关引用和事件,供地图销毁阶段复用。 + destroy(): void { + this.clearMapMask(false); + this.map = null; + this.view = null; + } + + // 备注:从 GeoJSON 中提取可用于点在面判断的多边形环坐标。 + private extractPolygonCoords(geoJson: any): number[][][] { + if (!geoJson?.features?.length) { + return []; + } + + const coords: number[][][] = []; + geoJson.features.forEach((feature: any) => { + const geometry = feature.geometry; + if (!geometry) return; + + if (geometry.type === 'Polygon') { + coords.push(...geometry.coordinates); + } else if (geometry.type === 'MultiPolygon') { + geometry.coordinates.forEach((polygon: number[][][]) => { + coords.push(...polygon); + }); + } + }); + + return coords; + } + + // 备注:使用射线法判断点是否落在任意一个区域环内。 + private isPointInPolygon( + lon: number, + lat: number, + polygons: number[][][] + ): boolean { + for (const polygon of polygons) { + if (this.isPointInRing(lon, lat, polygon)) { + return true; + } + } + return false; + } + + // 备注:使用射线法判断点是否落在单个闭合环内。 + private isPointInRing(lon: number, lat: number, ring: number[][]): boolean { + let inside = false; + const ringLength = ring.length; + + for (let i = 0, j = ringLength - 1; i < ringLength; j = i++) { + const xi = ring[i][0]; + const yi = ring[i][1]; + const xj = ring[j][0]; + const yj = ring[j][1]; + + const intersect = + yi > lat !== yj > lat && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + + if (intersect) { + inside = !inside; + } + } + + return inside; + } + + // 备注:把单个面环转换为当前帧 Canvas 可裁切的路径。 + private drawRingToContext( + context: CanvasRenderingContext2D, + ring: number[][], + frameState: any + ): boolean { + if (!ring || ring.length < 3) return false; + + const pixelRatio = frameState.pixelRatio || window.devicePixelRatio || 1; + let moved = false; + + for (let i = 0; i < ring.length; i++) { + const coord = ring[i]; + if (!coord || coord.length < 2 || typeof coord[0] !== 'number') { + continue; + } + + const cssPixel = this.map?.getPixelFromCoordinate(coord as [number, number]); + if (!cssPixel) continue; + + const canvasX = cssPixel[0] * pixelRatio; + const canvasY = cssPixel[1] * pixelRatio; + + if (!moved) { + context.moveTo(canvasX, canvasY); + moved = true; + } else { + context.lineTo(canvasX, canvasY); + } + } + + if (moved) { + context.closePath(); + return true; + } + + return false; + } + + // 备注:解除旧底图上的遮罩事件,避免重复绑定或残留旧裁切效果。 + private clearMaskEvents(): void { + this.maskBindings.forEach(binding => { + binding.layer.un('prerender', binding.prerender); + binding.layer.un('postrender', binding.postrender); + }); + this.maskBindings = []; + } +} diff --git a/frontend/src/components/mapController/LayerController.vue b/frontend/src/components/mapController/LayerController.vue index c40bb637..885932d0 100644 --- a/frontend/src/components/mapController/LayerController.vue +++ b/frontend/src/components/mapController/LayerController.vue @@ -30,24 +30,23 @@ \ No newline at end of file +