12 KiB
地图模块-OpenLayers抽吸与碰撞分析
1. 背景
当前地图 2D 主引擎已经切换为 OpenLayers,项目依赖版本为 ol@^10.8.0。
本次关注的问题有两个:
- 点位“抽吸”逻辑不符合业务预期;
- 文字碰撞时把整个点位一起隐藏,而不是仅隐藏文字。
典型场景是两个相邻电站,例如“积石峡”和“公伯峡”:
- 可用代号
1、2表示两个相邻点; - 默认缩放下只展示
1; - 放大到某个层级后,
1隐藏,2展示; - 再继续放大后,
1和2都展示,但2需要“抽吸/拉开”显示,避免与1重叠; - 如果只是文字发生碰撞,希望点图标保留,仅文字隐藏或延后显示。
本文件先整理当前代码现状和问题成因,不直接修改实现。
如果需要查看当前已经落地的抽吸规则、默认参数和调参入口,请同时参考:
docs/地图模块-抽吸规则与参数说明.md
2. 当前代码结构
当前前端 GIS 运行链路如下:
src/components/gis/GisView.vuesrc/modules/map/application/map-orchestrator.tssrc/components/gis/map.class.tssrc/components/gis/map.ol.tssrc/components/gis/ol/point-layer-manager.tssrc/components/gis/ol/popup-manager.ts
各层职责简述:
-
GisView.vue- 地图入口组件;
- 挂载地图容器、图例、筛选器、控制器;
- 通过
mapOrchestrator.mountView()初始化地图; - 菜单切换时通过
handlePageChange()触发页面地图重载。
-
map.class.ts- 地图门面类;
- 当前 2D 实现实际绑定的是
MapOl; - 3D 实现才会切到
MapCesium。
-
map.ol.ts- OpenLayers 主实现;
- 负责地图初始化、底图加载、点位样式、Popup、区域裁切、量算等;
- 当前“点位渲染规则”和“碰撞相关逻辑”主要集中在这个文件。
-
point-layer-manager.ts- 负责点图层创建、Feature 灌入、按图层控制显隐;
- 点位图层统一使用
VectorLayer + VectorSource。
-
popup-manager.ts- 管理 hover popup 和高缩放下的批量 popup;
- 内部额外做了一套基于边界框的碰撞判断。
3. 当前点位渲染链路
3.1 点图层创建
point-layer-manager.ts 中的 ensureLayer() 会统一创建点图层:
- 图层类型为
VectorLayer<VectorSource>; - 图层上开启了
declutter: true; - 样式函数统一走
map.ol.ts中的createPointStyle()。
也就是说,当前所有点位的“是否显示”和“图标/文字如何避让”,都收敛到 OpenLayers 的 declutter 机制和样式函数里。
3.2 点样式生成
map.ol.ts 的 createPointStyle() 当前做了这些事:
- 读取
_iconUrl和_labelText; - 如果图例不可见、区域不可见,则直接返回
null; - 如果
distance不满足当前缩放级别要求,也直接返回null; - 图标使用
Icon; - 文字使用
Text; - 图标和文字都设置了
declutterMode: 'declutter'; - 最终把图标和文字一起放进同一个
Style返回。
这意味着:
- 点图标和文字不是两个独立的渲染对象;
- 它们在当前实现中属于同一个样式结果;
- 只要这组样式参与碰撞判定,图标和文字的命运就是绑定的。
3.3 当前已有的“抽稀”逻辑
当前项目中确实有一层“缩放越小,显示越少”的处理,但它不是精细的“抽吸”。
map.ol.ts 的 shouldRenderFeatureByDistance() 逻辑是:
- 小缩放下要求
distance更大才允许显示; - 缩放越大,允许显示的点逐渐增多;
- 本质上是基于后端数据字段
distance做全局抽稀。
这个逻辑的特点是:
- 是全局阈值,不是成对点位或同组点位的专属规则;
- 只能决定“这个点显示还是不显示”;
- 不能表达“1 和 2 是近邻关系,并且在不同缩放阶段按业务优先级切换显示”。
3.4 当前批量 Popup 的碰撞检测
popup-manager.ts 在高缩放批量 popup 模式下,又额外做了一层碰撞判断:
- 先估算图标和文字合并后的锚点边界框;
- 如果边界框碰撞,则当前要素直接不参与 popup 渲染;
- 随后再判断 popup 矩形之间是否碰撞。
这说明当前“图标 + 文字”被视为一个整体,不仅体现在 OpenLayers 的 declutter 里,也体现在 popup 的辅助判断里。
4. 当前仓库里“抽吸”相关代码现状
仓库中确实存在历史上的“抽吸”实现,但不在当前 OpenLayers 主链路里。
相关文件:
src/utils/leaflet/leaflet.inflatable-markers-group.jssrc/components/gis/map.leaflet.ts
现状判断:
leaflet.inflatable-markers-group.js是旧 Leaflet 时代的抽吸插件;- 它内部有
inflateAsManyAsPossible()、_zoomend()等典型抽吸逻辑; - 但当前
map.class.ts实际实例化的是new MapOl(); map.leaflet.ts已经不在主运行路径上。
结论是:
- 当前页面上看到的点位行为,不是 Leaflet 抽吸插件在工作;
- 现在的显示结果主要来自 OpenLayers 的
declutter、当前样式函数和distance抽稀逻辑; - 因此“已经有抽吸但是不太对”,更准确地说是“历史上有抽吸代码,但现在主链路没有真正使用那套机制”。
5. 问题分析
5.1 为什么当前实现做不到你要的分阶段抽吸
你描述的目标并不是简单“避让”,而是一个明确的分阶段显示策略:
- 默认只显示
1; - 缩放到阶段 A 时,隐藏
1,改为显示2; - 缩放到阶段 B 时,
1和2都显示; - 阶段 B 下,
2还要相对1做抽吸/偏移,避免完全重叠。
而当前代码缺少以下能力:
-
没有“近邻点关系”配置
- 代码里没有看到类似
groupId、neighborIds、priority、displayLevel这样的结构; - 系统不知道“积石峡”和“公伯峡”是一组特殊近邻点。
- 代码里没有看到类似
-
没有“分阶段显示规则”配置
- 当前只有全局的
distance阈值判断; - 没有按缩放阶段表达“谁替代谁显示、谁先隐藏、谁后展示”。
- 当前只有全局的
-
没有“抽吸偏移”计算
- 当前点要素坐标就是原始经纬度转换结果;
- 没有根据缩放级别和邻近关系生成偏移坐标;
- 也没有“展开后沿圆周/沿固定方向偏移”的逻辑。
-
当前 declutter 是被动避让,不是主动编排
- declutter 只会尽量避免重叠;
- 它不会理解业务上的“1 是主点、2 是附点”;
- 也不会自动实现“先 1,后 2,再两个都展示且 2 偏移”的规则。
所以当前看到的结果不稳定,本质原因不是某一个参数不对,而是“现有机制和目标能力不是一类问题”。
5.2 为什么文字碰撞会把整个点隐藏
这是当前实现里最关键的原因:
- 点图标和文字是在同一个
Style里返回的; - 图层开启了
declutter: true; - 图标和文字又都设置了
declutterMode: 'declutter'。
在这种组织方式下,当前要素的碰撞判定是按“一个整体渲染单元”处理的,而不是“图标一套规则,文字一套规则”。
因此会出现下面的现象:
- 图标本身其实不冲突;
- 但文字边界框发生碰撞;
- 最终被隐藏的是整条样式结果;
- 用户视觉上就会觉得“明明只是字挤了,为什么点也没了”。
这和你现在观察到的问题是一致的。
5.3 为什么当前逻辑还会进一步放大这个问题
除了 OpenLayers 自身的 declutter 组织方式,代码里还有两层因素会继续放大问题:
shouldRenderFeatureByDistance()会先根据distance直接过滤点位;popup-manager.ts在批量 popup 模式下,也把“图标 + 文字”当作合并边界框做碰撞模拟。
也就是说,当前系统对近邻点位的处理是:
- 先用全局
distance规则筛掉一部分; - 再用 OpenLayers declutter 继续过滤;
- 高缩放时 popup 层再做一轮碰撞跳过。
这三层叠加后,显示行为更偏向“谁先撞谁消失”,而不是“按业务规则分阶段展开”。
6. 结合你的案例做具体映射
如果以近邻点 1、2 为例,当前代码里实际上缺的是下面这些业务语义:
1和2属于同一近邻组;- 组内有主次优先级;
- 在不同 zoom 区间内有不同展示策略;
- 在最大放大阶段需要给
2一个偏移位; - 偏移后图标和文字还要分别控制碰撞策略。
当前实现只能表达:
- 某个点在当前 zoom 下是否允许渲染;
- 某个点图例是否可见;
- 某个点是否被区域裁切隐藏;
- 某个点的图标和文字整体是否参与 declutter。
它并不能表达:
- “1 替代 2 展示”;
- “2 替代 1 展示”;
- “1 和 2 一起展示,但 2 抽吸展开”;
- “只隐藏文字,不隐藏图标”。
所以你现在遇到的问题是结构性问题,不是简单调一个 declutter 参数就能彻底解决。
7. 后续修改时建议优先动的层
如果下一步要真正改这块,建议优先从下面几层入手:
7.1 先补“近邻点显示规则”
建议不要直接把业务规则写死在样式函数里,而是先增加一层近邻点编排规则,例如:
groupIdpriorityshowZoomMinshowZoomMaxreplaceAtZoomexpandAtZoomexpandOffset
这样才能表达:
- 默认只显示主点;
- 中间层级切换成副点;
- 更大层级两个都显示;
- 副点在展示时做偏移。
7.2 抽吸不要依赖 declutter 自动完成
抽吸本质上是“主动布局”问题,不是“被动避让”问题。
建议后续把近邻点抽吸拆成单独逻辑:
- 先根据原始点数据识别近邻组;
- 再根据当前 zoom 产出“本次真正参与渲染的点集合”;
- 对需要展开的点,生成偏移后的渲染坐标;
- 最后再交给图层渲染。
这样会比直接指望 OpenLayers 的 declutter 更可控。
7.3 图标层和文字层要分开控制
要实现“文字碰撞时只隐藏文字,不隐藏点图标”,后续最重要的一点是把图标和文字的渲染职责拆开。
至少要做到下面二选一:
- 图标层和文字层拆成两个独立图层;
- 或者把图标与文字拆成可独立决策的渲染单元。
目标是:
- 图标优先保留;
- 文字单独避让;
- 文本碰撞时只压文字,不压图标。
如果仍然维持“同一个样式对象里同时放 image 和 text”的组织方式,这个问题大概率还会持续存在。
7.4 Popup 碰撞策略也要和新规则同步
后续如果点位抽吸和文字单独避让改了,popup-manager.ts 里的边界框逻辑也要一起调整。
否则会出现:
- 图标已经正常显示;
- 文字也按新规则显示了;
- 但 popup 层仍然沿用旧的合并边界框;
- 最终又在 popup 阶段把结果打回去。
8. 建议的改造顺序
为了降低风险,建议后续改造按以下顺序推进:
-
先梳理近邻点数据结构
- 确定近邻点如何分组;
- 确定主次优先级和缩放阈值从哪里来。
-
再实现“显示决策层”
- 在真正生成 Style 之前,先决定哪些点应该显示;
- 哪些点应该隐藏;
- 哪些点需要偏移展开。
-
然后拆分图标和文字碰撞
- 先保证点图标稳定显示;
- 再单独做文字避让。
-
最后调整 popup 逻辑
- 保证 popup 的碰撞判定和最终可见点保持一致。
9. 涉及文件清单
本次分析重点涉及以下文件:
src/components/gis/GisView.vuesrc/components/gis/map.class.tssrc/components/gis/map.ol.tssrc/components/gis/ol/point-layer-manager.tssrc/components/gis/ol/popup-manager.tssrc/utils/leaflet/leaflet.inflatable-markers-group.jssrc/components/gis/map.leaflet.tspackage.json
10. 当前结论
当前地图问题可以归纳为两点:
- 现在的 OpenLayers 主链路并没有真正使用旧 Leaflet 的抽吸机制;
- 当前点图标和文字被作为一个整体参与 declutter,所以文字碰撞时会连点一起隐藏。
所以后续如果要改,方向不应该只是“调避让参数”,而应该是:
- 增加近邻点分阶段显示规则;
- 增加主动抽吸/偏移布局;
- 把图标和文字拆成可独立控制的渲染单元;
- 同步修正 popup 的碰撞判断。
在这个基础上,再进入代码修改会更稳。