地图模块修改
This commit is contained in:
parent
0398be3d83
commit
3200f1f36f
Binary file not shown.
@ -16,7 +16,10 @@
|
|||||||
- 再继续放大后,`1` 和 `2` 都展示,但 `2` 需要“抽吸/拉开”显示,避免与 `1` 重叠;
|
- 再继续放大后,`1` 和 `2` 都展示,但 `2` 需要“抽吸/拉开”显示,避免与 `1` 重叠;
|
||||||
- 如果只是文字发生碰撞,希望点图标保留,仅文字隐藏或延后显示。
|
- 如果只是文字发生碰撞,希望点图标保留,仅文字隐藏或延后显示。
|
||||||
|
|
||||||
本文件先整理当前代码现状和问题成因,不直接修改实现。
|
本文件先整理当前代码现状和问题成因,不直接修改实现。
|
||||||
|
如果需要查看**当前已经落地的抽吸规则、默认参数和调参入口**,请同时参考:
|
||||||
|
|
||||||
|
- `docs/地图模块-抽吸规则与参数说明.md`
|
||||||
|
|
||||||
## 2. 当前代码结构
|
## 2. 当前代码结构
|
||||||
|
|
||||||
|
|||||||
316
frontend/docs/地图模块-抽吸规则与参数说明.md
Normal file
316
frontend/docs/地图模块-抽吸规则与参数说明.md
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
# 地图模块-抽吸规则与参数说明
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
本文档用于记录当前地图点位“抽吸”功能的现行实现、默认参数、分层级显示规则和后续调参方式。
|
||||||
|
本文件描述的是**当前已经落地并验证通过**的规则,适合作为后续维护和继续调参的依据。
|
||||||
|
|
||||||
|
相关文档:
|
||||||
|
|
||||||
|
- `docs/地图模块-详细说明.md`:地图整体架构说明
|
||||||
|
- `docs/地图模块-OpenLayers抽吸与碰撞分析.md`:问题成因与改造分析
|
||||||
|
|
||||||
|
## 2. 当前实现范围
|
||||||
|
|
||||||
|
当前抽吸逻辑运行在 OpenLayers 主链路中,核心文件如下:
|
||||||
|
|
||||||
|
1. `src/modules/map/domain/nearby-point-rules.ts`
|
||||||
|
2. `src/store/modules/map.ts`
|
||||||
|
3. `src/components/gis/map.ol.ts`
|
||||||
|
4. `src/components/gis/ol/point-layer-manager.ts`
|
||||||
|
|
||||||
|
各文件职责:
|
||||||
|
|
||||||
|
- `nearby-point-rules.ts`
|
||||||
|
- 近邻点规则入口
|
||||||
|
- 自动识别配置入口
|
||||||
|
- 近邻点运行时元数据挂载
|
||||||
|
- `map.ts`
|
||||||
|
- 点位数据加载完成后,统一触发近邻点分组和元数据写回
|
||||||
|
- `map.ol.ts`
|
||||||
|
- 决定某个点在当前缩放级别是否显示
|
||||||
|
- 决定文字是否允许显示
|
||||||
|
- 承担图标与文字的手工碰撞控制
|
||||||
|
- `point-layer-manager.ts`
|
||||||
|
- 负责展开阶段的几何偏移
|
||||||
|
- 负责把同组点位按扇形/环形布局散开
|
||||||
|
|
||||||
|
## 3. 当前抽吸规则
|
||||||
|
|
||||||
|
### 3.1 近邻点自动识别
|
||||||
|
|
||||||
|
当前采用自动识别近邻点,不再靠固定站名手工枚举。
|
||||||
|
|
||||||
|
识别原则:
|
||||||
|
|
||||||
|
- 只在同一 `layerKey` 内做近邻识别
|
||||||
|
- 按点位空间距离自动聚组
|
||||||
|
- 满足最小组大小后,才认为是近邻组
|
||||||
|
- 组内自动生成显示优先级 `priority`
|
||||||
|
|
||||||
|
这样做的目的:
|
||||||
|
|
||||||
|
- 避免跨图层混组
|
||||||
|
- 避免后续新增点位还要手工补名字
|
||||||
|
- 让抽吸能力可以覆盖整批业务点
|
||||||
|
|
||||||
|
### 3.2 分层级显示规则
|
||||||
|
|
||||||
|
当前显示规则以 `replaceAtZoom` 和 `expandAtZoom` 为核心:
|
||||||
|
|
||||||
|
1. `zoom < replaceAtZoom`
|
||||||
|
- 只显示组内主点
|
||||||
|
- 即 `priority = 1`
|
||||||
|
2. `replaceAtZoom <= zoom < expandAtZoom`
|
||||||
|
- 显示组内前两个点
|
||||||
|
- 即 `priority <= 2`
|
||||||
|
- 当前这样设计是为了避免主点在放大过程中突然消失
|
||||||
|
3. `zoom >= expandAtZoom`
|
||||||
|
- 同组点全部显示
|
||||||
|
- 同时进入展开布局阶段
|
||||||
|
|
||||||
|
补充规则:
|
||||||
|
|
||||||
|
- 如果某个点配置了 `showZoomMin` 或 `showZoomMax`,则先受这两个值约束
|
||||||
|
- 没有进入近邻组的点,不受抽吸规则影响
|
||||||
|
|
||||||
|
### 3.3 文字显示规则
|
||||||
|
|
||||||
|
当前文字显示必须依赖点位显示。
|
||||||
|
|
||||||
|
也就是说:
|
||||||
|
|
||||||
|
- 点不显示,文字一定不显示
|
||||||
|
- 点显示后,文字还要继续通过碰撞判断才会显示
|
||||||
|
|
||||||
|
当前文字碰撞规则:
|
||||||
|
|
||||||
|
- 文字要避让已保留的图标占位盒
|
||||||
|
- 文字要避让已保留的其他文字
|
||||||
|
- 图标与文字不再分别交给 OpenLayers 的 `declutter` 自动处理
|
||||||
|
- 当前走的是项目内的手工碰撞控制
|
||||||
|
|
||||||
|
## 4. 当前默认参数
|
||||||
|
|
||||||
|
当前默认参数集中维护在:
|
||||||
|
|
||||||
|
- `src/modules/map/domain/nearby-point-rules.ts`
|
||||||
|
|
||||||
|
当前采用统一配置对象:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type NearbyPointConfig = {
|
||||||
|
autoRule: NearbyPointAutoRule;
|
||||||
|
layoutRule: NearbyPointLayoutRule;
|
||||||
|
densityDisplayRules: NearbyPointDensityDisplayRule[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `autoRule`:控制近邻点自动识别、缩放切换和基础展开半径
|
||||||
|
- `layoutRule`:控制展开阶段的单圈容量、扇形角度和外圈间距
|
||||||
|
- `densityDisplayRules`:控制 `distance` 密度分档在不同缩放级别的基础显示资格
|
||||||
|
|
||||||
|
### 4.1 自动识别与缩放阶段参数
|
||||||
|
|
||||||
|
当前值如下:
|
||||||
|
|
||||||
|
| 参数 | 当前值 | 作用 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `distanceThresholdMeters` | `9000` | 近邻点自动识别距离阈值 |
|
||||||
|
| `replaceAtZoom` | `10.5` | 进入“主点 + 次点”阶段的缩放级别 |
|
||||||
|
| `expandAtZoom` | `12.2` | 进入“全部显示并展开”阶段的缩放级别 |
|
||||||
|
| `expandOffsetPx` | `48` | 第一圈展开的基础偏移半径 |
|
||||||
|
| `angleStepDeg` | `55` | 默认角度步长参考值 |
|
||||||
|
| `minGroupSize` | `2` | 最小成组数量 |
|
||||||
|
|
||||||
|
### 4.2 展开布局参数
|
||||||
|
|
||||||
|
当前值如下:
|
||||||
|
|
||||||
|
| 参数 | 当前值 | 作用 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `ringSize` | `5` | 单圈最多容纳的次点数量 |
|
||||||
|
| `fanAngleForTwoDeg` | `110` | 单圈剩余 `2` 个点时的扇形总角度 |
|
||||||
|
| `fanAngleForFourDeg` | `150` | 单圈剩余 `3` 到 `4` 个点时的扇形总角度 |
|
||||||
|
| `fanAngleForManyDeg` | `180` | 单圈剩余 `5` 个及以上点时的扇形总角度 |
|
||||||
|
| `ringGapPx` | `22` | 外圈最小增量半径 |
|
||||||
|
| `ringGapFactor` | `0.85` | 外圈相对基础半径的增量系数 |
|
||||||
|
|
||||||
|
### 4.3 密度分档显示参数
|
||||||
|
|
||||||
|
这里的 `distance` 不是物理距离,而是点位密度分档值。
|
||||||
|
数值越大表示越稀疏,越早参与显示候选;数值越小表示越密集,需要更高缩放级别才参与显示。
|
||||||
|
|
||||||
|
当前值如下:
|
||||||
|
|
||||||
|
| `minDensityValue` | `minZoom` | 作用 |
|
||||||
|
|---:|---:|---|
|
||||||
|
| `1500000` | `0` | 很稀疏的点,从小缩放级别就可参与显示 |
|
||||||
|
| `800000` | `6.1` | 稍密集的点,从中低缩放开始参与显示 |
|
||||||
|
| `400000` | `8.1` | 中密度点,需要进一步放大 |
|
||||||
|
| `50000` | `10.6` | 高密度点,需要较高缩放级别 |
|
||||||
|
| `0` | `12.2` | 极密集点,只有更高缩放才进入候选 |
|
||||||
|
|
||||||
|
## 5. 当前展开布局
|
||||||
|
|
||||||
|
展开阶段由 `point-layer-manager.ts` 控制。
|
||||||
|
|
||||||
|
### 5.1 布局特点
|
||||||
|
|
||||||
|
当前布局不是简单把点随机散开,而是按以下方式处理:
|
||||||
|
|
||||||
|
- 主点保留原位置
|
||||||
|
- 同组其他点围绕主点展开
|
||||||
|
- 展开方向优先偏下方
|
||||||
|
- 单圈数量由 `ringSize` 控制
|
||||||
|
- 点数较多时进入第二圈
|
||||||
|
- 第二圈半径按 `ringGapPx` 和 `ringGapFactor` 继续增大,避免继续叠在一起
|
||||||
|
|
||||||
|
### 5.2 为什么优先向下展开
|
||||||
|
|
||||||
|
当前优先向下展开,主要是为了:
|
||||||
|
|
||||||
|
- 尽量给上方文字留空间
|
||||||
|
- 降低文字压住上方点的概率
|
||||||
|
- 让地图上的抽吸形态更稳定
|
||||||
|
|
||||||
|
## 6. 当前实现的几个关键约束
|
||||||
|
|
||||||
|
### 6.1 不使用淡入淡出动画
|
||||||
|
|
||||||
|
目前已移除点和文字的淡入淡出动画。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 抽吸状态切换和动画叠加时,容易让点和文字出现闪烁
|
||||||
|
- 会干扰对“当前到底该不该显示”的判断
|
||||||
|
|
||||||
|
当前阶段优先保证:
|
||||||
|
|
||||||
|
- 显示规则稳定
|
||||||
|
- 点和文字状态一致
|
||||||
|
- 放大缩小时不出现明显错位
|
||||||
|
|
||||||
|
### 6.2 不再依赖 OL 的自动 declutter 决定图标命运
|
||||||
|
|
||||||
|
当前图标和文字都设置为 `declutterMode: 'none'`,由项目内逻辑统一控制。
|
||||||
|
|
||||||
|
这样做的原因:
|
||||||
|
|
||||||
|
- 避免图标和文字落在两套碰撞体系里
|
||||||
|
- 避免出现“文字先出来、点没出来”
|
||||||
|
- 避免文字压住其他点却仍然显示
|
||||||
|
|
||||||
|
## 7. 常见现象与调参建议
|
||||||
|
|
||||||
|
### 7.1 成组太少
|
||||||
|
|
||||||
|
表现:
|
||||||
|
|
||||||
|
- 相近点仍然容易互相盖住
|
||||||
|
- 进入展开前看起来还是压在一起
|
||||||
|
|
||||||
|
优先调整:
|
||||||
|
|
||||||
|
- 增大 `distanceThresholdMeters`
|
||||||
|
|
||||||
|
### 7.2 成组太多
|
||||||
|
|
||||||
|
表现:
|
||||||
|
|
||||||
|
- 距离并不算近的点也被当成一组
|
||||||
|
- 放大后过早进入抽吸关系
|
||||||
|
|
||||||
|
优先调整:
|
||||||
|
|
||||||
|
- 减小 `distanceThresholdMeters`
|
||||||
|
|
||||||
|
### 7.3 展开太晚
|
||||||
|
|
||||||
|
表现:
|
||||||
|
|
||||||
|
- 放大到较深层级前仍然挤在一起
|
||||||
|
|
||||||
|
优先调整:
|
||||||
|
|
||||||
|
- 减小 `expandAtZoom`
|
||||||
|
|
||||||
|
### 7.4 展开太早
|
||||||
|
|
||||||
|
表现:
|
||||||
|
|
||||||
|
- 中等缩放时地图上就出现较明显散开效果
|
||||||
|
|
||||||
|
优先调整:
|
||||||
|
|
||||||
|
- 增大 `expandAtZoom`
|
||||||
|
|
||||||
|
### 7.5 展开后间距不够
|
||||||
|
|
||||||
|
表现:
|
||||||
|
|
||||||
|
- 虽然已经展开,但图标还是比较挤
|
||||||
|
|
||||||
|
优先调整:
|
||||||
|
|
||||||
|
- 增大 `expandOffsetPx`
|
||||||
|
- 或增大 `ringGapPx`
|
||||||
|
- 或增大 `ringGapFactor`
|
||||||
|
|
||||||
|
### 7.6 单圈散开角度不合适
|
||||||
|
|
||||||
|
表现:
|
||||||
|
|
||||||
|
- 次点虽然已经展开,但仍然集中在一侧
|
||||||
|
- 或者扇形过大,展开形态显得太散
|
||||||
|
|
||||||
|
优先调整:
|
||||||
|
|
||||||
|
- 调整 `fanAngleForTwoDeg`
|
||||||
|
- 调整 `fanAngleForFourDeg`
|
||||||
|
- 调整 `fanAngleForManyDeg`
|
||||||
|
|
||||||
|
### 7.7 主点在放大过程中突然消失
|
||||||
|
|
||||||
|
当前已经通过 `replaceAtZoom <= zoom < expandAtZoom` 阶段显示 `priority <= 2` 进行修正。
|
||||||
|
如果后续又出现类似现象,优先检查:
|
||||||
|
|
||||||
|
- `map.ol.ts` 中 `shouldRenderNearbyFeature()` 的阶段逻辑
|
||||||
|
- 是否误改成只显示 `priority = 2`
|
||||||
|
|
||||||
|
### 7.8 明明周围看起来空,文字还是不显示
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
- 图标碰撞盒是否过大
|
||||||
|
- 文字碰撞盒是否过于保守
|
||||||
|
- 候选集是否发生重复统计
|
||||||
|
|
||||||
|
相关代码主要在:
|
||||||
|
|
||||||
|
- `src/components/gis/map.ol.ts`
|
||||||
|
|
||||||
|
## 8. 后续维护建议
|
||||||
|
|
||||||
|
建议后续继续保持以下约定:
|
||||||
|
|
||||||
|
1. 抽吸参数继续统一放在 `nearby-point-rules.ts`
|
||||||
|
2. 显示决策继续集中在 `map.ol.ts`
|
||||||
|
3. 几何偏移继续集中在 `point-layer-manager.ts`
|
||||||
|
4. 后续调参优先改 `DEFAULT_NEARBY_POINT_CONFIG`,不要直接把数值散落回业务代码
|
||||||
|
5. 如果继续加动画,必须放在抽吸状态机完全稳定之后
|
||||||
|
6. 如果继续优化 popup,也要同步遵循“点先于文字”的显示原则
|
||||||
|
|
||||||
|
## 9. 当前结论
|
||||||
|
|
||||||
|
当前这版抽吸实现已经形成了比较稳定的主线:
|
||||||
|
|
||||||
|
- 自动识别近邻点
|
||||||
|
- 统一缩放阶段显示规则
|
||||||
|
- 展开阶段几何偏移
|
||||||
|
- 文字依赖点显示
|
||||||
|
- 文字避让图标与文字
|
||||||
|
|
||||||
|
后续如果继续调优,建议先从参数层开始,不要先动主逻辑结构。
|
||||||
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,10 @@ const appStore = useAppStore();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!--
|
||||||
|
1.地图抽吸
|
||||||
|
2.地图文字和点显示
|
||||||
|
-->
|
||||||
<el-config-provider :locale="appStore.locale" :size="appStore.size">
|
<el-config-provider :locale="appStore.locale" :size="appStore.size">
|
||||||
<a-config-provider :theme="usetTheme" :locale="locale">
|
<a-config-provider :theme="usetTheme" :locale="locale">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
|||||||
12
frontend/src/assets/legend/map-zxsdzGaojing0.svg
Normal file
12
frontend/src/assets/legend/map-zxsdzGaojing0.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="20px" height="15px" viewBox="0 0 20 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>map-zxsdzGaojing0</title>
|
||||||
|
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="10.1-运行告警-运行告警-全国" transform="translate(-575.000000, -635.000000)">
|
||||||
|
<g id="map-zxsdzGaojing0" transform="translate(575.000000, 635.000000)">
|
||||||
|
<polygon id="路径" fill="#78C300" fill-rule="nonzero" points="20 0 20 15 0 15 0 0"></polygon>
|
||||||
|
<rect id="矩形" fill="#FFFFFF" x="1.11111111" y="1.07142857" width="17.7777778" height="6.42857143"></rect>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 777 B |
12
frontend/src/assets/legend/map-zxsdzGaojing1.svg
Normal file
12
frontend/src/assets/legend/map-zxsdzGaojing1.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="20px" height="15px" viewBox="0 0 20 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>map-zxsdzGaojing1</title>
|
||||||
|
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="10.1-运行告警-运行告警-全国" transform="translate(-512.000000, -699.000000)">
|
||||||
|
<g id="map-zxsdzGaojing1" transform="translate(512.000000, 699.000000)">
|
||||||
|
<polygon id="路径" fill="#EECA47" fill-rule="nonzero" points="20 0 20 15 0 15 0 0"></polygon>
|
||||||
|
<rect id="矩形" fill="#FFFFFF" x="1.11111111" y="1.07142857" width="17.7777778" height="6.42857143"></rect>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 777 B |
12
frontend/src/assets/legend/map-zxsdzGaojing2.svg
Normal file
12
frontend/src/assets/legend/map-zxsdzGaojing2.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="20px" height="15px" viewBox="0 0 20 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>map-zxsdzGaojing2</title>
|
||||||
|
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="10.1-运行告警-运行告警-全国" transform="translate(-605.000000, -694.000000)">
|
||||||
|
<g id="map-zxsdzGaojing2" transform="translate(605.000000, 694.000000)">
|
||||||
|
<polygon id="路径" fill="#F7A737" fill-rule="nonzero" points="20 0 20 15 0 15 0 0"></polygon>
|
||||||
|
<rect id="矩形" fill="#FFFFFF" x="1.11111111" y="1.07142857" width="17.7777778" height="6.42857143"></rect>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 777 B |
@ -1071,6 +1071,7 @@ watch(selectedMonth, val => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-pagination {
|
.sidebar-pagination {
|
||||||
|
|||||||
@ -80,6 +80,7 @@ const init = async () => {
|
|||||||
|
|
||||||
// 备注:地图控制器只分发简单视图切换命令,避免在页面入口堆叠复杂业务逻辑。
|
// 备注:地图控制器只分发简单视图切换命令,避免在页面入口堆叠复杂业务逻辑。
|
||||||
const handleMapController = async (e: any, mapType: any) => {
|
const handleMapController = async (e: any, mapType: any) => {
|
||||||
|
console.log(e, mapType);
|
||||||
switch (e) {
|
switch (e) {
|
||||||
case 'dim':
|
case 'dim':
|
||||||
await mapClass.switchView(mapType);
|
await mapClass.switchView(mapType);
|
||||||
|
|||||||
@ -122,6 +122,12 @@ export class MapCesium implements MapInterface {
|
|||||||
checked: boolean
|
checked: boolean
|
||||||
): void {}
|
): void {}
|
||||||
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {}
|
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {}
|
||||||
|
hasLayer(layerKey: string): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
hasBaseLayer(layerKey: string): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
setLegendPointVisible(
|
setLegendPointVisible(
|
||||||
layerKey: string,
|
layerKey: string,
|
||||||
anchoPointState: string,
|
anchoPointState: string,
|
||||||
|
|||||||
@ -134,9 +134,13 @@ export class MapClass implements MapClassInterface {
|
|||||||
this.service.mapOutPut();
|
this.service.mapOutPut();
|
||||||
}
|
}
|
||||||
// 检查图层是否存在
|
// 检查图层是否存在
|
||||||
hasLayer(layerKey: string): void {
|
hasLayer(layerKey: string): boolean {
|
||||||
return this.service.hasLayer(layerKey);
|
return this.service.hasLayer(layerKey);
|
||||||
}
|
}
|
||||||
|
// 检查 GIS/底图图层是否存在
|
||||||
|
hasBaseLayer(layerKey: string): boolean {
|
||||||
|
return this.service.hasBaseLayer(layerKey);
|
||||||
|
}
|
||||||
// 删除描点图层
|
// 删除描点图层
|
||||||
removePointLayer(layerKey: string): void {
|
removePointLayer(layerKey: string): void {
|
||||||
this.service.removePointLayer?.(layerKey);
|
this.service.removePointLayer?.(layerKey);
|
||||||
|
|||||||
8
frontend/src/components/gis/map.d.ts
vendored
8
frontend/src/components/gis/map.d.ts
vendored
@ -108,7 +108,13 @@ export interface MapInterface {
|
|||||||
* @param layerKey 图层 key
|
* @param layerKey 图层 key
|
||||||
* @returns 是否存在
|
* @returns 是否存在
|
||||||
*/
|
*/
|
||||||
hasLayer(layerKey: string): void;
|
hasLayer(layerKey: string): boolean;
|
||||||
|
/**
|
||||||
|
* 检查 GIS/底图图层是否存在
|
||||||
|
* @param layerKey 图层 key
|
||||||
|
* @returns 是否存在
|
||||||
|
*/
|
||||||
|
hasBaseLayer(layerKey: string): boolean;
|
||||||
/**
|
/**
|
||||||
* 地图输出打印
|
* 地图输出打印
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -20,7 +20,8 @@ import { get as getProjection, fromLonLat } from 'ol/proj';
|
|||||||
import {
|
import {
|
||||||
defaults as defaultInteractions,
|
defaults as defaultInteractions,
|
||||||
Draw,
|
Draw,
|
||||||
DoubleClickZoom
|
DoubleClickZoom,
|
||||||
|
DragPan
|
||||||
} from 'ol/interaction';
|
} from 'ol/interaction';
|
||||||
import { getTopLeft, getWidth } from 'ol/extent';
|
import { getTopLeft, getWidth } from 'ol/extent';
|
||||||
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
|
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
|
||||||
@ -28,6 +29,7 @@ import { servers } from './mapurlManage';
|
|||||||
import { XYZ } from 'ol/source';
|
import { XYZ } from 'ol/source';
|
||||||
import Feature from 'ol/Feature';
|
import Feature from 'ol/Feature';
|
||||||
import LineString from 'ol/geom/LineString';
|
import LineString from 'ol/geom/LineString';
|
||||||
|
import Point from 'ol/geom/Point';
|
||||||
import {
|
import {
|
||||||
getLength as getSphericalLength,
|
getLength as getSphericalLength,
|
||||||
getArea as getSphericalArea
|
getArea as getSphericalArea
|
||||||
@ -38,6 +40,7 @@ import { useModelStore } from '@/store/modules/model';
|
|||||||
import { PointLayerManager } from './ol/point-layer-manager';
|
import { PointLayerManager } from './ol/point-layer-manager';
|
||||||
import { PopupManager } from './ol/popup-manager';
|
import { PopupManager } from './ol/popup-manager';
|
||||||
import { RegionMaskManager } from './ol/region-mask-manager';
|
import { RegionMaskManager } from './ol/region-mask-manager';
|
||||||
|
import { getNearbyPointDensityDisplayRules } from '@/modules/map/domain/nearby-point-rules';
|
||||||
|
|
||||||
const modelStore = useModelStore();
|
const modelStore = useModelStore();
|
||||||
const VITE_APP_MAP_URL = import.meta.env.VITE_APP_MAP_URL;
|
const VITE_APP_MAP_URL = import.meta.env.VITE_APP_MAP_URL;
|
||||||
@ -47,6 +50,8 @@ const CENTER_positionCN = [114.17112499999996, 38]; // OpenLayers 使用 [lon, l
|
|||||||
const MIN_ZOOM = 4.23;
|
const MIN_ZOOM = 4.23;
|
||||||
const MAX_ZOOM = 22;
|
const MAX_ZOOM = 22;
|
||||||
const INITIAL_ZOOM = 4.5;
|
const INITIAL_ZOOM = 4.5;
|
||||||
|
const BATCH_POPUP_MODE_ZOOM = 15;
|
||||||
|
const FULL_DISPLAY_NO_COLLISION_ZOOM = 15;
|
||||||
|
|
||||||
// 定义边界 [minX, minY, maxX, maxY] (Web Mercator 坐标)
|
// 定义边界 [minX, minY, maxX, maxY] (Web Mercator 坐标)
|
||||||
const BOUNDS_SW = [26.5, -9.99999999999929];
|
const BOUNDS_SW = [26.5, -9.99999999999929];
|
||||||
@ -56,6 +61,7 @@ export class MapOl implements MapInterface {
|
|||||||
map: OlMap | null = null;
|
map: OlMap | null = null;
|
||||||
view: View | null = null;
|
view: View | null = null;
|
||||||
private layerRegistry: Map<string, any> = new Map();
|
private layerRegistry: Map<string, any> = new Map();
|
||||||
|
private iconLoadState = new Map<string, 'loading' | 'loaded' | 'error'>();
|
||||||
private baseLayerConfig: any | null = null;
|
private baseLayerConfig: any | null = null;
|
||||||
private hydropBaseConfig: any | null = null;
|
private hydropBaseConfig: any | null = null;
|
||||||
private REGISTRY_KEY = 'customBaseLayer';
|
private REGISTRY_KEY = 'customBaseLayer';
|
||||||
@ -73,6 +79,8 @@ export class MapOl implements MapInterface {
|
|||||||
private popupManager: PopupManager;
|
private popupManager: PopupManager;
|
||||||
private regionMaskManager: RegionMaskManager;
|
private regionMaskManager: RegionMaskManager;
|
||||||
private isBatchPopupMode = false;
|
private isBatchPopupMode = false;
|
||||||
|
private batchPopupRefreshFrameId: number | null = null;
|
||||||
|
private labelVisibilityRefreshFrameId: number | null = null;
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pointLayerManager = new PointLayerManager({
|
this.pointLayerManager = new PointLayerManager({
|
||||||
map: null,
|
map: null,
|
||||||
@ -134,10 +142,15 @@ export class MapOl implements MapInterface {
|
|||||||
controls: [], // 对应 Leaflet 的 attributionControl: false, zoomControl: false
|
controls: [], // 对应 Leaflet 的 attributionControl: false, zoomControl: false
|
||||||
interactions: defaultInteractions({
|
interactions: defaultInteractions({
|
||||||
doubleClickZoom: true,
|
doubleClickZoom: true,
|
||||||
dragPan: true,
|
dragPan: false,
|
||||||
|
|
||||||
pinchRotate: false // 通常禁用旋转,除非需要
|
pinchRotate: false // 通常禁用旋转,除非需要
|
||||||
}).extend([mouseWheelInteraction])
|
}).extend([
|
||||||
|
new DragPan({
|
||||||
|
kinetic: undefined
|
||||||
|
}),
|
||||||
|
mouseWheelInteraction
|
||||||
|
])
|
||||||
});
|
});
|
||||||
this.pointLayerManager.setMap(this.map);
|
this.pointLayerManager.setMap(this.map);
|
||||||
this.popupManager.setMap(this.map);
|
this.popupManager.setMap(this.map);
|
||||||
@ -145,13 +158,10 @@ export class MapOl implements MapInterface {
|
|||||||
|
|
||||||
this.view.on('change:resolution', () => {
|
this.view.on('change:resolution', () => {
|
||||||
this.handleZoomChange();
|
this.handleZoomChange();
|
||||||
const zoom = this.view.getZoom();
|
|
||||||
console.log('mouse zoom:', zoom);
|
|
||||||
});
|
});
|
||||||
this.map.on('click', evt => {
|
this.map.on('click', evt => {
|
||||||
this.popupManager.showPopup(undefined, undefined);
|
this.popupManager.showPopup(undefined, undefined);
|
||||||
this.popupManager.handleMapClick(evt.pixel, detectedFeature => {
|
this.popupManager.handleMapClick(evt.pixel, detectedFeature => {
|
||||||
console.log(detectedFeature);
|
|
||||||
if (detectedFeature.values_.sttpMap == 'ylfb') {
|
if (detectedFeature.values_.sttpMap == 'ylfb') {
|
||||||
modelStore.ylfbModalVisible = true;
|
modelStore.ylfbModalVisible = true;
|
||||||
modelStore.params = detectedFeature.values_;
|
modelStore.params = detectedFeature.values_;
|
||||||
@ -174,7 +184,7 @@ export class MapOl implements MapInterface {
|
|||||||
targetElement.style.cursor = payload.hoveredId ? 'pointer' : '';
|
targetElement.style.cursor = payload.hoveredId ? 'pointer' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量 popup 模式下(>= 14 级)不触发 hover popup
|
// 批量 popup 模式下不触发 hover popup
|
||||||
if (!this.isBatchPopupMode) {
|
if (!this.isBatchPopupMode) {
|
||||||
this.showPopup(payload.detectedFeature, payload.coordinate);
|
this.showPopup(payload.detectedFeature, payload.coordinate);
|
||||||
}
|
}
|
||||||
@ -183,12 +193,13 @@ export class MapOl implements MapInterface {
|
|||||||
|
|
||||||
this.map.on('pointerdrag', () => {
|
this.map.on('pointerdrag', () => {
|
||||||
if (this.isBatchPopupMode) {
|
if (this.isBatchPopupMode) {
|
||||||
this.updateBatchPopups();
|
this.requestUpdateBatchPopups();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.map.on('moveend', () => {
|
this.map.on('moveend', () => {
|
||||||
|
this.requestRefreshPointLabelVisibility(true);
|
||||||
if (this.isBatchPopupMode) {
|
if (this.isBatchPopupMode) {
|
||||||
this.updateBatchPopups();
|
this.requestUpdateBatchPopups(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -211,6 +222,9 @@ export class MapOl implements MapInterface {
|
|||||||
_mdoptions?: MDOptions
|
_mdoptions?: MDOptions
|
||||||
): void {
|
): void {
|
||||||
this.pointLayerManager.addDataLayer(pointData, layerType);
|
this.pointLayerManager.addDataLayer(pointData, layerType);
|
||||||
|
const currentZoom = this.view ? this.view.getZoom() : INITIAL_ZOOM;
|
||||||
|
this.pointLayerManager.updateNearbyFeatureLayout(currentZoom);
|
||||||
|
this.requestRefreshPointLabelVisibility(true);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 初始化 Popup Overlay
|
* 初始化 Popup Overlay
|
||||||
@ -242,7 +256,7 @@ export class MapOl implements MapInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监听缩放层级变化,>= 15 时自动显示可视区域内所有锚点的 popup
|
* 监听缩放层级变化,到达指定层级时自动显示可视区域内所有锚点的 popup
|
||||||
*/
|
*/
|
||||||
private handleZoomChange() {
|
private handleZoomChange() {
|
||||||
if (!this.view) return;
|
if (!this.view) return;
|
||||||
@ -250,7 +264,10 @@ export class MapOl implements MapInterface {
|
|||||||
const zoom = this.view.getZoom();
|
const zoom = this.view.getZoom();
|
||||||
if (zoom === undefined) return;
|
if (zoom === undefined) return;
|
||||||
|
|
||||||
if (zoom >= 15) {
|
this.pointLayerManager.updateNearbyFeatureLayout(zoom);
|
||||||
|
this.requestRefreshPointLabelVisibility();
|
||||||
|
|
||||||
|
if (zoom >= BATCH_POPUP_MODE_ZOOM) {
|
||||||
this.enableBatchPopupMode();
|
this.enableBatchPopupMode();
|
||||||
} else {
|
} else {
|
||||||
this.disableBatchPopupMode();
|
this.disableBatchPopupMode();
|
||||||
@ -263,7 +280,7 @@ export class MapOl implements MapInterface {
|
|||||||
private enableBatchPopupMode() {
|
private enableBatchPopupMode() {
|
||||||
this.isBatchPopupMode = true;
|
this.isBatchPopupMode = true;
|
||||||
this.popupManager.clearBatchPopups();
|
this.popupManager.clearBatchPopups();
|
||||||
this.updateBatchPopups();
|
this.requestUpdateBatchPopups(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -271,6 +288,10 @@ export class MapOl implements MapInterface {
|
|||||||
*/
|
*/
|
||||||
private disableBatchPopupMode() {
|
private disableBatchPopupMode() {
|
||||||
this.isBatchPopupMode = false;
|
this.isBatchPopupMode = false;
|
||||||
|
if (this.batchPopupRefreshFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.batchPopupRefreshFrameId);
|
||||||
|
this.batchPopupRefreshFrameId = null;
|
||||||
|
}
|
||||||
this.popupManager.clearBatchPopups();
|
this.popupManager.clearBatchPopups();
|
||||||
this.popupManager.showPopup(undefined, undefined);
|
this.popupManager.showPopup(undefined, undefined);
|
||||||
}
|
}
|
||||||
@ -289,7 +310,7 @@ export class MapOl implements MapInterface {
|
|||||||
/**
|
/**
|
||||||
* 创建点样式 (模拟 Leaflet 的 DivIcon 效果,支持随缩放动态调整大小)
|
* 创建点样式 (模拟 Leaflet 的 DivIcon 效果,支持随缩放动态调整大小)
|
||||||
*/
|
*/
|
||||||
private createPointStyle(feature: Feature): Style {
|
private createPointStyle(feature: Feature): Style[] | null {
|
||||||
const iconUrl = feature.get('_iconUrl') as string;
|
const iconUrl = feature.get('_iconUrl') as string;
|
||||||
const labelText = feature.get('_labelText') as string;
|
const labelText = feature.get('_labelText') as string;
|
||||||
const legendVisible = feature.get('_legendVisible');
|
const legendVisible = feature.get('_legendVisible');
|
||||||
@ -299,47 +320,504 @@ export class MapOl implements MapInterface {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (legendVisible === false) {
|
if (!this.ensureIconReady(iconUrl)) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (regionVisible === false) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentZoom: any = this.view ? this.view.getZoom() : 4.5;
|
const currentZoom: any = this.view ? this.view.getZoom() : 4.5;
|
||||||
if (!this.shouldRenderFeatureByDistance(feature, currentZoom)) {
|
const iconTargetVisible =
|
||||||
return null;
|
legendVisible !== false &&
|
||||||
}
|
regionVisible !== false &&
|
||||||
|
this.shouldRenderFeatureByDensity(feature, currentZoom) &&
|
||||||
|
this.shouldRenderNearbyFeature(feature, currentZoom);
|
||||||
|
|
||||||
const dynamicScale = this.getDynamicPointScale(currentZoom);
|
const dynamicScale = this.getDynamicPointScale(currentZoom);
|
||||||
const fontSize = this.getPointFontSize(dynamicScale);
|
const fontSize = this.getPointFontSize(dynamicScale);
|
||||||
const formattedLabelText = this.formatPointLabelText(labelText);
|
const formattedLabelText = this.formatPointLabelText(labelText);
|
||||||
const labelLineCount = formattedLabelText
|
const labelLineCount = formattedLabelText
|
||||||
? formattedLabelText.split('\n').length
|
? formattedLabelText.split('\n').length
|
||||||
: 1;
|
: 1;
|
||||||
const labelOffsetY =
|
const labelOffsetY = this.getPointLabelRenderOffsetY(
|
||||||
labelLineCount > 1 ? -30 * dynamicScale : -22 * dynamicScale;
|
dynamicScale,
|
||||||
|
labelLineCount
|
||||||
|
);
|
||||||
|
const iconCollisionVisible = feature.get('_iconCollisionVisible') !== false;
|
||||||
|
const labelCollisionVisible =
|
||||||
|
feature.get('_labelCollisionVisible') === true;
|
||||||
|
const finalIconVisible = iconTargetVisible && iconCollisionVisible;
|
||||||
|
const labelTargetVisible =
|
||||||
|
finalIconVisible && !!formattedLabelText && labelCollisionVisible;
|
||||||
|
|
||||||
const styleOptions: any = {
|
if (!finalIconVisible) {
|
||||||
image: new Icon({
|
return null;
|
||||||
src: iconUrl,
|
}
|
||||||
scale: dynamicScale,
|
|
||||||
anchor: [0.5, 0.5],
|
const styles: Style[] = [];
|
||||||
crossOrigin: 'anonymous',
|
styles.push(
|
||||||
declutterMode: 'declutter'
|
new Style({
|
||||||
|
image: new Icon({
|
||||||
|
src: iconUrl,
|
||||||
|
scale: dynamicScale,
|
||||||
|
anchor: [0.5, 0.5],
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
declutterMode: 'none'
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (formattedLabelText && labelTargetVisible) {
|
||||||
|
styles.push(
|
||||||
|
new Style({
|
||||||
|
zIndex: 101,
|
||||||
|
text: new Text({
|
||||||
|
text: formattedLabelText,
|
||||||
|
offsetY: labelOffsetY,
|
||||||
|
font: `${fontSize}px sans-serif`,
|
||||||
|
fill: new Fill({ color: '#fff' }),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
width: 2
|
||||||
|
}),
|
||||||
|
textAlign: 'center',
|
||||||
|
declutterMode: 'none'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshPointLabelVisibility() {
|
||||||
|
if (!this.map || !this.view) return;
|
||||||
|
|
||||||
|
const currentZoom = this.view.getZoom();
|
||||||
|
if (currentZoom === undefined) return;
|
||||||
|
|
||||||
|
const candidateMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
feature: Feature;
|
||||||
|
iconLeft: number;
|
||||||
|
iconRight: number;
|
||||||
|
iconTop: number;
|
||||||
|
iconBottom: number;
|
||||||
|
hasLabel: boolean;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
priority: number;
|
||||||
|
densityPriority: number;
|
||||||
|
id: string;
|
||||||
|
pixelX: number;
|
||||||
|
pixelY: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const candidates: Array<{
|
||||||
|
feature: Feature;
|
||||||
|
iconLeft: number;
|
||||||
|
iconRight: number;
|
||||||
|
iconTop: number;
|
||||||
|
iconBottom: number;
|
||||||
|
hasLabel: boolean;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
priority: number;
|
||||||
|
densityPriority: number;
|
||||||
|
id: string;
|
||||||
|
pixelX: number;
|
||||||
|
pixelY: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const viewportFeatures = this.pointLayerManager.getFeaturesInViewport();
|
||||||
|
|
||||||
|
if (currentZoom >= FULL_DISPLAY_NO_COLLISION_ZOOM) {
|
||||||
|
viewportFeatures.forEach(feature => {
|
||||||
|
const iconUrl = feature.get('_iconUrl') as string;
|
||||||
|
const shouldRenderIcon =
|
||||||
|
!!iconUrl &&
|
||||||
|
this.isIconReady(iconUrl) &&
|
||||||
|
feature.get('_legendVisible') !== false &&
|
||||||
|
feature.get('_regionVisible') !== false &&
|
||||||
|
this.shouldRenderFeatureByDensity(feature, currentZoom) &&
|
||||||
|
this.shouldRenderNearbyFeature(feature, currentZoom);
|
||||||
|
const hasLabel = !!this.formatPointLabelText(
|
||||||
|
feature.get('_labelText') as string
|
||||||
|
);
|
||||||
|
|
||||||
|
this.syncFeatureIconCollisionVisible(feature, shouldRenderIcon);
|
||||||
|
this.syncFeatureLabelCollisionVisible(
|
||||||
|
feature,
|
||||||
|
shouldRenderIcon && hasLabel
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewportFeatures.forEach(feature => {
|
||||||
|
const iconUrl = feature.get('_iconUrl') as string;
|
||||||
|
const shouldRenderIcon =
|
||||||
|
!!iconUrl &&
|
||||||
|
this.isIconReady(iconUrl) &&
|
||||||
|
feature.get('_legendVisible') !== false &&
|
||||||
|
feature.get('_regionVisible') !== false &&
|
||||||
|
this.shouldRenderFeatureByDensity(feature, currentZoom) &&
|
||||||
|
this.shouldRenderNearbyFeature(feature, currentZoom);
|
||||||
|
|
||||||
|
if (!shouldRenderIcon) {
|
||||||
|
this.syncFeatureIconCollisionVisible(feature, false);
|
||||||
|
this.syncFeatureLabelCollisionVisible(feature, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = feature.getGeometry();
|
||||||
|
if (!geometry || geometry.getType() !== 'Point') {
|
||||||
|
this.syncFeatureIconCollisionVisible(feature, false);
|
||||||
|
this.syncFeatureLabelCollisionVisible(feature, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = (geometry as any).getCoordinates?.();
|
||||||
|
if (!coordinates) {
|
||||||
|
this.syncFeatureIconCollisionVisible(feature, false);
|
||||||
|
this.syncFeatureLabelCollisionVisible(feature, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixel = this.map?.getPixelFromCoordinate(coordinates);
|
||||||
|
if (!pixel) {
|
||||||
|
this.syncFeatureIconCollisionVisible(feature, false);
|
||||||
|
this.syncFeatureLabelCollisionVisible(feature, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicScale = this.getDynamicPointScale(currentZoom);
|
||||||
|
// 备注:图标碰撞盒适当小于视觉图标尺寸,允许相邻站点轻微贴近显示,
|
||||||
|
// 避免像“小浪底/三门峡”这类近点被过早裁掉成只剩一个点。
|
||||||
|
const iconCollisionSize = Math.max(12, 16 * dynamicScale);
|
||||||
|
const iconPadding = 0;
|
||||||
|
const iconLeft = pixel[0] - iconCollisionSize / 2 - iconPadding;
|
||||||
|
const iconRight = pixel[0] + iconCollisionSize / 2 + iconPadding;
|
||||||
|
const iconTop = pixel[1] - iconCollisionSize / 2 - iconPadding;
|
||||||
|
const iconBottom = pixel[1] + iconCollisionSize / 2 + iconPadding;
|
||||||
|
const labelText = this.formatPointLabelText(
|
||||||
|
feature.get('_labelText') as string
|
||||||
|
);
|
||||||
|
const fontSize = this.getPointFontSize(dynamicScale);
|
||||||
|
const lines = labelText ? labelText.split('\n') : [''];
|
||||||
|
const maxLineLength = Math.max(...lines.map(line => line.length), 1);
|
||||||
|
const labelLineCount = lines.length;
|
||||||
|
const labelOffsetY = this.getPointLabelCollisionOffsetY(
|
||||||
|
dynamicScale,
|
||||||
|
labelLineCount
|
||||||
|
);
|
||||||
|
const estimatedWidth = maxLineLength * fontSize * 0.6 + 16;
|
||||||
|
const estimatedHeight = labelLineCount * (fontSize + 4) + 8;
|
||||||
|
const centerX = pixel[0];
|
||||||
|
const centerY = pixel[1] + labelOffsetY;
|
||||||
|
const candidateId = String(
|
||||||
|
feature.getId?.() || feature.get('stcd') || ''
|
||||||
|
);
|
||||||
|
const dedupeKey =
|
||||||
|
candidateId ||
|
||||||
|
[
|
||||||
|
feature.get('_layerKey') || '',
|
||||||
|
pixel[0].toFixed(2),
|
||||||
|
pixel[1].toFixed(2),
|
||||||
|
labelText
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
candidateMap.set(dedupeKey, {
|
||||||
|
feature,
|
||||||
|
iconLeft,
|
||||||
|
iconRight,
|
||||||
|
iconTop,
|
||||||
|
iconBottom,
|
||||||
|
hasLabel: !!labelText,
|
||||||
|
left: centerX - estimatedWidth / 2,
|
||||||
|
right: centerX + estimatedWidth / 2,
|
||||||
|
top: centerY - estimatedHeight / 2,
|
||||||
|
bottom: centerY + estimatedHeight / 2,
|
||||||
|
priority: Number(feature.get('_nearbyPriority') || 9999),
|
||||||
|
densityPriority: this.getFeatureDensityPriority(feature),
|
||||||
|
id: candidateId,
|
||||||
|
pixelX: pixel[0],
|
||||||
|
pixelY: pixel[1]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
candidateMap.forEach(candidate => {
|
||||||
|
candidates.push(candidate);
|
||||||
|
});
|
||||||
|
|
||||||
|
candidates.sort((left, right) => {
|
||||||
|
if (left.priority !== right.priority) {
|
||||||
|
return left.priority - right.priority;
|
||||||
|
}
|
||||||
|
if (left.densityPriority !== right.densityPriority) {
|
||||||
|
return left.densityPriority - right.densityPriority;
|
||||||
|
}
|
||||||
|
if (left.pixelY !== right.pixelY) {
|
||||||
|
return left.pixelY - right.pixelY;
|
||||||
|
}
|
||||||
|
return left.id.localeCompare(right.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const placedIconRects: Array<{
|
||||||
|
featureId: string;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
}> = [];
|
||||||
|
const placedLabelRects: Array<{
|
||||||
|
featureId: string;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
}> = [];
|
||||||
|
let hiddenByIconCollisionCount = 0;
|
||||||
|
let hiddenByLabelCollisionCount = 0;
|
||||||
|
let hiddenByIconHiddenCount = 0;
|
||||||
|
|
||||||
|
candidates.forEach(candidate => {
|
||||||
|
const featureId = String(
|
||||||
|
candidate.feature.getId?.() || candidate.feature.get('stcd') || ''
|
||||||
|
);
|
||||||
|
const iconRect = {
|
||||||
|
left: candidate.iconLeft,
|
||||||
|
right: candidate.iconRight,
|
||||||
|
top: candidate.iconTop,
|
||||||
|
bottom: candidate.iconBottom
|
||||||
|
};
|
||||||
|
const iconHasCollision = placedIconRects.some(rect =>
|
||||||
|
this.checkLabelCollision(rect, iconRect)
|
||||||
|
);
|
||||||
|
const iconVisible = !iconHasCollision;
|
||||||
|
this.syncFeatureIconCollisionVisible(candidate.feature, iconVisible);
|
||||||
|
|
||||||
|
if (iconVisible) {
|
||||||
|
placedIconRects.push({
|
||||||
|
featureId,
|
||||||
|
left: iconRect.left,
|
||||||
|
right: iconRect.right,
|
||||||
|
top: iconRect.top,
|
||||||
|
bottom: iconRect.bottom
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelCandidates = [...candidates].sort((left, right) => {
|
||||||
|
if (left.priority !== right.priority) {
|
||||||
|
return left.priority - right.priority;
|
||||||
|
}
|
||||||
|
if (left.pixelY !== right.pixelY) {
|
||||||
|
return left.pixelY - right.pixelY;
|
||||||
|
}
|
||||||
|
if (left.pixelX !== right.pixelX) {
|
||||||
|
return left.pixelX - right.pixelX;
|
||||||
|
}
|
||||||
|
return left.id.localeCompare(right.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
labelCandidates.forEach(candidate => {
|
||||||
|
const featureId = String(
|
||||||
|
candidate.feature.getId?.() || candidate.feature.get('stcd') || ''
|
||||||
|
);
|
||||||
|
const iconVisible =
|
||||||
|
candidate.feature.get('_iconCollisionVisible') !== false;
|
||||||
|
if (!candidate.hasLabel) {
|
||||||
|
this.syncFeatureLabelCollisionVisible(candidate.feature, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!iconVisible) {
|
||||||
|
hiddenByIconHiddenCount += 1;
|
||||||
|
this.syncFeatureLabelCollisionVisible(candidate.feature, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collidingIconRect = placedIconRects.find(rect => {
|
||||||
|
if (rect.featureId === featureId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.checkLabelCollisionWithIcon(rect, candidate);
|
||||||
|
});
|
||||||
|
const hasCollisionWithIcon = !!collidingIconRect;
|
||||||
|
const collidingLabelRect = placedLabelRects.find(rect =>
|
||||||
|
this.checkLabelCollision(rect, candidate)
|
||||||
|
);
|
||||||
|
const hasCollisionWithLabel = !!collidingLabelRect;
|
||||||
|
const visible = !hasCollisionWithIcon && !hasCollisionWithLabel;
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
if (hasCollisionWithIcon) {
|
||||||
|
hiddenByIconCollisionCount += 1;
|
||||||
|
} else {
|
||||||
|
hiddenByLabelCollisionCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncFeatureLabelCollisionVisible(candidate.feature, visible);
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
placedLabelRects.push({
|
||||||
|
featureId,
|
||||||
|
left: candidate.left,
|
||||||
|
right: candidate.right,
|
||||||
|
top: candidate.top,
|
||||||
|
bottom: candidate.bottom
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestRefreshPointLabelVisibility(immediate = false) {
|
||||||
|
if (immediate) {
|
||||||
|
if (this.labelVisibilityRefreshFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
|
||||||
|
this.labelVisibilityRefreshFrameId = null;
|
||||||
|
}
|
||||||
|
this.refreshPointLabelVisibility();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.labelVisibilityRefreshFrameId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.labelVisibilityRefreshFrameId = window.requestAnimationFrame(() => {
|
||||||
|
this.labelVisibilityRefreshFrameId = null;
|
||||||
|
this.refreshPointLabelVisibility();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncFeatureIconCollisionVisible(feature: Feature, visible: boolean) {
|
||||||
|
if (feature.get('_iconCollisionVisible') !== visible) {
|
||||||
|
feature.set('_iconCollisionVisible', visible);
|
||||||
|
feature.changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncFeatureLabelCollisionVisible(feature: Feature, visible: boolean) {
|
||||||
|
if (feature.get('_labelCollisionVisible') !== visible) {
|
||||||
|
feature.set('_labelCollisionVisible', visible);
|
||||||
|
feature.changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isIconReady(iconUrl: string): boolean {
|
||||||
|
return this.iconLoadState.get(iconUrl) === 'loaded';
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureIconReady(iconUrl: string): boolean {
|
||||||
|
if (!iconUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = this.iconLoadState.get(iconUrl);
|
||||||
|
if (status === 'loaded') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (status === 'loading' || status === 'error') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.iconLoadState.set(iconUrl, 'loading');
|
||||||
|
const image = new Image();
|
||||||
|
image.crossOrigin = 'anonymous';
|
||||||
|
image.onload = () => {
|
||||||
|
this.iconLoadState.set(iconUrl, 'loaded');
|
||||||
|
this.refreshPointLayerStyles();
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
this.iconLoadState.set(iconUrl, 'error');
|
||||||
|
this.refreshPointLayerStyles();
|
||||||
|
};
|
||||||
|
image.src = iconUrl;
|
||||||
|
|
||||||
|
if (image.complete && image.naturalWidth > 0) {
|
||||||
|
this.iconLoadState.set(iconUrl, 'loaded');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshPointLayerStyles() {
|
||||||
|
this.pointLayerManager.forEachLayer(layer => {
|
||||||
|
layer.changed();
|
||||||
|
});
|
||||||
|
this.requestRefreshPointLabelVisibility(true);
|
||||||
|
if (this.isBatchPopupMode) {
|
||||||
|
this.requestUpdateBatchPopups(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestUpdateBatchPopups(immediate = false) {
|
||||||
|
if (!this.isBatchPopupMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
if (this.batchPopupRefreshFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.batchPopupRefreshFrameId);
|
||||||
|
this.batchPopupRefreshFrameId = null;
|
||||||
|
}
|
||||||
|
this.updateBatchPopups();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.batchPopupRefreshFrameId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.batchPopupRefreshFrameId = window.requestAnimationFrame(() => {
|
||||||
|
this.batchPopupRefreshFrameId = null;
|
||||||
|
if (!this.isBatchPopupMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.updateBatchPopups();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkLabelCollision(
|
||||||
|
left: { left: number; right: number; top: number; bottom: number },
|
||||||
|
right: { left: number; right: number; top: number; bottom: number }
|
||||||
|
) {
|
||||||
|
const padding = 4;
|
||||||
|
return !(
|
||||||
|
left.right + padding < right.left ||
|
||||||
|
left.left - padding > right.right ||
|
||||||
|
left.bottom + padding < right.top ||
|
||||||
|
left.top - padding > right.bottom
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkLabelCollisionWithIcon(
|
||||||
|
iconRect: { left: number; right: number; top: number; bottom: number },
|
||||||
|
labelRect: { left: number; right: number; top: number; bottom: number }
|
||||||
|
) {
|
||||||
|
// 标签避让图标时仅保留图标中心更小的保护区,并允许边缘接触不算碰撞,
|
||||||
|
// 避免像“三峡/黄龙滩”这类仅在边界轻微擦到时把文字过度压掉。
|
||||||
|
const inset = 4;
|
||||||
|
const padding = 0;
|
||||||
|
const narrowedIconRect = {
|
||||||
|
left: iconRect.left + inset,
|
||||||
|
right: iconRect.right - inset,
|
||||||
|
top: iconRect.top + inset,
|
||||||
|
bottom: iconRect.bottom - inset
|
||||||
};
|
};
|
||||||
|
|
||||||
styleOptions.text = new Text({
|
return !(
|
||||||
text: formattedLabelText,
|
narrowedIconRect.right + padding <= labelRect.left ||
|
||||||
offsetY: labelOffsetY,
|
narrowedIconRect.left - padding >= labelRect.right ||
|
||||||
font: `${fontSize}px sans-serif`,
|
narrowedIconRect.bottom + padding <= labelRect.top ||
|
||||||
fill: new Fill({ color: '#fff' }),
|
narrowedIconRect.top - padding >= labelRect.bottom
|
||||||
stroke: new Stroke({ color: 'rgba(0, 0, 0, .9)', width: 2 }),
|
);
|
||||||
textAlign: 'center',
|
|
||||||
declutterMode: 'declutter'
|
|
||||||
});
|
|
||||||
return new Style(styleOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -387,27 +865,194 @@ export class MapOl implements MapInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一根据缩放级别和点位距离判断要素是否需要参与渲染,减少样式函数中的业务判断复杂度。
|
* 统一计算标签渲染时的纵向偏移,保持视觉位置与原效果一致。
|
||||||
*/
|
*/
|
||||||
private shouldRenderFeatureByDistance(
|
private getPointLabelRenderOffsetY(
|
||||||
|
dynamicScale: number,
|
||||||
|
labelLineCount: number
|
||||||
|
): number {
|
||||||
|
return labelLineCount > 1 ? -30 * dynamicScale : -22 * dynamicScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一计算标签碰撞时的纵向偏移。
|
||||||
|
* 这里允许比视觉位置略高一点,避免相邻点图标过早压掉主标签。
|
||||||
|
*/
|
||||||
|
private getPointLabelCollisionOffsetY(
|
||||||
|
dynamicScale: number,
|
||||||
|
labelLineCount: number
|
||||||
|
): number {
|
||||||
|
return labelLineCount > 1 ? -36 * dynamicScale : -28 * dynamicScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* distance 字段表示点位密度分档,不是物理距离。
|
||||||
|
* 这里仅用它决定点位从哪个缩放级别开始参与显示候选。
|
||||||
|
*/
|
||||||
|
private getFeatureDensityValue(feature: Feature): number | null {
|
||||||
|
const rawDistance = feature.get('distance');
|
||||||
|
if (
|
||||||
|
rawDistance === undefined ||
|
||||||
|
rawDistance === null ||
|
||||||
|
rawDistance === ''
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const densityValue = Number(rawDistance);
|
||||||
|
return Number.isFinite(densityValue) ? densityValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFeatureDensityPriority(feature: Feature): number {
|
||||||
|
const densityValue = this.getFeatureDensityValue(feature);
|
||||||
|
const densityDisplayRules = getNearbyPointDensityDisplayRules();
|
||||||
|
if (densityValue === null) {
|
||||||
|
return densityDisplayRules.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedRuleIndex = densityDisplayRules.findIndex(rule => {
|
||||||
|
return densityValue >= rule.minDensityValue;
|
||||||
|
});
|
||||||
|
return matchedRuleIndex >= 0
|
||||||
|
? matchedRuleIndex
|
||||||
|
: densityDisplayRules.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFeatureDensityMinZoom(feature: Feature): number {
|
||||||
|
const densityValue = this.getFeatureDensityValue(feature);
|
||||||
|
if (densityValue === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const densityDisplayRules = getNearbyPointDensityDisplayRules();
|
||||||
|
const matchedRule = densityDisplayRules.find(rule => {
|
||||||
|
return densityValue >= rule.minDensityValue;
|
||||||
|
});
|
||||||
|
if (matchedRule) {
|
||||||
|
return matchedRule.minZoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
return densityDisplayRules.at(-1)?.minZoom ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 如果点位在当前视图中没有近邻点,则允许跳过静态密度门槛直接参与显示。
|
||||||
|
* 这样像“戈兰滩”这类 distance 档位偏密、但当前周围实际很空的点也能出现。
|
||||||
|
*/
|
||||||
|
private shouldBypassDensityGateForIsolatedFeature(feature: Feature): boolean {
|
||||||
|
if (!this.map) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerKey = String(feature.get('_layerKey') || '');
|
||||||
|
const geometry = feature.getGeometry();
|
||||||
|
if (!geometry || geometry.getType() !== 'Point') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featurePixel = this.map.getPixelFromCoordinate(
|
||||||
|
(geometry as Point).getCoordinates()
|
||||||
|
);
|
||||||
|
if (!featurePixel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nearbyPixelThreshold = 56;
|
||||||
|
const viewportFeatures = this.pointLayerManager.getFeaturesInViewport();
|
||||||
|
|
||||||
|
return !viewportFeatures.some(otherFeature => {
|
||||||
|
if (otherFeature === feature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (otherFeature.get('_legendVisible') === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (otherFeature.get('_regionVisible') === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(otherFeature.get('_layerKey') || '') !== layerKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherGeometry = otherFeature.getGeometry();
|
||||||
|
if (!otherGeometry || otherGeometry.getType() !== 'Point') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherPixel = this.map?.getPixelFromCoordinate(
|
||||||
|
(otherGeometry as Point).getCoordinates()
|
||||||
|
);
|
||||||
|
if (!otherPixel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.hypot(
|
||||||
|
otherPixel[0] - featurePixel[0],
|
||||||
|
otherPixel[1] - featurePixel[1]
|
||||||
|
) <= nearbyPixelThreshold
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldRenderFeatureByDensity(
|
||||||
feature: Feature,
|
feature: Feature,
|
||||||
currentZoom: number
|
currentZoom: number
|
||||||
): boolean {
|
): boolean {
|
||||||
const distance = feature.get('distance');
|
const minZoom = this.getFeatureDensityMinZoom(feature);
|
||||||
if (distance === undefined || distance === null) {
|
const bypassDensityGate =
|
||||||
|
currentZoom < minZoom &&
|
||||||
|
this.shouldBypassDensityGateForIsolatedFeature(feature);
|
||||||
|
return currentZoom >= minZoom || bypassDensityGate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一根据近邻点分组和缩放阶段判断当前要素是否应该参与渲染。
|
||||||
|
*/
|
||||||
|
private shouldRenderNearbyFeature(
|
||||||
|
feature: Feature,
|
||||||
|
currentZoom: number
|
||||||
|
): boolean {
|
||||||
|
const nearbyGroupId = feature.get('_nearbyGroupId');
|
||||||
|
const groupSize = Number(feature.get('_nearbyGroupSize'));
|
||||||
|
const priority = Number(feature.get('_nearbyPriority'));
|
||||||
|
const replaceAtZoom = Number(feature.get('_nearbyReplaceAtZoom'));
|
||||||
|
const expandAtZoom = Number(feature.get('_nearbyExpandAtZoom'));
|
||||||
|
const showZoomMin = feature.get('_nearbyShowZoomMin');
|
||||||
|
const showZoomMax = feature.get('_nearbyShowZoomMax');
|
||||||
|
|
||||||
|
if (!nearbyGroupId || !Number.isFinite(groupSize) || groupSize < 2) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let minDistanceRequired = 0;
|
if (!Number.isFinite(priority) || priority <= 0) {
|
||||||
if (currentZoom <= 6) {
|
return true;
|
||||||
minDistanceRequired = 1500000;
|
|
||||||
} else if (currentZoom <= 8) {
|
|
||||||
minDistanceRequired = 800000;
|
|
||||||
} else if (currentZoom <= 10.5) {
|
|
||||||
minDistanceRequired = 400000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return distance > minDistanceRequired;
|
if (
|
||||||
|
showZoomMin !== null &&
|
||||||
|
showZoomMin !== undefined &&
|
||||||
|
currentZoom < Number(showZoomMin)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
showZoomMax !== null &&
|
||||||
|
showZoomMax !== undefined &&
|
||||||
|
currentZoom > Number(showZoomMax)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(expandAtZoom) && currentZoom >= expandAtZoom) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(replaceAtZoom) && currentZoom >= replaceAtZoom) {
|
||||||
|
return priority <= Math.min(2, groupSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return priority === 1;
|
||||||
}
|
}
|
||||||
private createPointStyle1(feature: Feature): Style {
|
private createPointStyle1(feature: Feature): Style {
|
||||||
const color = (feature.get('iconCode') as string) || '#3399CC';
|
const color = (feature.get('iconCode') as string) || '#3399CC';
|
||||||
@ -728,7 +1373,7 @@ export class MapOl implements MapInterface {
|
|||||||
layerInstance.setVisible(checked);
|
layerInstance.setVisible(checked);
|
||||||
// 图层显隐变化时刷新 batch popup
|
// 图层显隐变化时刷新 batch popup
|
||||||
if (this.isBatchPopupMode) {
|
if (this.isBatchPopupMode) {
|
||||||
this.updateBatchPopups();
|
this.requestUpdateBatchPopups(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
console.warn(
|
||||||
@ -739,16 +1384,21 @@ export class MapOl implements MapInterface {
|
|||||||
}
|
}
|
||||||
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {
|
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {
|
||||||
this.pointLayerManager.setLayerVisible(layerType, checked);
|
this.pointLayerManager.setLayerVisible(layerType, checked);
|
||||||
|
this.requestRefreshPointLabelVisibility(true);
|
||||||
// 图层显隐变化时刷新 batch popup
|
// 图层显隐变化时刷新 batch popup
|
||||||
if (this.isBatchPopupMode) {
|
if (this.isBatchPopupMode) {
|
||||||
this.updateBatchPopups();
|
this.requestUpdateBatchPopups(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasLayer(layerType: string): void {
|
hasLayer(layerType: string): boolean {
|
||||||
return this.pointLayerManager.hasLayer(layerType as string);
|
return this.pointLayerManager.hasLayer(layerType as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasBaseLayer(layerKey: string): boolean {
|
||||||
|
return this.layerRegistry.has(layerKey);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 缩放地图
|
* 缩放地图
|
||||||
* @param type 'out' 缩小, 'in' 放大
|
* @param type 'out' 缩小, 'in' 放大
|
||||||
@ -1383,7 +2033,7 @@ export class MapOl implements MapInterface {
|
|||||||
);
|
);
|
||||||
// 图例变化时刷新 batch popup
|
// 图例变化时刷新 batch popup
|
||||||
if (this.isBatchPopupMode) {
|
if (this.isBatchPopupMode) {
|
||||||
this.updateBatchPopups();
|
this.requestUpdateBatchPopups(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1403,9 +2053,10 @@ export class MapOl implements MapInterface {
|
|||||||
anchoPointState,
|
anchoPointState,
|
||||||
checked
|
checked
|
||||||
);
|
);
|
||||||
|
this.requestRefreshPointLabelVisibility(true);
|
||||||
// 图例变化时刷新 batch popup
|
// 图例变化时刷新 batch popup
|
||||||
if (this.isBatchPopupMode) {
|
if (this.isBatchPopupMode) {
|
||||||
this.updateBatchPopups();
|
this.requestUpdateBatchPopups(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1487,6 +2138,10 @@ export class MapOl implements MapInterface {
|
|||||||
* 移除地图对象,释放资源
|
* 移除地图对象,释放资源
|
||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
if (this.labelVisibilityRefreshFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
|
||||||
|
this.labelVisibilityRefreshFrameId = null;
|
||||||
|
}
|
||||||
this.removeQueryLayer();
|
this.removeQueryLayer();
|
||||||
this.regionMaskManager.destroy();
|
this.regionMaskManager.destroy();
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import VectorLayer from 'ol/layer/Vector';
|
|||||||
import VectorSource from 'ol/source/Vector';
|
import VectorSource from 'ol/source/Vector';
|
||||||
import { fromLonLat } from 'ol/proj';
|
import { fromLonLat } from 'ol/proj';
|
||||||
import { getIconPath } from '@/utils/index';
|
import { getIconPath } from '@/utils/index';
|
||||||
|
import { getNearbyPointConfig } from '@/modules/map/domain/nearby-point-rules';
|
||||||
|
|
||||||
type PointLayerStyleFactory = (feature: Feature) => any;
|
type PointLayerStyleFactory = (feature: Feature) => any;
|
||||||
|
|
||||||
@ -75,6 +76,99 @@ export class PointLayerManager {
|
|||||||
return Array.from(this.layerRegistry.values());
|
return Array.from(this.layerRegistry.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 备注:按当前缩放级别统一刷新近邻点展开坐标,保证抽吸后的点击和 popup 与视觉位置一致。
|
||||||
|
updateNearbyFeatureLayout(currentZoom?: number): void {
|
||||||
|
if (!this.map) return;
|
||||||
|
|
||||||
|
const resolution = this.map.getView().getResolution();
|
||||||
|
if (currentZoom === undefined || !Number.isFinite(currentZoom) || !resolution) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nearbyPointConfig = getNearbyPointConfig();
|
||||||
|
const { autoRule, layoutRule } = nearbyPointConfig;
|
||||||
|
|
||||||
|
const groupMap = new Map<string, Feature[]>();
|
||||||
|
|
||||||
|
this.forEachFeature(feature => {
|
||||||
|
this.resetFeatureToBaseCoordinate(feature);
|
||||||
|
|
||||||
|
const groupId = feature.get('_nearbyGroupId');
|
||||||
|
const groupSize = Number(feature.get('_nearbyGroupSize'));
|
||||||
|
if (!groupId || !Number.isFinite(groupSize) || groupSize < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupFeatures = groupMap.get(groupId) || [];
|
||||||
|
groupFeatures.push(feature);
|
||||||
|
groupMap.set(groupId, groupFeatures);
|
||||||
|
});
|
||||||
|
|
||||||
|
groupMap.forEach(features => {
|
||||||
|
const expandAtZoom = Number(features[0]?.get('_nearbyExpandAtZoom'));
|
||||||
|
if (!Number.isFinite(expandAtZoom) || currentZoom < expandAtZoom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sortedFeatures = [...features].sort((left, right) => {
|
||||||
|
return Number(left.get('_nearbyPriority') || 0) - Number(right.get('_nearbyPriority') || 0);
|
||||||
|
});
|
||||||
|
const secondaryFeatures = sortedFeatures.filter(feature => {
|
||||||
|
const priority = Number(feature.get('_nearbyPriority'));
|
||||||
|
return Number.isFinite(priority) && priority > 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
secondaryFeatures.forEach((feature, index) => {
|
||||||
|
const baseCoordinates = feature.get('_baseCoordinates') as
|
||||||
|
| number[]
|
||||||
|
| undefined;
|
||||||
|
const geometry = feature.getGeometry();
|
||||||
|
if (
|
||||||
|
!baseCoordinates ||
|
||||||
|
baseCoordinates.length < 2 ||
|
||||||
|
!geometry ||
|
||||||
|
geometry.getType() !== 'Point'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupSecondaryCount = secondaryFeatures.length;
|
||||||
|
const baseOffsetPx =
|
||||||
|
Number(feature.get('_nearbyExpandOffsetPx')) || autoRule.expandOffsetPx;
|
||||||
|
const ringSize = Math.max(1, layoutRule.ringSize);
|
||||||
|
const ringIndex = Math.floor(index / ringSize);
|
||||||
|
const ringOffsetIndex = index % ringSize;
|
||||||
|
const currentRingCount = Math.min(
|
||||||
|
ringSize,
|
||||||
|
groupSecondaryCount - ringIndex * ringSize
|
||||||
|
);
|
||||||
|
const fanAngleDeg =
|
||||||
|
currentRingCount <= 1
|
||||||
|
? 0
|
||||||
|
: currentRingCount <= 2
|
||||||
|
? layoutRule.fanAngleForTwoDeg
|
||||||
|
: currentRingCount <= 4
|
||||||
|
? layoutRule.fanAngleForFourDeg
|
||||||
|
: layoutRule.fanAngleForManyDeg;
|
||||||
|
const angleStepDeg =
|
||||||
|
currentRingCount <= 1 ? 0 : fanAngleDeg / (currentRingCount - 1);
|
||||||
|
const startAngleDeg = -90 - fanAngleDeg / 2;
|
||||||
|
const expandAngleDeg = startAngleDeg + ringOffsetIndex * angleStepDeg;
|
||||||
|
const expandAngleRad = (expandAngleDeg * Math.PI) / 180;
|
||||||
|
const expandOffsetPx =
|
||||||
|
baseOffsetPx +
|
||||||
|
ringIndex *
|
||||||
|
Math.max(layoutRule.ringGapPx, baseOffsetPx * layoutRule.ringGapFactor);
|
||||||
|
const offsetX = Math.cos(expandAngleRad) * expandOffsetPx * resolution;
|
||||||
|
const offsetY = Math.sin(expandAngleRad) * expandOffsetPx * resolution;
|
||||||
|
|
||||||
|
feature.set('_nearbyExpandAngleDeg', expandAngleDeg, true);
|
||||||
|
(geometry as Point).setCoordinates([
|
||||||
|
baseCoordinates[0] + offsetX,
|
||||||
|
baseCoordinates[1] - offsetY
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 备注:判断指定图层 key 的点图层是否已存在。
|
// 备注:判断指定图层 key 的点图层是否已存在。
|
||||||
hasLayer(layerKey: string): boolean {
|
hasLayer(layerKey: string): boolean {
|
||||||
return this.layerRegistry.has(layerKey);
|
return this.layerRegistry.has(layerKey);
|
||||||
@ -304,6 +398,7 @@ export class PointLayerManager {
|
|||||||
'_labelText',
|
'_labelText',
|
||||||
item.sttpMap === 'ylfb' ? item.ftp || '' : titleName || stnm || ennm || ''
|
item.sttpMap === 'ylfb' ? item.ftp || '' : titleName || stnm || ennm || ''
|
||||||
);
|
);
|
||||||
|
feature.set('_baseCoordinates', coord);
|
||||||
feature.set('_legendVisible', true);
|
feature.set('_legendVisible', true);
|
||||||
feature.set('_sttpMap', item.sttpMap);
|
feature.set('_sttpMap', item.sttpMap);
|
||||||
if (item.popupHtml) {
|
if (item.popupHtml) {
|
||||||
@ -353,4 +448,20 @@ export class PointLayerManager {
|
|||||||
|
|
||||||
return valueMap.get(String(matchValue)) || [];
|
return valueMap.get(String(matchValue)) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 备注:先恢复要素原始坐标,再按缩放阶段决定是否做展开偏移,避免多次缩放后偏移累计。
|
||||||
|
private resetFeatureToBaseCoordinate(feature: Feature): void {
|
||||||
|
const baseCoordinates = feature.get('_baseCoordinates') as number[] | undefined;
|
||||||
|
const geometry = feature.getGeometry();
|
||||||
|
if (
|
||||||
|
!baseCoordinates ||
|
||||||
|
baseCoordinates.length < 2 ||
|
||||||
|
!geometry ||
|
||||||
|
geometry.getType() !== 'Point'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(geometry as Point).setCoordinates([baseCoordinates[0], baseCoordinates[1]]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -251,14 +251,6 @@ export class PopupManager {
|
|||||||
const renderedIds: Array<string | number | null> = [];
|
const renderedIds: Array<string | number | null> = [];
|
||||||
const blockedIds: Array<string | number | null> = [];
|
const blockedIds: Array<string | number | null> = [];
|
||||||
|
|
||||||
// 用于 declutter 模拟检测的已放置要素边界框列表
|
|
||||||
const placedAnchors: Array<{
|
|
||||||
bbLeft: number;
|
|
||||||
bbRight: number;
|
|
||||||
bbTop: number;
|
|
||||||
bbBottom: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
visibleFeatures.forEach(feature => {
|
visibleFeatures.forEach(feature => {
|
||||||
const geom = feature.getGeometry();
|
const geom = feature.getGeometry();
|
||||||
if (!geom || geom.getType() !== 'Point') return;
|
if (!geom || geom.getType() !== 'Point') return;
|
||||||
@ -273,59 +265,10 @@ export class PopupManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟 OpenLayers declutter:基于图标+文字的边界框碰撞检测
|
|
||||||
const zoom = this.map.getView().getZoom() ?? 14;
|
|
||||||
let dynamicScale = 0.7 + (zoom - 4.5) * 0.08;
|
|
||||||
dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale));
|
|
||||||
|
|
||||||
// 计算要素的碰撞边界框
|
|
||||||
const labelText = (feature.get('_labelText') as string) || '';
|
|
||||||
const labelLineCount = labelText.split('\n').length;
|
|
||||||
const labelWidth = Math.min(
|
|
||||||
labelText
|
|
||||||
.split('\n')
|
|
||||||
.reduce((max, line) => Math.max(max, line.length), 0) *
|
|
||||||
7 *
|
|
||||||
dynamicScale,
|
|
||||||
200
|
|
||||||
);
|
|
||||||
const labelHeight = 12 * dynamicScale * labelLineCount;
|
|
||||||
const labelOffsetY =
|
|
||||||
labelLineCount > 1 ? -30 * dynamicScale : -22 * dynamicScale;
|
|
||||||
const iconSize = 32 * dynamicScale; // 假设图标原始大小 32x32
|
|
||||||
|
|
||||||
// 碰撞框 = 图标框 ∪ 文字框
|
|
||||||
const bbLeft = pixel[0] - Math.max(iconSize / 2, labelWidth / 2);
|
|
||||||
const bbRight = pixel[0] + Math.max(iconSize / 2, labelWidth / 2);
|
|
||||||
const bbTop = pixel[1] + labelOffsetY - labelHeight;
|
|
||||||
const bbBottom = pixel[1] + iconSize / 2;
|
|
||||||
|
|
||||||
// 检查是否和已放置的要素碰撞
|
|
||||||
let blocked = false;
|
|
||||||
for (const placed of placedAnchors) {
|
|
||||||
if (
|
|
||||||
bbRight > placed.bbLeft &&
|
|
||||||
bbLeft < placed.bbRight &&
|
|
||||||
bbBottom > placed.bbTop &&
|
|
||||||
bbTop < placed.bbBottom
|
|
||||||
) {
|
|
||||||
blocked = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (blocked) {
|
|
||||||
blockedCount += 1;
|
|
||||||
blockedIds.push((feature.getId?.() as string | number | null) ?? null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = feature.getProperties();
|
const props = feature.getProperties();
|
||||||
const popupHtml = props.popupHtml || generatePopupHtml(props);
|
const popupHtml = props.popupHtml || generatePopupHtml(props);
|
||||||
if (!popupHtml) return;
|
if (!popupHtml) return;
|
||||||
|
|
||||||
// 记录边界框用于 declutter 检测
|
|
||||||
placedAnchors.push({ bbLeft, bbRight, bbTop, bbBottom });
|
|
||||||
|
|
||||||
// 创建与原始 popupElement 完全相同的样式
|
// 创建与原始 popupElement 完全相同的样式
|
||||||
const popupEl = document.createElement('div');
|
const popupEl = document.createElement('div');
|
||||||
popupEl.className = this.popupElement.className;
|
popupEl.className = this.popupElement.className;
|
||||||
|
|||||||
@ -110,6 +110,7 @@ const route = useRoute();
|
|||||||
const mapOrchestrator = useMapOrchestrator();
|
const mapOrchestrator = useMapOrchestrator();
|
||||||
const mapDataStore = useMapDataStore();
|
const mapDataStore = useMapDataStore();
|
||||||
const mapViewStore = useMapViewStore();
|
const mapViewStore = useMapViewStore();
|
||||||
|
const ENG_POINT_LAYER_KEY = 'eng_point';
|
||||||
const siteRangePicker = [
|
const siteRangePicker = [
|
||||||
{ label: '全部', value: 'all' },
|
{ label: '全部', value: 'all' },
|
||||||
{ label: '大型电站', value: 'large_eng_built' },
|
{ label: '大型电站', value: 'large_eng_built' },
|
||||||
@ -147,6 +148,7 @@ watch(
|
|||||||
() => route.path,
|
() => route.path,
|
||||||
() => {
|
() => {
|
||||||
mapOrchestrator.resetFilterState();
|
mapOrchestrator.resetFilterState();
|
||||||
|
mapOrchestrator.changeEngPointCapacity('all');
|
||||||
searchTimeRange.value = [
|
searchTimeRange.value = [
|
||||||
dayjs(mapViewStore.searchTimeRange[0]).format('YYYY-MM-DD HH:mm:ss'),
|
dayjs(mapViewStore.searchTimeRange[0]).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
dayjs(mapViewStore.searchTimeRange[1]).format('YYYY-MM-DD HH:mm:ss')
|
dayjs(mapViewStore.searchTimeRange[1]).format('YYYY-MM-DD HH:mm:ss')
|
||||||
@ -187,13 +189,19 @@ const anchorPointOptions = computed(() => {
|
|||||||
capacityValue !== 'all'
|
capacityValue !== 'all'
|
||||||
) {
|
) {
|
||||||
if (capacityValue === 'large_eng_built') {
|
if (capacityValue === 'large_eng_built') {
|
||||||
filteredData = filteredData.filter((item: any) =>
|
filteredData = filteredData.filter((item: any) => {
|
||||||
largeScale.includes(item.anchoPointState)
|
if (item.layerKey !== ENG_POINT_LAYER_KEY) {
|
||||||
);
|
return true;
|
||||||
|
}
|
||||||
|
return largeScale.includes(item.anchoPointState);
|
||||||
|
});
|
||||||
} else if (capacityValue === 'mid_eng_built') {
|
} else if (capacityValue === 'mid_eng_built') {
|
||||||
filteredData = filteredData.filter((item: any) =>
|
filteredData = filteredData.filter((item: any) => {
|
||||||
mediumScale.includes(item.anchoPointState)
|
if (item.layerKey !== ENG_POINT_LAYER_KEY) {
|
||||||
);
|
return true;
|
||||||
|
}
|
||||||
|
return mediumScale.includes(item.anchoPointState);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,13 +236,7 @@ watch(
|
|||||||
|
|
||||||
const handleCapacityChange = (value: string) => {
|
const handleCapacityChange = (value: string) => {
|
||||||
formModel.value.anchorPointSelect = null;
|
formModel.value.anchorPointSelect = null;
|
||||||
|
mapOrchestrator.changeEngPointCapacity(value);
|
||||||
const isAll = !value || value === '' || value === 'all';
|
|
||||||
const showLarge = isAll || value === 'large_eng_built';
|
|
||||||
const showMedium = isAll || value === 'mid_eng_built';
|
|
||||||
|
|
||||||
mapOrchestrator.toggleLegendBatch(largeScale, showLarge ? 1 : 0);
|
|
||||||
mapOrchestrator.toggleLegendBatch(mediumScale, showMedium ? 1 : 0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 备注:搜索定位只分发定位命令,不在筛选组件里直接操作地图实例。
|
// 备注:搜索定位只分发定位命令,不在筛选组件里直接操作地图实例。
|
||||||
|
|||||||
@ -68,19 +68,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useMapOrchestrator } from '@/modules/map/application/map-orchestrator';
|
import { useMapOrchestrator } from '@/modules/map/application/map-orchestrator';
|
||||||
import { useMapDataStore } from '@/modules/map/stores/map-data.store';
|
import { useMapConfigStore } from '@/modules/map/stores/map-config.store';
|
||||||
import { useMapStore } from '@/store/modules/map';
|
import { useMapStore } from '@/store/modules/map';
|
||||||
import LegendItem from '@/components/mapLegend/LegendItem.vue';
|
import LegendItem from '@/components/mapLegend/LegendItem.vue';
|
||||||
|
|
||||||
const mapStore = useMapStore();
|
const mapStore = useMapStore();
|
||||||
const mapDataStore = useMapDataStore();
|
const mapConfigStore = useMapConfigStore();
|
||||||
const mapOrchestrator = useMapOrchestrator();
|
const mapOrchestrator = useMapOrchestrator();
|
||||||
const isOpen = ref(true);
|
const isOpen = ref(true);
|
||||||
|
|
||||||
// 备注:图例组件直接渲染当前已派生好的图例运行态,不再维护本地同步副本。
|
// 备注:图例组件直接渲染当前已派生好的图例运行态,不再维护本地同步副本。
|
||||||
const legendData = computed(() => mapStore.legendDataSelected || []);
|
const legendData = computed(() => mapStore.legendDataSelected || []);
|
||||||
// 备注:loading 状态直接取数据缓存态,组件本身不再 watch 后再二次赋值。
|
// 备注:图例转圈只跟图例配置接口绑定,不再等待全部锚点接口加载完成。
|
||||||
const loadLegendData = computed(() => mapDataStore.loading);
|
const loadLegendData = computed(() => mapConfigStore.legendLoading);
|
||||||
// 备注:环保设施分组展示状态直接从当前图例数据派生,保持组件只负责渲染。
|
// 备注:环保设施分组展示状态直接从当前图例数据派生,保持组件只负责渲染。
|
||||||
const hasEnvFac = computed(() =>
|
const hasEnvFac = computed(() =>
|
||||||
legendData.value.some((item: any) => item.name === '环保设施')
|
legendData.value.some((item: any) => item.name === '环保设施')
|
||||||
|
|||||||
@ -41,6 +41,10 @@ const HYDRO_DYNAMIC_LAYER_KEYS = [
|
|||||||
'va_point',
|
'va_point',
|
||||||
'sg_point'
|
'sg_point'
|
||||||
];
|
];
|
||||||
|
const ENG_POINT_LAYER_KEY = 'eng_point';
|
||||||
|
const ENG_POINT_MEDIUM_VISIBLE_ZOOM = 7.5;
|
||||||
|
const LARGE_ENG_LEGEND_PREFIX = 'large_eng_';
|
||||||
|
const MID_ENG_LEGEND_PREFIX = 'mid_eng_';
|
||||||
|
|
||||||
export const useMapOrchestrator = () => {
|
export const useMapOrchestrator = () => {
|
||||||
const mapClass = MapClass.getInstance();
|
const mapClass = MapClass.getInstance();
|
||||||
@ -109,18 +113,23 @@ export const useMapOrchestrator = () => {
|
|||||||
try {
|
try {
|
||||||
const previousPageKey = mapConfigStore.lastLoadOptions?.pageKey || '';
|
const previousPageKey = mapConfigStore.lastLoadOptions?.pageKey || '';
|
||||||
const previousCheckedKeys = mapViewStore.getCheckedLayerKeys();
|
const previousCheckedKeys = mapViewStore.getCheckedLayerKeys();
|
||||||
const { layerConfig, legendOriginal, pageLegend } =
|
const loadOptions = {
|
||||||
await mapConfigStore.loadPageMapConfig({
|
systemId: SYSTEM_ID,
|
||||||
systemId: SYSTEM_ID,
|
moduleId: MODULE_ID,
|
||||||
moduleId: MODULE_ID,
|
pageKey,
|
||||||
pageKey,
|
description: 'true'
|
||||||
description: 'true'
|
};
|
||||||
});
|
const layerConfigPromise =
|
||||||
|
mapConfigStore.loadPageLayerConfig(loadOptions);
|
||||||
|
const legendConfigPromise = mapConfigStore.loadPageLegendConfig(pageKey);
|
||||||
|
const { legendOriginal, pageLegend } = await legendConfigPromise;
|
||||||
|
|
||||||
if (legendOriginal.length > 0) {
|
if (legendOriginal.length > 0) {
|
||||||
mapStore.setLegendData(legendOriginal, pageLegend);
|
mapStore.setLegendData(legendOriginal, pageLegend);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { layerConfig } = await layerConfigPromise;
|
||||||
|
|
||||||
if (layerConfig.length > 0) {
|
if (layerConfig.length > 0) {
|
||||||
mapStore.setLayerData(layerConfig);
|
mapStore.setLayerData(layerConfig);
|
||||||
ensureBaseLayersInitialized(layerConfig);
|
ensureBaseLayersInitialized(layerConfig);
|
||||||
@ -137,6 +146,7 @@ export const useMapOrchestrator = () => {
|
|||||||
);
|
);
|
||||||
if (runtimeCheckedKeys.length > 0) {
|
if (runtimeCheckedKeys.length > 0) {
|
||||||
mapViewStore.setCheckedLayerKeys(runtimeCheckedKeys);
|
mapViewStore.setCheckedLayerKeys(runtimeCheckedKeys);
|
||||||
|
mapStore.setSelectedLegendData();
|
||||||
checkedKeys = runtimeCheckedKeys;
|
checkedKeys = runtimeCheckedKeys;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -304,6 +314,19 @@ export const useMapOrchestrator = () => {
|
|||||||
) => {
|
) => {
|
||||||
mapViewStore.setCurrentZoomLevel(currentZoom);
|
mapViewStore.setCurrentZoomLevel(currentZoom);
|
||||||
|
|
||||||
|
const crossedEngPointMediumThreshold =
|
||||||
|
(previousZoom <= ENG_POINT_MEDIUM_VISIBLE_ZOOM &&
|
||||||
|
currentZoom > ENG_POINT_MEDIUM_VISIBLE_ZOOM) ||
|
||||||
|
(previousZoom > ENG_POINT_MEDIUM_VISIBLE_ZOOM &&
|
||||||
|
currentZoom <= ENG_POINT_MEDIUM_VISIBLE_ZOOM);
|
||||||
|
|
||||||
|
if (
|
||||||
|
crossedEngPointMediumThreshold &&
|
||||||
|
mapViewStore.getCheckedLayerKeys().includes(ENG_POINT_LAYER_KEY)
|
||||||
|
) {
|
||||||
|
mapStore.refreshPointLayerDisplayData(ENG_POINT_LAYER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isHydroMenu) {
|
if (!isHydroMenu) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -391,6 +414,38 @@ export const useMapOrchestrator = () => {
|
|||||||
await mapStore.reloadBySearchTimeRange();
|
await mapStore.reloadBySearchTimeRange();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 备注:统一处理装机容量筛选,让筛选器切换时同步更新 eng_point 图例运行态和点位显示。
|
||||||
|
const changeEngPointCapacity = (capacityType?: string | null) => {
|
||||||
|
const legendItems = mapStore.getLegendItemsByLayerCode(ENG_POINT_LAYER_KEY);
|
||||||
|
if (!legendItems.length) return;
|
||||||
|
|
||||||
|
const allEngLegendNames = legendItems
|
||||||
|
.map((item: any) => item?.nameEn)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!allEngLegendNames.length) return;
|
||||||
|
|
||||||
|
const normalizedCapacityType = capacityType || 'all';
|
||||||
|
let activePrefixes = [LARGE_ENG_LEGEND_PREFIX, MID_ENG_LEGEND_PREFIX];
|
||||||
|
|
||||||
|
if (normalizedCapacityType === 'large_eng_built') {
|
||||||
|
activePrefixes = [LARGE_ENG_LEGEND_PREFIX];
|
||||||
|
} else if (normalizedCapacityType === 'mid_eng_built') {
|
||||||
|
activePrefixes = [MID_ENG_LEGEND_PREFIX];
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleLegendNames = allEngLegendNames.filter((nameEn: string) =>
|
||||||
|
activePrefixes.some(prefix => nameEn.startsWith(prefix))
|
||||||
|
);
|
||||||
|
const hiddenLegendNames = allEngLegendNames.filter(
|
||||||
|
(nameEn: string) => !visibleLegendNames.includes(nameEn)
|
||||||
|
);
|
||||||
|
|
||||||
|
mapStore.updateLegendCheckedBatch(hiddenLegendNames, 0);
|
||||||
|
mapStore.updateLegendCheckedBatch(visibleLegendNames, 1);
|
||||||
|
mapStore.refreshPointLayerDisplayData(ENG_POINT_LAYER_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
// 备注:统一重置地图筛选表单依赖的时间范围,供筛选组件在切页时复位默认输入。
|
// 备注:统一重置地图筛选表单依赖的时间范围,供筛选组件在切页时复位默认输入。
|
||||||
const resetFilterState = () => {
|
const resetFilterState = () => {
|
||||||
mapViewStore.setSearchTimeRange([dayjs().subtract(1, 'M'), dayjs()]);
|
mapViewStore.setSearchTimeRange([dayjs().subtract(1, 'M'), dayjs()]);
|
||||||
@ -436,6 +491,7 @@ export const useMapOrchestrator = () => {
|
|||||||
toggleLegendBatch,
|
toggleLegendBatch,
|
||||||
changeBaseId,
|
changeBaseId,
|
||||||
changeTimeRange,
|
changeTimeRange,
|
||||||
|
changeEngPointCapacity,
|
||||||
resetFilterState,
|
resetFilterState,
|
||||||
focusPoint
|
focusPoint
|
||||||
};
|
};
|
||||||
|
|||||||
396
frontend/src/modules/map/domain/nearby-point-rules.ts
Normal file
396
frontend/src/modules/map/domain/nearby-point-rules.ts
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
export type NearbyPointDisplayMode = 'default' | 'replace' | 'expand';
|
||||||
|
|
||||||
|
export type NearbyPointAutoRule = {
|
||||||
|
distanceThresholdMeters: number;
|
||||||
|
replaceAtZoom: number;
|
||||||
|
expandAtZoom: number;
|
||||||
|
expandOffsetPx: number;
|
||||||
|
angleStepDeg: number;
|
||||||
|
minGroupSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NearbyPointLayoutRule = {
|
||||||
|
ringSize: number;
|
||||||
|
fanAngleForTwoDeg: number;
|
||||||
|
fanAngleForFourDeg: number;
|
||||||
|
fanAngleForManyDeg: number;
|
||||||
|
ringGapPx: number;
|
||||||
|
ringGapFactor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NearbyPointDensityDisplayRule = {
|
||||||
|
minDensityValue: number;
|
||||||
|
minZoom: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NearbyPointConfig = {
|
||||||
|
autoRule: NearbyPointAutoRule;
|
||||||
|
layoutRule: NearbyPointLayoutRule;
|
||||||
|
densityDisplayRules: NearbyPointDensityDisplayRule[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NearbyPointRuntimeMeta = {
|
||||||
|
groupId: string;
|
||||||
|
displayName: string;
|
||||||
|
groupSize: number;
|
||||||
|
priority: number;
|
||||||
|
showZoomMin: number | null;
|
||||||
|
showZoomMax: number | null;
|
||||||
|
replaceAtZoom: number;
|
||||||
|
expandAtZoom: number;
|
||||||
|
expandOffsetPx: number;
|
||||||
|
expandAngleDeg: number;
|
||||||
|
displayMode: NearbyPointDisplayMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 备注:近邻点统一调参入口。
|
||||||
|
// 当前参数偏向“更容易成组、稍早进入替换与展开、展开位移更明显”,
|
||||||
|
// 方便抽吸效果在常用浏览层级更早被感知。
|
||||||
|
export const DEFAULT_NEARBY_POINT_AUTO_RULE: NearbyPointAutoRule = {
|
||||||
|
distanceThresholdMeters: 9000,
|
||||||
|
replaceAtZoom: 10.5,
|
||||||
|
expandAtZoom: 12.2,
|
||||||
|
expandOffsetPx: 48,
|
||||||
|
angleStepDeg: 55,
|
||||||
|
minGroupSize: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
// 备注:展开布局统一调参入口。这里控制单圈数量、扇形角度和外圈扩散节奏。
|
||||||
|
export const DEFAULT_NEARBY_POINT_LAYOUT_RULE: NearbyPointLayoutRule = {
|
||||||
|
ringSize: 5,
|
||||||
|
fanAngleForTwoDeg: 110,
|
||||||
|
fanAngleForFourDeg: 150,
|
||||||
|
fanAngleForManyDeg: 180,
|
||||||
|
ringGapPx: 22,
|
||||||
|
ringGapFactor: 0.85
|
||||||
|
};
|
||||||
|
|
||||||
|
// 备注:密度分档显示规则。distance 越大表示越稀疏,越早参与显示候选。
|
||||||
|
export const DEFAULT_NEARBY_POINT_DENSITY_DISPLAY_RULES: NearbyPointDensityDisplayRule[] =
|
||||||
|
[
|
||||||
|
{
|
||||||
|
minDensityValue: 1500000,
|
||||||
|
minZoom: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minDensityValue: 800000,
|
||||||
|
minZoom: 6.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minDensityValue: 400000,
|
||||||
|
minZoom: 8.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minDensityValue: 50000,
|
||||||
|
minZoom: 10.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minDensityValue: 0,
|
||||||
|
minZoom: 12.2
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_NEARBY_POINT_CONFIG: NearbyPointConfig = {
|
||||||
|
autoRule: DEFAULT_NEARBY_POINT_AUTO_RULE,
|
||||||
|
layoutRule: DEFAULT_NEARBY_POINT_LAYOUT_RULE,
|
||||||
|
densityDisplayRules: DEFAULT_NEARBY_POINT_DENSITY_DISPLAY_RULES
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNearbyPointConfig = (): NearbyPointConfig => {
|
||||||
|
return DEFAULT_NEARBY_POINT_CONFIG;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNearbyPointAutoRule = (): NearbyPointAutoRule => {
|
||||||
|
return getNearbyPointConfig().autoRule;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNearbyPointLayoutRule = (): NearbyPointLayoutRule => {
|
||||||
|
return getNearbyPointConfig().layoutRule;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNearbyPointDensityDisplayRules =
|
||||||
|
(): NearbyPointDensityDisplayRule[] => {
|
||||||
|
return getNearbyPointConfig().densityDisplayRules;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupCandidate = {
|
||||||
|
index: number;
|
||||||
|
point: Record<string, any>;
|
||||||
|
lon: number;
|
||||||
|
lat: number;
|
||||||
|
layerKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeText = (value: unknown): string => {
|
||||||
|
return String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[()()]/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toFiniteNumber = (value: unknown): number | null => {
|
||||||
|
if (value === '' || value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const numericValue = Number(value);
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const haversineDistanceMeters = (
|
||||||
|
lon1: number,
|
||||||
|
lat1: number,
|
||||||
|
lon2: number,
|
||||||
|
lat2: number
|
||||||
|
): number => {
|
||||||
|
const toRad = (degree: number) => (degree * Math.PI) / 180;
|
||||||
|
const earthRadius = 6371000;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const lat1Rad = toRad(lat1);
|
||||||
|
const lat2Rad = toRad(lat2);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(lat1Rad) *
|
||||||
|
Math.cos(lat2Rad) *
|
||||||
|
Math.sin(dLon / 2) *
|
||||||
|
Math.sin(dLon / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return earthRadius * c;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearNearbyPointRuntimeMeta = (point: Record<string, any>) => {
|
||||||
|
delete point._nearbyRule;
|
||||||
|
delete point._nearbyGroupId;
|
||||||
|
delete point._nearbyDisplayName;
|
||||||
|
delete point._nearbyGroupSize;
|
||||||
|
delete point._nearbyPriority;
|
||||||
|
delete point._nearbyShowZoomMin;
|
||||||
|
delete point._nearbyShowZoomMax;
|
||||||
|
delete point._nearbyReplaceAtZoom;
|
||||||
|
delete point._nearbyExpandAtZoom;
|
||||||
|
delete point._nearbyExpandOffsetPx;
|
||||||
|
delete point._nearbyExpandAngleDeg;
|
||||||
|
delete point._nearbyDisplayMode;
|
||||||
|
delete point._nearbyIsPrimary;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPointDisplayName = (point: Record<string, any>): string => {
|
||||||
|
return (
|
||||||
|
point.titleName || point.stnm || point.ennm || point.stcd || point._id || ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPointStableIdentity = (
|
||||||
|
point: Record<string, any>,
|
||||||
|
index: number
|
||||||
|
): string => {
|
||||||
|
return normalizeText(
|
||||||
|
point.stcd ||
|
||||||
|
point._id ||
|
||||||
|
point.titleName ||
|
||||||
|
point.stnm ||
|
||||||
|
point.ennm ||
|
||||||
|
`point_${index}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupSortKey = (candidate: GroupCandidate): string => {
|
||||||
|
return [
|
||||||
|
getPointStableIdentity(candidate.point, candidate.index),
|
||||||
|
normalizeText(candidate.point.layerKey),
|
||||||
|
String(candidate.index).padStart(6, '0')
|
||||||
|
].join('|');
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareGroupCandidates = (
|
||||||
|
left: GroupCandidate,
|
||||||
|
right: GroupCandidate
|
||||||
|
): number => {
|
||||||
|
const leftDensityValue = toFiniteNumber(left.point.distance);
|
||||||
|
const rightDensityValue = toFiniteNumber(right.point.distance);
|
||||||
|
|
||||||
|
if (
|
||||||
|
leftDensityValue !== null &&
|
||||||
|
rightDensityValue !== null &&
|
||||||
|
leftDensityValue !== rightDensityValue
|
||||||
|
) {
|
||||||
|
return rightDensityValue - leftDensityValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftDensityValue !== null || rightDensityValue !== null) {
|
||||||
|
return leftDensityValue !== null ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getGroupSortKey(left).localeCompare(getGroupSortKey(right));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRuntimeMeta = (
|
||||||
|
groupId: string,
|
||||||
|
groupSize: number,
|
||||||
|
priority: number,
|
||||||
|
displayName: string,
|
||||||
|
rule: NearbyPointAutoRule
|
||||||
|
): NearbyPointRuntimeMeta => {
|
||||||
|
const displayMode: NearbyPointDisplayMode =
|
||||||
|
priority === 1 ? 'default' : priority === 2 ? 'replace' : 'expand';
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupId,
|
||||||
|
displayName,
|
||||||
|
groupSize,
|
||||||
|
priority,
|
||||||
|
showZoomMin: priority === 1 ? null : rule.replaceAtZoom,
|
||||||
|
showZoomMax: null,
|
||||||
|
replaceAtZoom: rule.replaceAtZoom,
|
||||||
|
expandAtZoom: rule.expandAtZoom,
|
||||||
|
expandOffsetPx: rule.expandOffsetPx,
|
||||||
|
expandAngleDeg: priority === 1 ? 0 : (priority - 2) * rule.angleStepDeg,
|
||||||
|
displayMode
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildGroupCandidates = (
|
||||||
|
points: Record<string, any>[] = []
|
||||||
|
): GroupCandidate[] => {
|
||||||
|
const candidates: GroupCandidate[] = [];
|
||||||
|
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
const lon = toFiniteNumber(point.lgtd);
|
||||||
|
const lat = toFiniteNumber(point.lttd);
|
||||||
|
if (lon === null || lat === null) {
|
||||||
|
clearNearbyPointRuntimeMeta(point);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.abs(lon) > 180 || Math.abs(lat) > 90) {
|
||||||
|
clearNearbyPointRuntimeMeta(point);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
index,
|
||||||
|
point,
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
layerKey: String(point.layerKey || '')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLayerScopedNearbyGroups = (
|
||||||
|
candidates: GroupCandidate[],
|
||||||
|
rule: NearbyPointAutoRule
|
||||||
|
): GroupCandidate[][] => {
|
||||||
|
const layerScopedCandidates = new Map<string, GroupCandidate[]>();
|
||||||
|
|
||||||
|
candidates.forEach(candidate => {
|
||||||
|
const layerKey = candidate.layerKey || '__empty_layer__';
|
||||||
|
const layerItems = layerScopedCandidates.get(layerKey) || [];
|
||||||
|
layerItems.push(candidate);
|
||||||
|
layerScopedCandidates.set(layerKey, layerItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: GroupCandidate[][] = [];
|
||||||
|
|
||||||
|
layerScopedCandidates.forEach(layerItems => {
|
||||||
|
const sortedCandidates = [...layerItems].sort(compareGroupCandidates);
|
||||||
|
const consumedIndexes = new Set<number>();
|
||||||
|
|
||||||
|
sortedCandidates.forEach((seedCandidate, seedIndex) => {
|
||||||
|
if (consumedIndexes.has(seedIndex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupItems: GroupCandidate[] = [seedCandidate];
|
||||||
|
const groupIndexes = [seedIndex];
|
||||||
|
|
||||||
|
for (
|
||||||
|
let candidateIndex = seedIndex + 1;
|
||||||
|
candidateIndex < sortedCandidates.length;
|
||||||
|
candidateIndex += 1
|
||||||
|
) {
|
||||||
|
if (consumedIndexes.has(candidateIndex)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCandidate = sortedCandidates[candidateIndex];
|
||||||
|
const distanceMeters = haversineDistanceMeters(
|
||||||
|
seedCandidate.lon,
|
||||||
|
seedCandidate.lat,
|
||||||
|
currentCandidate.lon,
|
||||||
|
currentCandidate.lat
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distanceMeters <= rule.distanceThresholdMeters) {
|
||||||
|
groupItems.push(currentCandidate);
|
||||||
|
groupIndexes.push(candidateIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupItems.length >= rule.minGroupSize) {
|
||||||
|
groupIndexes.forEach(index => consumedIndexes.add(index));
|
||||||
|
result.push(groupItems);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 备注:统一按空间距离自动识别近邻点组,不按名称或点类型分规则。
|
||||||
|
export const attachNearbyPointRuntimeMeta = (
|
||||||
|
points: Record<string, any>[] = [],
|
||||||
|
rule: NearbyPointAutoRule = getNearbyPointAutoRule()
|
||||||
|
): Record<string, any>[] => {
|
||||||
|
if (!Array.isArray(points) || points.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
points.forEach(point => clearNearbyPointRuntimeMeta(point));
|
||||||
|
|
||||||
|
const candidates = buildGroupCandidates(points);
|
||||||
|
if (candidates.length < rule.minGroupSize) {
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
const groupedCandidates = buildLayerScopedNearbyGroups(candidates, rule);
|
||||||
|
|
||||||
|
let groupSeed = 0;
|
||||||
|
groupedCandidates.forEach(groupItems => {
|
||||||
|
if (groupItems.length < rule.minGroupSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedItems = [...groupItems].sort(compareGroupCandidates);
|
||||||
|
groupSeed += 1;
|
||||||
|
const groupId = `nearby-group-${groupSeed}`;
|
||||||
|
|
||||||
|
sortedItems.forEach((candidate, orderIndex) => {
|
||||||
|
const priority = orderIndex + 1;
|
||||||
|
const runtimeMeta = buildRuntimeMeta(
|
||||||
|
groupId,
|
||||||
|
sortedItems.length,
|
||||||
|
priority,
|
||||||
|
getPointDisplayName(candidate.point),
|
||||||
|
rule
|
||||||
|
);
|
||||||
|
|
||||||
|
candidate.point._nearbyRule = runtimeMeta;
|
||||||
|
candidate.point._nearbyGroupId = runtimeMeta.groupId;
|
||||||
|
candidate.point._nearbyDisplayName = runtimeMeta.displayName;
|
||||||
|
candidate.point._nearbyGroupSize = runtimeMeta.groupSize;
|
||||||
|
candidate.point._nearbyPriority = runtimeMeta.priority;
|
||||||
|
candidate.point._nearbyShowZoomMin = runtimeMeta.showZoomMin;
|
||||||
|
candidate.point._nearbyShowZoomMax = runtimeMeta.showZoomMax;
|
||||||
|
candidate.point._nearbyReplaceAtZoom = runtimeMeta.replaceAtZoom;
|
||||||
|
candidate.point._nearbyExpandAtZoom = runtimeMeta.expandAtZoom;
|
||||||
|
candidate.point._nearbyExpandOffsetPx = runtimeMeta.expandOffsetPx;
|
||||||
|
candidate.point._nearbyExpandAngleDeg = runtimeMeta.expandAngleDeg;
|
||||||
|
candidate.point._nearbyDisplayMode = runtimeMeta.displayMode;
|
||||||
|
candidate.point._nearbyIsPrimary = runtimeMeta.priority === 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return points;
|
||||||
|
};
|
||||||
@ -21,6 +21,7 @@ export const useMapConfigStore = defineStore('map-config', () => {
|
|||||||
const legendConfigByLayerCode = ref<Record<string, any[]>>({});
|
const legendConfigByLayerCode = ref<Record<string, any[]>>({});
|
||||||
const pageLegendConfig = ref<any[]>([]);
|
const pageLegendConfig = ref<any[]>([]);
|
||||||
const configLoading = ref(false);
|
const configLoading = ref(false);
|
||||||
|
const legendLoading = ref(false);
|
||||||
const lastLoadOptions = ref<MapConfigLoadOptions | null>(null);
|
const lastLoadOptions = ref<MapConfigLoadOptions | null>(null);
|
||||||
|
|
||||||
// 备注:统一规范图例 nameEn,避免 `_测试` 后缀影响索引命中。
|
// 备注:统一规范图例 nameEn,避免 `_测试` 后缀影响索引命中。
|
||||||
@ -141,45 +142,70 @@ export const useMapConfigStore = defineStore('map-config', () => {
|
|||||||
legendConfigByNameEn.value = {};
|
legendConfigByNameEn.value = {};
|
||||||
legendConfigByLayerCode.value = {};
|
legendConfigByLayerCode.value = {};
|
||||||
pageLegendConfig.value = [];
|
pageLegendConfig.value = [];
|
||||||
|
configLoading.value = false;
|
||||||
|
legendLoading.value = false;
|
||||||
lastLoadOptions.value = null;
|
lastLoadOptions.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 备注:加载页面地图配置,只负责请求和保存配置数据,不处理锚点或地图渲染逻辑。
|
const loadPageLayerConfig = async (options: MapConfigLoadOptions) => {
|
||||||
const loadPageMapConfig = async (options: MapConfigLoadOptions) => {
|
|
||||||
configLoading.value = true;
|
configLoading.value = true;
|
||||||
lastLoadOptions.value = { ...options };
|
lastLoadOptions.value = { ...options };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [layerRes, legendAllRes, legendPageRes] = await Promise.all([
|
const layerRes = await getMapList({
|
||||||
getMapList({
|
systemId: options.systemId,
|
||||||
systemId: options.systemId,
|
moduleId: options.moduleId,
|
||||||
moduleId: options.moduleId,
|
description: options.description ?? 'true'
|
||||||
description: options.description ?? 'true'
|
});
|
||||||
}),
|
|
||||||
getModuleMapLegendList(),
|
|
||||||
getModuleMapLegendList(
|
|
||||||
options.pageKey ? { moduleId: options.pageKey } : undefined
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const layerConfig = layerRes?.data?.mapLayerVos || [];
|
const layerConfig = layerRes?.data?.mapLayerVos || [];
|
||||||
const legendOriginal = legendAllRes?.data || [];
|
|
||||||
const pageLegend = legendPageRes?.data || [];
|
|
||||||
|
|
||||||
setLayerConfigTree(layerConfig);
|
setLayerConfigTree(layerConfig);
|
||||||
setLegendConfigOriginal(legendOriginal);
|
|
||||||
setPageLegendConfig(pageLegend);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layerConfig,
|
layerConfig
|
||||||
legendOriginal,
|
|
||||||
pageLegend
|
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
configLoading.value = false;
|
configLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadPageLegendConfig = async (pageKey?: string) => {
|
||||||
|
legendLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [legendAllRes, legendPageRes] = await Promise.all([
|
||||||
|
getModuleMapLegendList(),
|
||||||
|
getModuleMapLegendList(pageKey ? { moduleId: pageKey } : undefined)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const legendOriginal = legendAllRes?.data || [];
|
||||||
|
const pageLegend = legendPageRes?.data || [];
|
||||||
|
|
||||||
|
setLegendConfigOriginal(legendOriginal);
|
||||||
|
setPageLegendConfig(pageLegend);
|
||||||
|
|
||||||
|
return {
|
||||||
|
legendOriginal,
|
||||||
|
pageLegend
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
legendLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 备注:加载页面地图配置,只负责请求和保存配置数据,不处理锚点或地图渲染逻辑。
|
||||||
|
const loadPageMapConfig = async (options: MapConfigLoadOptions) => {
|
||||||
|
const [{ layerConfig }, { legendOriginal, pageLegend }] = await Promise.all([
|
||||||
|
loadPageLayerConfig(options),
|
||||||
|
loadPageLegendConfig(options.pageKey)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
layerConfig,
|
||||||
|
legendOriginal,
|
||||||
|
pageLegend
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layerConfigTree,
|
layerConfigTree,
|
||||||
layerConfigByKey,
|
layerConfigByKey,
|
||||||
@ -188,6 +214,7 @@ export const useMapConfigStore = defineStore('map-config', () => {
|
|||||||
legendConfigByLayerCode,
|
legendConfigByLayerCode,
|
||||||
pageLegendConfig,
|
pageLegendConfig,
|
||||||
configLoading,
|
configLoading,
|
||||||
|
legendLoading,
|
||||||
lastLoadOptions,
|
lastLoadOptions,
|
||||||
normalizeLegendNameEn,
|
normalizeLegendNameEn,
|
||||||
rebuildLayerConfigIndex,
|
rebuildLayerConfigIndex,
|
||||||
@ -200,6 +227,8 @@ export const useMapConfigStore = defineStore('map-config', () => {
|
|||||||
getLegendConfigByLayerCode,
|
getLegendConfigByLayerCode,
|
||||||
getLegendConfigByNameEn,
|
getLegendConfigByNameEn,
|
||||||
clearConfigState,
|
clearConfigState,
|
||||||
|
loadPageLayerConfig,
|
||||||
|
loadPageLegendConfig,
|
||||||
loadPageMapConfig
|
loadPageMapConfig
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -98,11 +98,12 @@ export const useMapDataStore = defineStore('map-data', () => {
|
|||||||
const cache = pointDataCache.value[layerKey];
|
const cache = pointDataCache.value[layerKey];
|
||||||
if (!cache?.data?.length) return;
|
if (!cache?.data?.length) return;
|
||||||
|
|
||||||
const pointsWithLayerKey = cache.data.map((point: any) => ({
|
cache.data.forEach((point: any) => {
|
||||||
...point,
|
if (point?.layerKey !== layerKey) {
|
||||||
layerKey
|
point.layerKey = layerKey;
|
||||||
}));
|
}
|
||||||
allPointData.push(...pointsWithLayerKey);
|
allPointData.push(point);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
pointData.value = allPointData;
|
pointData.value = allPointData;
|
||||||
|
|||||||
@ -2,12 +2,14 @@ import { defineStore, storeToRefs } from 'pinia';
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { MapClass } from '@/components/gis/map.class';
|
import { MapClass } from '@/components/gis/map.class';
|
||||||
|
import { getMapConfig } from '@/components/gis/gisUtils';
|
||||||
import { applyLayerMutualExclusionRules } from '@/modules/map/domain/map-layer-rules';
|
import { applyLayerMutualExclusionRules } from '@/modules/map/domain/map-layer-rules';
|
||||||
import {
|
import {
|
||||||
applyEnvFacilityLegendRule,
|
applyEnvFacilityLegendRule,
|
||||||
buildLegendCheckedState,
|
buildLegendCheckedState,
|
||||||
buildLegendTree
|
buildLegendTree
|
||||||
} from '@/modules/map/domain/legend-deriver';
|
} from '@/modules/map/domain/legend-deriver';
|
||||||
|
import { attachNearbyPointRuntimeMeta } from '@/modules/map/domain/nearby-point-rules';
|
||||||
import { useMapConfigStore } from '@/modules/map/stores/map-config.store';
|
import { useMapConfigStore } from '@/modules/map/stores/map-config.store';
|
||||||
import { useMapDataStore } from '@/modules/map/stores/map-data.store';
|
import { useMapDataStore } from '@/modules/map/stores/map-data.store';
|
||||||
import { useMapViewStore } from '@/modules/map/stores/map-view.store';
|
import { useMapViewStore } from '@/modules/map/stores/map-view.store';
|
||||||
@ -15,6 +17,18 @@ import request from '@/utils/request';
|
|||||||
import { urlList } from '@/utils/GisUrlList';
|
import { urlList } from '@/utils/GisUrlList';
|
||||||
const mapClass = MapClass.getInstance();
|
const mapClass = MapClass.getInstance();
|
||||||
const DEFAULT_LAYER_REQUEST_CONCURRENCY = 4;
|
const DEFAULT_LAYER_REQUEST_CONCURRENCY = 4;
|
||||||
|
const ENG_POINT_LAYER_KEY = 'eng_point';
|
||||||
|
const ENG_POINT_MEDIUM_VISIBLE_ZOOM = 7.5;
|
||||||
|
const ENG_POINT_LARGE_STATES = [
|
||||||
|
'large_eng_built',
|
||||||
|
'large_eng_ubuilt',
|
||||||
|
'large_eng_nbuilt'
|
||||||
|
];
|
||||||
|
const ENG_POINT_MEDIUM_STATES = [
|
||||||
|
'mid_eng_built',
|
||||||
|
'mid_eng_ubuilt',
|
||||||
|
'mid_eng_nbuilt'
|
||||||
|
];
|
||||||
|
|
||||||
const normalizeCachePayload = (value: any): any => {
|
const normalizeCachePayload = (value: any): any => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@ -99,6 +113,46 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
|
|
||||||
const normalizeLegendNameEn = mapConfigStore.normalizeLegendNameEn;
|
const normalizeLegendNameEn = mapConfigStore.normalizeLegendNameEn;
|
||||||
|
|
||||||
|
const filterPointLayerDataForDisplay = (
|
||||||
|
layerKey: string,
|
||||||
|
sourceData: any[] = [],
|
||||||
|
currentZoom: number = mapViewStore.currentZoomLevel
|
||||||
|
): any[] => {
|
||||||
|
if (layerKey !== ENG_POINT_LAYER_KEY || !Array.isArray(sourceData)) {
|
||||||
|
return sourceData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowMedium = currentZoom > ENG_POINT_MEDIUM_VISIBLE_ZOOM;
|
||||||
|
return sourceData.filter((item: any) => {
|
||||||
|
const state = String(item?.anchoPointState || '');
|
||||||
|
if (ENG_POINT_LARGE_STATES.includes(state)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ENG_POINT_MEDIUM_STATES.includes(state)) {
|
||||||
|
return allowMedium;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshPointLayerDisplayData = (layerKey: string) => {
|
||||||
|
if (!layerKey) return;
|
||||||
|
|
||||||
|
const layerItem = findLayerByKey(layerData.value, layerKey);
|
||||||
|
const rawData = layerItem?.data || getPointLayerData(layerKey);
|
||||||
|
const displayData = filterPointLayerDataForDisplay(layerKey, rawData);
|
||||||
|
const shouldRestoreVisible =
|
||||||
|
layerItem?.checked === 1 || checkedLayerKeys.value.includes(layerKey);
|
||||||
|
|
||||||
|
mapClass.addInitDataLayer(displayData, layerKey);
|
||||||
|
if (!shouldRestoreVisible) {
|
||||||
|
mapClass.mdLayerTreeShowOrHidden(layerKey, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreLayerLegendVisibility(layerKey);
|
||||||
|
mapClass.mdLayerTreeShowOrHidden(layerKey, true);
|
||||||
|
};
|
||||||
|
|
||||||
const getLegendItemsByLayerCode = (layerCode: string): any[] => {
|
const getLegendItemsByLayerCode = (layerCode: string): any[] => {
|
||||||
return getLegendConfigByLayerCode(layerCode);
|
return getLegendConfigByLayerCode(layerCode);
|
||||||
};
|
};
|
||||||
@ -109,6 +163,25 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
return mapViewStore.getLegendChecked(normalizedNameEn);
|
return mapViewStore.getLegendChecked(normalizedNameEn);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const restoreLayerLegendVisibility = (layerKey: string): boolean => {
|
||||||
|
const layerLegendItems = getLegendItemsByLayerCode(layerKey);
|
||||||
|
if (
|
||||||
|
legendDataOriginal.value.length === 0 ||
|
||||||
|
layerLegendItems.length === 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapClass.setLegendPointVisible(layerKey, '', false);
|
||||||
|
layerLegendItems.forEach((legendItem: any) => {
|
||||||
|
if (getLegendChecked(legendItem.nameEn) === 1) {
|
||||||
|
applyLegendItemVisibility(legendItem, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const buildRuntimeLegendTree = (
|
const buildRuntimeLegendTree = (
|
||||||
items: any[] = [],
|
items: any[] = [],
|
||||||
selectedLayerCodes?: Set<string>
|
selectedLayerCodes?: Set<string>
|
||||||
@ -186,14 +259,48 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRuntimeCheckedLayerKeys = (): string[] => {
|
||||||
|
return normalizeCheckedLayerKeys(mapViewStore.getCheckedLayerKeys());
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncPointDataForFilter = () => {
|
||||||
|
mapDataStore.rebuildPointDataFromCache();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureGISLayerConfig = (layerItem: any) => {
|
||||||
|
if (!layerItem) return null;
|
||||||
|
|
||||||
|
if (!layerItem.config && layerItem.paramJson) {
|
||||||
|
try {
|
||||||
|
const jsonObj = JSON.parse(layerItem.paramJson);
|
||||||
|
layerItem.config = getMapConfig(jsonObj);
|
||||||
|
} catch {
|
||||||
|
layerItem.config = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layerItem.config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerItem.config.key !== layerItem.key) {
|
||||||
|
layerItem.config = {
|
||||||
|
...layerItem.config,
|
||||||
|
key: layerItem.key
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return layerItem.config;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置图层数据
|
* 设置图层数据
|
||||||
*/
|
*/
|
||||||
const setLayerData = (data: any[]) => {
|
const setLayerData = (data: any[]) => {
|
||||||
mapConfigStore.setLayerConfigTree(data);
|
mapConfigStore.setLayerConfigTree(data);
|
||||||
mapViewStore.setCheckedLayerKeys(
|
const nextCheckedLayerKeys = mapConfigStore.extractCheckedLayerKeys(data);
|
||||||
mapConfigStore.extractCheckedLayerKeys(data)
|
mapViewStore.setCheckedLayerKeys(nextCheckedLayerKeys);
|
||||||
);
|
rebuildLegendRuntimeData(nextCheckedLayerKeys);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -329,12 +436,14 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
// 使用初始化时已加载的数据(存入 layer.data)
|
// 使用初始化时已加载的数据(存入 layer.data)
|
||||||
const cachedData = layer?.data || getPointLayerData(key);
|
const cachedData = layer?.data || getPointLayerData(key);
|
||||||
if (cachedData && cachedData.length > 0) {
|
if (cachedData && cachedData.length > 0) {
|
||||||
console.log(`图层 ${key} 使用缓存数据,共 ${cachedData.length} 条`);
|
|
||||||
layer.checked = 1;
|
layer.checked = 1;
|
||||||
|
const displayData = filterPointLayerDataForDisplay(key, cachedData);
|
||||||
if (!mapClass.hasLayer(key)) {
|
if (!mapClass.hasLayer(key)) {
|
||||||
console.log(`向地图添加图层 ${key}`);
|
mapClass.addInitDataLayer(displayData, key);
|
||||||
mapClass.addInitDataLayer(cachedData, key);
|
} else {
|
||||||
|
mapClass.addInitDataLayer(displayData, key);
|
||||||
}
|
}
|
||||||
|
restoreLayerLegendVisibility(key);
|
||||||
if (mapDataStore.getPointLayerCache(key)) {
|
if (mapDataStore.getPointLayerCache(key)) {
|
||||||
mapDataStore.setPointLayerCacheChecked(key, true);
|
mapDataStore.setPointLayerCacheChecked(key, true);
|
||||||
}
|
}
|
||||||
@ -393,27 +502,26 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
const shouldBeVisible = checkKeys.includes(key);
|
const shouldBeVisible = checkKeys.includes(key);
|
||||||
await handlePointMapLayer(layerItem, shouldBeVisible);
|
await handlePointMapLayer(layerItem, shouldBeVisible);
|
||||||
if (shouldBeVisible) {
|
if (shouldBeVisible) {
|
||||||
if (legendDataOriginal.value.length > 0) {
|
restoreLayerLegendVisibility(key);
|
||||||
// 先将该图层所有锚点隐藏
|
mapClass.mdLayerTreeShowOrHidden(key, true);
|
||||||
mapClass.setLegendPointVisible(key, '', false);
|
|
||||||
getLegendItemsByLayerCode(key).forEach((legendItem: any) => {
|
|
||||||
if (getLegendChecked(legendItem.nameEn) === 1) {
|
|
||||||
applyLegendItemVisibility(legendItem, 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// 图例状态设置完成后再显示图层
|
|
||||||
mapClass.mdLayerTreeShowOrHidden(key, true);
|
|
||||||
} else {
|
|
||||||
// 没有图例数据:直接显示图层
|
|
||||||
mapClass.mdLayerTreeShowOrHidden(key, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (layerItem && layerItem.type === 'GISMap') {
|
} else if (layerItem && layerItem.type === 'GISMap') {
|
||||||
// 处理 GISMap 类型的图层(基础图层已在 LayerController 中加载,这里只需控制显示隐藏)
|
// 处理 GISMap 类型的图层:勾选时若地图实例中不存在,则先重新加载再控制显隐。
|
||||||
if (key && key !== '-' && key !== 'powerBaseStation') {
|
if (key && key !== '-' && key !== 'powerBaseStation') {
|
||||||
console.log('layerItem 接口', key);
|
|
||||||
const shouldBeVisible = checkKeys.includes(key);
|
const shouldBeVisible = checkKeys.includes(key);
|
||||||
mapClass.controlBaseLayerTreeShowAndHidden(key, key, shouldBeVisible);
|
const layerConfig = ensureGISLayerConfig(layerItem);
|
||||||
|
if (
|
||||||
|
shouldBeVisible &&
|
||||||
|
layerConfig &&
|
||||||
|
!mapClass.hasBaseLayer(layerItem.key)
|
||||||
|
) {
|
||||||
|
mapClass.addBaseDataLayer(layerConfig, true);
|
||||||
|
}
|
||||||
|
mapClass.controlBaseLayerTreeShowAndHidden(
|
||||||
|
key,
|
||||||
|
layerItem.key,
|
||||||
|
shouldBeVisible
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -425,7 +533,6 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
* @param checkedKeys 选中的图层 keys(用于优先加载)
|
* @param checkedKeys 选中的图层 keys(用于优先加载)
|
||||||
*/
|
*/
|
||||||
const loadAllLayerData = async (items: any[], checkedKeys: string[] = []) => {
|
const loadAllLayerData = async (items: any[], checkedKeys: string[] = []) => {
|
||||||
console.log('开始加载所有图层数据:', items);
|
|
||||||
mapDataStore.setLoading(true);
|
mapDataStore.setLoading(true);
|
||||||
const currentSessionId = startLoadSession();
|
const currentSessionId = startLoadSession();
|
||||||
const checkedTasks: Array<() => Promise<any>> = [];
|
const checkedTasks: Array<() => Promise<any>> = [];
|
||||||
@ -475,30 +582,47 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('所有图层数据加载完成');
|
|
||||||
|
|
||||||
// 确保所有图层的数据都已经缓存到 pointDataCache 和 layer.data
|
// 确保所有图层的数据都已经缓存到 pointDataCache 和 layer.data
|
||||||
const allLayerKeys = getAllLayerKeys(items);
|
const allLayerKeys = getAllLayerKeys(items);
|
||||||
for (const key of allLayerKeys) {
|
for (const key of allLayerKeys) {
|
||||||
const layer = findLayerByKey(items, key);
|
const layer = findLayerByKey(items, key);
|
||||||
if (layer && hasPointLayerData(key)) {
|
if (layer && hasPointLayerData(key)) {
|
||||||
layer.data = getPointLayerData(key);
|
layer.data = getPointLayerData(key);
|
||||||
console.log(
|
|
||||||
`确认图层 ${key} 数据已准备好,共 ${layer.data.length} 条`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allPointData = mapDataStore.rebuildPointDataFromCache(allLayerKeys);
|
const allPointData = mapDataStore.rebuildPointDataFromCache(allLayerKeys);
|
||||||
console.log('所有锚点数据已设置,共', allPointData.length, '条');
|
attachNearbyPointRuntimeMeta(allPointData);
|
||||||
|
const runtimeCheckedKeys = getRuntimeCheckedLayerKeys();
|
||||||
|
|
||||||
|
// 备注:近邻点元数据需要基于全量点位统一识别,识别完成后同步刷新各图层 Feature。
|
||||||
|
for (const key of allLayerKeys) {
|
||||||
|
const layer = findLayerByKey(items, key);
|
||||||
|
if (!layer || !hasPointLayerData(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerPoints = getPointLayerData(key);
|
||||||
|
const displayData = filterPointLayerDataForDisplay(key, layerPoints);
|
||||||
|
const cacheChecked = mapDataStore.getPointLayerCache(key)?.checked;
|
||||||
|
const shouldRestoreVisible =
|
||||||
|
typeof cacheChecked === 'boolean'
|
||||||
|
? cacheChecked
|
||||||
|
: layer.checked === 1 || runtimeCheckedKeys.includes(key);
|
||||||
|
layer.data = layerPoints;
|
||||||
|
mapClass.addInitDataLayer(displayData, key);
|
||||||
|
if (shouldRestoreVisible) {
|
||||||
|
restoreLayerLegendVisibility(key);
|
||||||
|
}
|
||||||
|
mapClass.mdLayerTreeShowOrHidden(key, shouldRestoreVisible);
|
||||||
|
}
|
||||||
|
|
||||||
// 设置选中的图例数据(确保图例在所有锚点加载完成后才显示)
|
// 设置选中的图例数据(确保图例在所有锚点加载完成后才显示)
|
||||||
setSelectedLegendData();
|
setSelectedLegendData();
|
||||||
|
|
||||||
// 调用 updateLayerData 显示选中图层的锚点
|
// 备注:初始化 loading 期间用户可能已经手动改过图层勾选,这里必须以最新运行态收尾,
|
||||||
if (checkedKeys.length > 0) {
|
// 不能再回放 load 启动瞬间的默认 checked 快照。
|
||||||
await updateLayerData(checkedKeys, true);
|
await updateLayerData(runtimeCheckedKeys, true);
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
mapDataStore.setLoading(false);
|
mapDataStore.setLoading(false);
|
||||||
}
|
}
|
||||||
@ -532,11 +656,21 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
return keys;
|
return keys;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRuntimeLegendName = (nameEn: string): string => {
|
const getRuntimeLegendNames = (
|
||||||
|
layerKey: string,
|
||||||
|
nameEn: string
|
||||||
|
): string[] => {
|
||||||
|
void layerKey;
|
||||||
const normalizedNameEn = normalizeLegendNameEn(nameEn);
|
const normalizedNameEn = normalizeLegendNameEn(nameEn);
|
||||||
return normalizedNameEn.includes('alarm_range_')
|
if (!normalizedNameEn) {
|
||||||
? `large_eng_built_${normalizedNameEn}`
|
return [];
|
||||||
: normalizedNameEn;
|
}
|
||||||
|
|
||||||
|
if (normalizedNameEn.includes('alarm_range_')) {
|
||||||
|
return [`large_eng_built_${normalizedNameEn}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [normalizedNameEn];
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncLegendCheckedInTree = (
|
const syncLegendCheckedInTree = (
|
||||||
@ -562,7 +696,10 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
const layerKey = legendItem.layerCode;
|
const layerKey = legendItem.layerCode;
|
||||||
const layerItem = getLayerConfigByKey(layerKey);
|
const layerItem = getLayerConfigByKey(layerKey);
|
||||||
const shouldBeVisible = checked === 1;
|
const shouldBeVisible = checked === 1;
|
||||||
const runtimeLegendName = getRuntimeLegendName(legendItem.nameEn);
|
const runtimeLegendNames = getRuntimeLegendNames(
|
||||||
|
layerKey,
|
||||||
|
legendItem.nameEn
|
||||||
|
);
|
||||||
|
|
||||||
if (layerItem?.type === 'GISMap') {
|
if (layerItem?.type === 'GISMap') {
|
||||||
mapClass.controlBaseLayerTreeShowAndHidden(
|
mapClass.controlBaseLayerTreeShowAndHidden(
|
||||||
@ -573,11 +710,13 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
mapClass.setLegendPointVisible(
|
runtimeLegendNames.forEach(runtimeLegendName => {
|
||||||
layerKey,
|
mapClass.setLegendPointVisible(
|
||||||
runtimeLegendName,
|
layerKey,
|
||||||
shouldBeVisible
|
runtimeLegendName,
|
||||||
);
|
shouldBeVisible
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -736,9 +875,7 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
field: 'startTime',
|
field: 'startTime',
|
||||||
operator: 'eq',
|
operator: 'eq',
|
||||||
dataType: 'date',
|
dataType: 'date',
|
||||||
value: dayjs(yearTime)
|
value: dayjs(yearTime).startOf('year').format('YYYY-MM-DD 00:00:00')
|
||||||
.startOf('year')
|
|
||||||
.format('YYYY-MM-DD 00:00:00')
|
|
||||||
});
|
});
|
||||||
requestParams.filters.push({
|
requestParams.filters.push({
|
||||||
field: 'endTime',
|
field: 'endTime',
|
||||||
@ -787,12 +924,14 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (hasValidPointLayerCache(key, requestIdentifier)) {
|
if (hasValidPointLayerCache(key, requestIdentifier)) {
|
||||||
console.log(`图层 ${key} 命中有效缓存,跳过请求`);
|
|
||||||
layer.data = getPointLayerData(key);
|
layer.data = getPointLayerData(key);
|
||||||
layer.checked = 0;
|
layer.checked = 0;
|
||||||
|
syncPointDataForFilter();
|
||||||
if (!mapClass.hasLayer(key)) {
|
if (!mapClass.hasLayer(key)) {
|
||||||
console.log(`图层 ${key} 未在地图中,添加到地图(隐藏状态)`);
|
mapClass.addInitDataLayer(
|
||||||
mapClass.addInitDataLayer(getPointLayerData(key), key);
|
filterPointLayerDataForDisplay(key, getPointLayerData(key)),
|
||||||
|
key
|
||||||
|
);
|
||||||
mapClass.mdLayerTreeShowOrHidden(key, false);
|
mapClass.mdLayerTreeShowOrHidden(key, false);
|
||||||
}
|
}
|
||||||
return layer.data;
|
return layer.data;
|
||||||
@ -875,20 +1014,19 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
data: list,
|
data: list,
|
||||||
cacheKey: requestIdentifier
|
cacheKey: requestIdentifier
|
||||||
});
|
});
|
||||||
|
syncPointDataForFilter();
|
||||||
|
|
||||||
layer.data = list;
|
layer.data = list;
|
||||||
|
|
||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
console.log(
|
const displayData = filterPointLayerDataForDisplay(key, list);
|
||||||
`向地图添加图层 ${key}(${
|
mapClass.addInitDataLayer(displayData, key);
|
||||||
isLayerChecked ? '显示状态' : '隐藏状态'
|
if (isLayerChecked) {
|
||||||
})`
|
restoreLayerLegendVisibility(key);
|
||||||
);
|
}
|
||||||
mapClass.addInitDataLayer(list, key);
|
|
||||||
mapClass.mdLayerTreeShowOrHidden(key, isLayerChecked);
|
mapClass.mdLayerTreeShowOrHidden(key, isLayerChecked);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`图层 ${key} 数据加载完成,共 ${list.length} 条记录`);
|
|
||||||
mapDataStore.setLayerLoaded(key);
|
mapDataStore.setLayerLoaded(key);
|
||||||
return list;
|
return list;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -936,7 +1074,6 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
* 根据当前搜索时间范围重新加载描点数据
|
* 根据当前搜索时间范围重新加载描点数据
|
||||||
*/
|
*/
|
||||||
const reloadBySearchTimeRange = async () => {
|
const reloadBySearchTimeRange = async () => {
|
||||||
console.log('searchTimeRange updated:', searchTimeRange.value);
|
|
||||||
mapDataStore.setLoading(true);
|
mapDataStore.setLoading(true);
|
||||||
const currentSessionId = startLoadSession();
|
const currentSessionId = startLoadSession();
|
||||||
|
|
||||||
@ -952,7 +1089,6 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
if (mapDataStore.getPointLayerCache(key)) {
|
if (mapDataStore.getPointLayerCache(key)) {
|
||||||
mapDataStore.removePointLayerCache(key);
|
mapDataStore.removePointLayerCache(key);
|
||||||
mapDataStore.clearLayerLoadState(key);
|
mapDataStore.clearLayerLoadState(key);
|
||||||
console.log(`已清空图层 ${key} 的缓存`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -963,7 +1099,6 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
for (const key of timeRangeLayerKeys) {
|
for (const key of timeRangeLayerKeys) {
|
||||||
const layerItem = findLayerByKey(layerData.value, key);
|
const layerItem = findLayerByKey(layerData.value, key);
|
||||||
if (layerItem && currentCheckedKeys.includes(key)) {
|
if (layerItem && currentCheckedKeys.includes(key)) {
|
||||||
console.log(`重新加载图层 ${key}`);
|
|
||||||
await loadLayerData(layerItem, currentSessionId);
|
await loadLayerData(layerItem, currentSessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -973,7 +1108,6 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allPointData = mapDataStore.rebuildPointDataFromCache();
|
const allPointData = mapDataStore.rebuildPointDataFromCache();
|
||||||
console.log('锚点数据已更新,共', allPointData.length, '条');
|
|
||||||
|
|
||||||
// 更新地图锚点显示
|
// 更新地图锚点显示
|
||||||
await updateLayerData(currentCheckedKeys, false);
|
await updateLayerData(currentCheckedKeys, false);
|
||||||
@ -1015,6 +1149,7 @@ export const useMapStore = defineStore('map', () => {
|
|||||||
getLegendItemsByLayerCode,
|
getLegendItemsByLayerCode,
|
||||||
getLayerBranchKeys,
|
getLayerBranchKeys,
|
||||||
normalizeCheckedLayerKeys,
|
normalizeCheckedLayerKeys,
|
||||||
|
refreshPointLayerDisplayData,
|
||||||
searchTimeRange,
|
searchTimeRange,
|
||||||
reloadBySearchTimeRange,
|
reloadBySearchTimeRange,
|
||||||
updateSearchTimeRange
|
updateSearchTimeRange
|
||||||
|
|||||||
@ -144,11 +144,11 @@ export const urlList = [
|
|||||||
title: '视频监控',
|
title: '视频监控',
|
||||||
params: { sttpCode: 'VD', lgtd: [{ field: 'lgtd', operator: 'isnotnull' }] }
|
params: { sttpCode: 'VD', lgtd: [{ field: 'lgtd', operator: 'isnotnull' }] }
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
url: '/wmp-env-server/env/vd/aiPoint/GetKendoListCust',
|
// url: '/wmp-env-server/env/vd/aiPoint/GetKendoListCust',
|
||||||
title: 'AI视频监控站',
|
// title: 'AI视频监控站',
|
||||||
params: { sttpCode: 'AIVD' }
|
// params: { sttpCode: 'AIVD' }
|
||||||
},
|
// },
|
||||||
// { url: '/wmp-env-server/env/fh/vdpoint/GetKendoListCust', title: '水电站监控视频', keyType: "video_fbfm_point", params: { sttp: 'VD_FBFM', hbrvcd: window.__lyConfigs.wbsCode, lgtd: [{ field: 'lgtd', operator: 'isnotnull' }] } },
|
// { url: '/wmp-env-server/env/fh/vdpoint/GetKendoListCust', title: '水电站监控视频', keyType: "video_fbfm_point", params: { sttp: 'VD_FBFM', hbrvcd: window.__lyConfigs.wbsCode, lgtd: [{ field: 'lgtd', operator: 'isnotnull' }] } },
|
||||||
// { url: '/wmp-env-server/env/fh/vdpoint/GetKendoListCust', title: '生态流量监测断面视频', keyType: "video_eqs_point", params: { sttp: 'VD_EQS', hbrvcd: window.__lyConfigs.wbsCode, lgtd: [{ field: 'lgtd', operator: 'isnotnull' }] } },
|
// { url: '/wmp-env-server/env/fh/vdpoint/GetKendoListCust', title: '生态流量监测断面视频', keyType: "video_eqs_point", params: { sttp: 'VD_EQS', hbrvcd: window.__lyConfigs.wbsCode, lgtd: [{ field: 'lgtd', operator: 'isnotnull' }] } },
|
||||||
// { url: '/wmp-env-server/env/fh/vdpoint/GetKendoListCust', title: '水质站运行视频', keyType: "video_wq_point", params: { sttp: 'VD_WQ', hbrvcd: window.__lyConfigs.wbsCode, lgtd: [{ field: 'lgtd', operator: 'isnotnull' }] } },
|
// { url: '/wmp-env-server/env/fh/vdpoint/GetKendoListCust', title: '水质站运行视频', keyType: "video_wq_point", params: { sttp: 'VD_WQ', hbrvcd: window.__lyConfigs.wbsCode, lgtd: [{ field: 'lgtd', operator: 'isnotnull' }] } },
|
||||||
|
|||||||
@ -1,99 +0,0 @@
|
|||||||
L.TileLayer.WMTS = L.TileLayer.extend({
|
|
||||||
defaultWmtsParams: {
|
|
||||||
service: "WMTS",
|
|
||||||
request: "GetTile",
|
|
||||||
version: "1.0.0",
|
|
||||||
layer: "",
|
|
||||||
style: "",
|
|
||||||
tilematrixset: "",
|
|
||||||
format: "image/jpeg",
|
|
||||||
},
|
|
||||||
|
|
||||||
initialize: function (url, options) {
|
|
||||||
// (String, Object)
|
|
||||||
this._url = url;
|
|
||||||
var lOptions = {};
|
|
||||||
var cOptions = Object.keys(options);
|
|
||||||
cOptions.forEach((element) => {
|
|
||||||
lOptions[element.toLowerCase()] = options[element];
|
|
||||||
});
|
|
||||||
var wmtsParams = L.extend({}, this.defaultWmtsParams);
|
|
||||||
var tileSize = lOptions.tileSize || this.options.tileSize;
|
|
||||||
if (lOptions.detectRetina && L.Browser.retina) {
|
|
||||||
wmtsParams.width = wmtsParams.height = tileSize * 2;
|
|
||||||
} else {
|
|
||||||
wmtsParams.width = wmtsParams.height = tileSize;
|
|
||||||
}
|
|
||||||
for (var i in lOptions) {
|
|
||||||
// all keys that are in defaultWmtsParams options go to WMTS params
|
|
||||||
if (wmtsParams.hasOwnProperty(i) && i != "matrixIds") {
|
|
||||||
wmtsParams[i] = lOptions[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.wmtsParams = wmtsParams;
|
|
||||||
this.matrixIds = options.matrixIds || this.getDefaultMatrix();
|
|
||||||
L.setOptions(this, options);
|
|
||||||
},
|
|
||||||
|
|
||||||
onAdd: function (map) {
|
|
||||||
this._crs = this.options.crs || map.options.crs;
|
|
||||||
L.TileLayer.prototype.onAdd.call(this, map);
|
|
||||||
},
|
|
||||||
|
|
||||||
getTileUrl: function (coords) {
|
|
||||||
// (Point, Number) -> String
|
|
||||||
var tileSize = this.options.tileSize;
|
|
||||||
var nwPoint = coords.multiplyBy(tileSize);
|
|
||||||
nwPoint.x += 1;
|
|
||||||
nwPoint.y -= 1;
|
|
||||||
var sePoint = nwPoint.add(new L.Point(tileSize, tileSize));
|
|
||||||
var zoom = this._tileZoom;
|
|
||||||
var nw = this._crs.project(this._map.unproject(nwPoint, zoom));
|
|
||||||
var se = this._crs.project(this._map.unproject(sePoint, zoom));
|
|
||||||
var tilewidth = se.x - nw.x;
|
|
||||||
var ident = this.matrixIds[zoom].identifier;
|
|
||||||
var tilematrix = this.wmtsParams.tilematrixset + ":" + ident;
|
|
||||||
var X0 = this.matrixIds[zoom].topLeftCorner.lng;
|
|
||||||
var Y0 = this.matrixIds[zoom].topLeftCorner.lat;
|
|
||||||
var tilecol = Math.floor((nw.x - X0) / tilewidth);
|
|
||||||
var tilerow = -Math.floor((nw.y - Y0) / tilewidth);
|
|
||||||
var url = L.Util.template(this._url, { s: this._getSubdomain(coords) });
|
|
||||||
return (
|
|
||||||
url +
|
|
||||||
L.Util.getParamString(this.wmtsParams, url) +
|
|
||||||
"&tilematrix=" +
|
|
||||||
tilematrix +
|
|
||||||
"&tilerow=" +
|
|
||||||
tilerow +
|
|
||||||
"&tilecol=" +
|
|
||||||
tilecol
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
setParams: function (params, noRedraw) {
|
|
||||||
L.extend(this.wmtsParams, params);
|
|
||||||
if (!noRedraw) {
|
|
||||||
this.redraw();
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
getDefaultMatrix: function () {
|
|
||||||
/**
|
|
||||||
* the matrix3857 represents the projection
|
|
||||||
* for in the IGN WMTS for the google coordinates.
|
|
||||||
*/
|
|
||||||
var matrixIds3857 = new Array(22);
|
|
||||||
for (var i = 0; i < 22; i++) {
|
|
||||||
matrixIds3857[i] = {
|
|
||||||
identifier: "" + i,
|
|
||||||
topLeftCorner: new L.LatLng(20037508.3428, -20037508.3428),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return matrixIds3857;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
L.tileLayer.wmts = function (url, options) {
|
|
||||||
return new L.TileLayer.WMTS(url, options);
|
|
||||||
};
|
|
||||||
@ -1,877 +0,0 @@
|
|||||||
(function (factory, window) {
|
|
||||||
if (typeof window !== 'undefined' && window.L) {
|
|
||||||
factory(window.L);
|
|
||||||
}
|
|
||||||
}(function leafletInflatableMarkersGroupFactory(L) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Z-index offset to apply to inflated markers to make them show on top
|
|
||||||
* of deflated markers
|
|
||||||
*/
|
|
||||||
const INFLATED_MARKERS_ZINDEX_OFFSET = 10000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Z-index offset to apply to deflated markers to make them show on top
|
|
||||||
* of all markers when the special 'show hidden markers' action is used
|
|
||||||
*/
|
|
||||||
const DEFLATED_MARKERS_ZINDEX_OFFSET = 20000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A two-state marker that can be added to a InflatableMarkerGroup
|
|
||||||
*
|
|
||||||
* An inflatable marker can be either inflated (it's displayed normally)
|
|
||||||
* or deflated (it's displayed as a smaller different icon to declutter the
|
|
||||||
* map).
|
|
||||||
*
|
|
||||||
* This class is not normally used by end-users. Normal markers should be
|
|
||||||
* added to L.InflatableMarkerGroup instead and the group will manage its
|
|
||||||
* own L.InflatableMarker-s.
|
|
||||||
*
|
|
||||||
* To handle the two inflated/deflated states and the associated changes in
|
|
||||||
* shape and size, instances of this class are also their own icons.
|
|
||||||
* Toggling between the inflated and deflated state boils down to toggle the
|
|
||||||
* display of the base marker's icon (shown when inflated) and this class'
|
|
||||||
* icon (shown when deflated).
|
|
||||||
* @extends L.Marker
|
|
||||||
*/
|
|
||||||
const InflatableMarker = L.InflatableMarker = L.Marker.extend({
|
|
||||||
/**
|
|
||||||
* The options applicable to the marker, same as the Icon options but
|
|
||||||
* an explicit size in mandatory
|
|
||||||
* @public
|
|
||||||
* @type {L.IconOptions}
|
|
||||||
*/
|
|
||||||
options: L.Icon.prototype.options,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs the marker.
|
|
||||||
* @constructs L.InflatableMarker
|
|
||||||
* @public
|
|
||||||
* @param {L.LatLng} latlng - The position of the marker on the map
|
|
||||||
* @param {L.InflatableMarkerGroup} group - The group this marker belongs to,
|
|
||||||
* marker collisions are only computed inside a single group
|
|
||||||
* @param {L.Layer} baseMarker - The inflated version of the marker, added to the
|
|
||||||
* group via addLayer(...)
|
|
||||||
*/
|
|
||||||
initialize: function (latlng, group, baseMarker) {
|
|
||||||
L.Util.setOptions(this, baseMarker.options);
|
|
||||||
this.options.pane = group.options.pane;
|
|
||||||
// hijack the icon drawing process
|
|
||||||
this.options._inflatedIcon = this.options.icon;
|
|
||||||
this.options.icon = this;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The underlying marker added to the group, used as the inflated
|
|
||||||
* version of the current inflatable marker
|
|
||||||
* @public
|
|
||||||
* @type {L.Marker}
|
|
||||||
*/
|
|
||||||
this.baseMarker = baseMarker;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the marker is displayed
|
|
||||||
* @private
|
|
||||||
* @type {L.LatLng}
|
|
||||||
*/
|
|
||||||
this._latlng = latlng;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The clearance box around the marker in the order (north, east, south, west)
|
|
||||||
* @type {[number, number, number, number]}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
this._borders = [null, null, null, null];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The L.InflatableMarkerGroup this marker belongs to
|
|
||||||
* @private
|
|
||||||
* @type {L.InflatableMarkersGroup}
|
|
||||||
*/
|
|
||||||
this._group = group;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The set of all markers which collision with the current marker if
|
|
||||||
* they're both inflated
|
|
||||||
* @private
|
|
||||||
* @type {Set<L.InflatableMarker>}
|
|
||||||
*/
|
|
||||||
this._obstructiveMarkers = new Set();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the marker is currently inflated
|
|
||||||
* @private
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
this._inflated = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the marker's icon needs to be redrawn, typically after a
|
|
||||||
* change from inflated to deflated or vice-versa
|
|
||||||
* @private
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
this._iconNeedsUpdate = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The original Z-index attributed to this marker
|
|
||||||
* @private
|
|
||||||
* @type {integer}
|
|
||||||
*/
|
|
||||||
this._savedZIndexOffset = this.options.zIndexOffset;
|
|
||||||
|
|
||||||
this.addEventParent(this.baseMarker);
|
|
||||||
|
|
||||||
this.on("contextmenu", this.toggle, this);
|
|
||||||
},
|
|
||||||
|
|
||||||
_computeBorders: function(map, margin) {
|
|
||||||
const pos = map.latLngToContainerPoint(this._latlng);
|
|
||||||
const halfSize = L.point(this.options._inflatedIcon.options.iconSize).divideBy(2);
|
|
||||||
const br = pos.add(halfSize).add(margin);
|
|
||||||
const ul = pos.subtract(halfSize).subtract(margin);
|
|
||||||
this._borders = [ul.y, br.x, br.y, ul.x];
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the size of the marker when inflated.
|
|
||||||
* @public
|
|
||||||
* @return {L.Point} The inflated size of the marker
|
|
||||||
*/
|
|
||||||
getInflatedSize: function () {
|
|
||||||
const iconOptions = this.options._inflatedIcon.options;
|
|
||||||
if (!(iconOptions.iconSize instanceof L.Point))
|
|
||||||
iconOptions.iconSize = L.point(iconOptions.iconSize);
|
|
||||||
return iconOptions.iconSize;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the icon to be displayed on the map, depending on its
|
|
||||||
* inflated/deflated state.
|
|
||||||
* @public
|
|
||||||
* @return {HTMLElement} The icon to draw
|
|
||||||
*/
|
|
||||||
createIcon: function () {
|
|
||||||
this._iconObj = this._inflated ?
|
|
||||||
this.options._inflatedIcon :
|
|
||||||
this._group.options.iconCreateFunction(this);
|
|
||||||
return this._iconObj.createIcon();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the shadow to be displayed on the map, alongside with the
|
|
||||||
* icon.
|
|
||||||
* @public
|
|
||||||
* @todo This is not implemented for now and returns null (no shadow)
|
|
||||||
* @return {HTMLElement} The shadow to add to the icon
|
|
||||||
*/
|
|
||||||
createShadow: function () {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forces the icon to be redrawn, for example if its inflated/deflated
|
|
||||||
* state has been modified externally.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
redraw: function () {
|
|
||||||
// Wierd but convenient; remember that InflatableMarker-s are their
|
|
||||||
// own icons, this is why this works.
|
|
||||||
this.setIcon(this);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets another marker as conflicting/collisioning with the current one.
|
|
||||||
* @private
|
|
||||||
* @param {L.InflatableMarker} otherMarker - Another marker in the group
|
|
||||||
*/
|
|
||||||
_addObstructiveMarker: function (otherMarker) {
|
|
||||||
this._obstructiveMarkers.add(otherMarker);
|
|
||||||
otherMarker._obstructiveMarkers.add(this);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets another marker as NOT conflicting/collisioning with the current
|
|
||||||
* one (for example, if it's getting removed altogether or reduced in
|
|
||||||
* size).
|
|
||||||
* @private
|
|
||||||
* @param {L.InflatableMarker} otherMarker - Another marker in the group
|
|
||||||
*/
|
|
||||||
_removeObstructiveMarker: function (otherMarker) {
|
|
||||||
this._obstructiveMarkers.delete(otherMarker);
|
|
||||||
otherMarker._obstructiveMarkers.delete(this);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes all markers from the conflicting/collisioning set of the
|
|
||||||
* current one.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_clearObstructiveMarkers: function() {
|
|
||||||
for (const other of this._obstructiveMarkers) {
|
|
||||||
this._removeObstructiveMarker(other);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the set of all markers conflicting/collisioning to the
|
|
||||||
* current one.
|
|
||||||
* @private
|
|
||||||
* @return {Set<L.InflatableMarker>} The conflicting/collisioning
|
|
||||||
* markers.
|
|
||||||
*/
|
|
||||||
_getObstructiveMarkers: function() {
|
|
||||||
return this._obstructiveMarkers;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Puts a marker on top, this is used to ensure for instance that
|
|
||||||
* inflated markers are shown on top by default
|
|
||||||
* @private
|
|
||||||
* @param {int} offset - The offset by which to increase the Z-index of
|
|
||||||
* the marker
|
|
||||||
*/
|
|
||||||
_bringToFront: function(offset=INFLATED_MARKERS_ZINDEX_OFFSET) {
|
|
||||||
this.setZIndexOffset(offset);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Puts a marker back where it was after it has been brought onto top.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_bringBackFromFront: function() {
|
|
||||||
this.setZIndexOffset(this._savedZIndexOffset);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switches the marker to the inflated state (or no-op if it was already
|
|
||||||
* inflated).
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_inflate: function() {
|
|
||||||
if (this._inflated)
|
|
||||||
return;
|
|
||||||
this._bringToFront();
|
|
||||||
this._inflated = true;
|
|
||||||
this._iconNeedsUpdate = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switches the marker to the deflated state (or no-op if it was already
|
|
||||||
* inflated).
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_deflate: function() {
|
|
||||||
if (!this._inflated)
|
|
||||||
return;
|
|
||||||
if (this._group._inflatedMarkersAbove)
|
|
||||||
this._bringBackFromFront();
|
|
||||||
else
|
|
||||||
this._bringToFront(DEFLATED_MARKERS_ZINDEX_OFFSET);
|
|
||||||
this._inflated = false;
|
|
||||||
this._iconNeedsUpdate = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the inflated/deflated state of the marker
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
toggle: function() {
|
|
||||||
if (this._inflated) {
|
|
||||||
this._deflate();
|
|
||||||
} else {
|
|
||||||
this._inflate();
|
|
||||||
for (const other of this._obstructiveMarkers) {
|
|
||||||
other._deflate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// call daddy to refresh everybody
|
|
||||||
this._group._refreshIcons();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tells whether the marker is currently inflated
|
|
||||||
* @public
|
|
||||||
* @return {boolean} Whether the marker is inflated
|
|
||||||
*/
|
|
||||||
isInflated: function() {
|
|
||||||
return this._inflated;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A group of inflatable markers that can be added onto a leaflet map.
|
|
||||||
*
|
|
||||||
* This class should be used as a regular L.FeatureGroup but for now, users
|
|
||||||
* should stick to L.Marker objects. Other features are not explicitly
|
|
||||||
* supported (but have never been tested so who knows?). In any case, this
|
|
||||||
* class handled its own L.InflatableMarker, so end-users should not
|
|
||||||
* construct them manually but instead add the normal markers to this group.
|
|
||||||
*
|
|
||||||
* The markers group make sure that two conflicting markers (i.e. markers
|
|
||||||
* that would collision if they are both inflated) are never inflated at the
|
|
||||||
* same time. This makes the map much more readable while keeping the
|
|
||||||
* markers at their place on the map, as long as the deflated icon of each
|
|
||||||
* marker is carefully chosen not to clutter the map.
|
|
||||||
* @extends L.FeatureGroup
|
|
||||||
*/
|
|
||||||
const InflatableMarkersGroup = L.InflatableMarkersGroup = L.FeatureGroup.extend({
|
|
||||||
/**
|
|
||||||
* How to configure the markers group, additionally from the
|
|
||||||
* L.FeatureGroup options
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
options: {
|
|
||||||
/**
|
|
||||||
* The margin that must be kept clear around an inflated marker.
|
|
||||||
*
|
|
||||||
* Any marker closer to the current marker than this clearance will
|
|
||||||
* be marked as collisioning. The first element is the horizontal
|
|
||||||
* margin, the second one the vertical margin. It's set by default
|
|
||||||
* to [2, 2] (pixels). You can set it to [0, 0] or even to a
|
|
||||||
* negative value to tolerate some amount of overlapping between
|
|
||||||
* inflated markers.
|
|
||||||
*/
|
|
||||||
obstructionSize: L.point(2, 2),
|
|
||||||
/**
|
|
||||||
* The function that will be used to create deflated markers' icons.
|
|
||||||
*/
|
|
||||||
iconCreateFunction: null,
|
|
||||||
/**
|
|
||||||
* The map pane this group has to be added to, by default the
|
|
||||||
* markers pane
|
|
||||||
*/
|
|
||||||
groupPane: L.Marker.prototype.options.pane,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs an InflatableMarkersGroup
|
|
||||||
* @constructs {L.InflatableMarkersGroup}
|
|
||||||
* @param {L.InflatableMarkersGroup.options} options - The configuration options
|
|
||||||
*/
|
|
||||||
initialize: function (options) {
|
|
||||||
L.Util.setOptions(this, options);
|
|
||||||
if (!this.options.obstructionSize instanceof L.Point)
|
|
||||||
this.options.obstructionSize = L.point(this.options.obstructionSize);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The underlying layer group used to handle the map layer adding and
|
|
||||||
* removal operations
|
|
||||||
* @private
|
|
||||||
* @type {L.FeatureGroup}
|
|
||||||
*/
|
|
||||||
this._featureGroup = L.featureGroup();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A associative array between base Leaflet layers (the one added to
|
|
||||||
* this group) and the corresponding inflatable markers this group
|
|
||||||
* constructs.
|
|
||||||
*
|
|
||||||
* Note: this attribute is a Map but a Javascript one (i.e. an
|
|
||||||
* associative array), not a Leaflet Map!
|
|
||||||
* @private
|
|
||||||
* @type {Map<L.Layer, L.InflatableMarker>}
|
|
||||||
*/
|
|
||||||
this._markers = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The boundaries of this layer group
|
|
||||||
* @private
|
|
||||||
* @type {L.LatLngBounds}
|
|
||||||
*/
|
|
||||||
this._bounds = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this group has already been initialized and added to a map,
|
|
||||||
* setting it to false will force recompute all the InflatableMarker-s
|
|
||||||
* collisions.
|
|
||||||
* @private
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
this._alreadyDisplayed = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the inflated markers should be displayed on top of deflated
|
|
||||||
* markers (the default) or the opposite (to make masked deflated
|
|
||||||
* markers prominent).
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
this._inflatedMarkersAbove = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Other InflatableMarkersGroup that could be added to the same map and
|
|
||||||
* whose member markers may collision with the current group.
|
|
||||||
* @type {Set<L.InflatableMarkersGroup>}
|
|
||||||
*/
|
|
||||||
this._otherGroups = new Set();
|
|
||||||
|
|
||||||
this._featureGroup.addEventParent(this);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The events we handle.
|
|
||||||
*
|
|
||||||
* The class reacts to zooming/dezooming to inflate as many markers as
|
|
||||||
* possible with the new zoom without causing collisions.
|
|
||||||
* @public
|
|
||||||
* @inheritdoc
|
|
||||||
*/
|
|
||||||
getEvents: function() {
|
|
||||||
return {
|
|
||||||
'zoomend': this._zoomend,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether two markers collision when both are inflated
|
|
||||||
*
|
|
||||||
* @param {L.Point} distance - The distance between the center of both
|
|
||||||
* markers
|
|
||||||
* @param {L.InflatableMarker} marker1 - The first marker
|
|
||||||
* @param {L.InflatableMarker} marker2 - The second marker
|
|
||||||
* @return {boolean} Whether the markers are closer to each other than
|
|
||||||
* the obstruction size
|
|
||||||
*/
|
|
||||||
_mayObstruct: function(distance, marker1, marker2) {
|
|
||||||
const marker1HalfSize = marker1.getInflatedSize().divideBy(2);
|
|
||||||
const marker2HalfSize = marker2.getInflatedSize().divideBy(2);
|
|
||||||
return Math.abs(distance.x) <= marker1HalfSize.x + marker2HalfSize.x + this.options.obstructionSize.x &&
|
|
||||||
Math.abs(distance.y) <= marker1HalfSize.y + marker2HalfSize.y + this.options.obstructionSize.y
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a marker to the group and compute the collisions with already
|
|
||||||
* existing markers
|
|
||||||
* @param {L.Marker} layer - A marker to add (technically, it should be
|
|
||||||
* any layer, but we kind of break the Liskov principle to make things
|
|
||||||
* simpler for now...)
|
|
||||||
* @public
|
|
||||||
* @inheritdoc
|
|
||||||
*/
|
|
||||||
addLayer: function (layer) {
|
|
||||||
if (this.hasLayer(layer)) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
const marker = new InflatableMarker(layer._latlng, this, layer);
|
|
||||||
let inhibited = false;
|
|
||||||
if (this._map) {
|
|
||||||
const target = this._map.latLngToContainerPoint(layer._latlng);
|
|
||||||
for (const [l, m] of this._markers) {
|
|
||||||
const other = this._map.latLngToContainerPoint(l._latlng);
|
|
||||||
if (this._mayObstruct(target.subtract(other), marker, m)) {
|
|
||||||
marker._addObstructiveMarker(m);
|
|
||||||
inhibited = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._markers.set(layer, marker);
|
|
||||||
this._featureGroup.addLayer(marker);
|
|
||||||
|
|
||||||
if (!inhibited) {
|
|
||||||
marker._inflate();
|
|
||||||
marker.redraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this._inflatedMarkersAbove && !marker._inflated) {
|
|
||||||
marker._bringToFront(DEFLATED_MARKERS_ZINDEX_OFFSET);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._bounds == null) {
|
|
||||||
this._bounds = L.latLngBounds(layer._latlng, layer._latlng);
|
|
||||||
} else {
|
|
||||||
this._bounds.extend(layer._latlng);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a marker from the group and recompute the collisions of
|
|
||||||
* previously collisioning markers
|
|
||||||
* @param {L.Layer} layer - The layer to remove
|
|
||||||
* @public
|
|
||||||
* @inheritdoc
|
|
||||||
*/
|
|
||||||
removeLayer: function (layer) {
|
|
||||||
if (this._markers.has(layer)) {
|
|
||||||
const marker = this._markers.get(layer);
|
|
||||||
marker._clearObstructiveMarkers();
|
|
||||||
this._featureGroup.removeLayer(marker);
|
|
||||||
this._markers.delete(layer);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove all layers from this group and reset it completely
|
|
||||||
* @public
|
|
||||||
* @inheritdoc
|
|
||||||
*/
|
|
||||||
clearLayers: function () {
|
|
||||||
this._markers.clear();
|
|
||||||
this._featureGroup.clearLayers();
|
|
||||||
this._alreadyDisplayed = false; // reset the layer as if it had never
|
|
||||||
// been displayed
|
|
||||||
this._bounds = null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the bounds of this group on the map
|
|
||||||
* @return L.LatLngBounds
|
|
||||||
* @public
|
|
||||||
* @inheritdoc
|
|
||||||
*/
|
|
||||||
getBounds: function () {
|
|
||||||
return this._bounds;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a marker has been added to the group
|
|
||||||
* @param {L.Marker} layer - The marker looked for
|
|
||||||
* @return {boolean} whether the layer belongs to this group
|
|
||||||
* @public
|
|
||||||
* @inheritdoc
|
|
||||||
*/
|
|
||||||
hasLayer: function(layer) {
|
|
||||||
return this._markers.has(layer);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recompute all collision sets for all markers
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_recomputeObstructions: async function() {
|
|
||||||
for (const [l,m] of this._markers) {
|
|
||||||
m._clearObstructiveMarkers();
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitFor = delay => new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
|
|
||||||
const iterator = this._markers.entries();
|
|
||||||
let result = iterator.next();
|
|
||||||
|
|
||||||
const allMarkers = [...this._iterateOnOwnAndOtherGroupsMarkers()];
|
|
||||||
allMarkers.forEach(m => m[1]._computeBorders(this._map, this.options.obstructionSize));
|
|
||||||
allMarkers.sort((m1, m2) => {
|
|
||||||
return m1[1]._borders[0] - m2[1]._borders[0];
|
|
||||||
});
|
|
||||||
|
|
||||||
const process = L.bind(async function () {
|
|
||||||
const start = new Date();
|
|
||||||
|
|
||||||
while (!result.done) {
|
|
||||||
const currentDate = new Date();
|
|
||||||
if (currentDate - start > 200) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const marker = result.value[1];
|
|
||||||
|
|
||||||
const posNorth = this._bisectNorth(allMarkers, marker._borders[0]);
|
|
||||||
const posSouth = this._bisectSouth(allMarkers, marker._borders[2]);
|
|
||||||
|
|
||||||
const sortedByWest = allMarkers.slice(posNorth, posSouth)
|
|
||||||
.sort((m1, m2) => {
|
|
||||||
return m1[1]._borders[3] - m2[1]._borders[3];
|
|
||||||
});
|
|
||||||
const posWest = this._bisectWest(sortedByWest, marker._borders[3]);
|
|
||||||
const posEast = this._bisectEast(sortedByWest, marker._borders[1]);
|
|
||||||
|
|
||||||
sortedByWest.slice(posWest, posEast).forEach(m => {
|
|
||||||
if (marker !== m[1])
|
|
||||||
marker._addObstructiveMarker(m[1]);
|
|
||||||
})
|
|
||||||
|
|
||||||
result = iterator.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.done) {
|
|
||||||
process();
|
|
||||||
return waitFor(50).then(process);
|
|
||||||
} else {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}, this);
|
|
||||||
|
|
||||||
return process();
|
|
||||||
},
|
|
||||||
|
|
||||||
_iterateOnOwnAndOtherGroupsMarkers: function* () {
|
|
||||||
let iteratorOnGroups = this._otherGroups.values();
|
|
||||||
let iteratorOnMarkers = this._markers.entries();
|
|
||||||
let result = iteratorOnMarkers.next();
|
|
||||||
while (!result.done) {
|
|
||||||
yield result.value;
|
|
||||||
result = iteratorOnMarkers.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
let gr = iteratorOnGroups.next();
|
|
||||||
while (!gr.done) {
|
|
||||||
iteratorOnMarkers = gr.value._markers.entries();
|
|
||||||
result = iteratorOnMarkers.next();
|
|
||||||
while (!result.done) {
|
|
||||||
yield result.value;
|
|
||||||
result = iteratorOnMarkers.next();
|
|
||||||
}
|
|
||||||
gr = iteratorOnGroups.next();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React to this group being added onto a map
|
|
||||||
*
|
|
||||||
* Computation of the collision sets is delayed until this method is
|
|
||||||
* called.
|
|
||||||
* @param {L.Map} map - The leaflet map
|
|
||||||
* @public
|
|
||||||
* @inheritdoc
|
|
||||||
*/
|
|
||||||
onAdd: function (map) {
|
|
||||||
this._map = map;
|
|
||||||
this._recomputeObstructions().then(() => {
|
|
||||||
this._featureGroup.addTo(map);
|
|
||||||
if (!this._alreadyDisplayed) {
|
|
||||||
this._alreadyDisplayed = true;
|
|
||||||
this.inflateAsManyAsPossible(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React to this group being removed from a map
|
|
||||||
*
|
|
||||||
* The collision sets are not cleared at this point in case someone
|
|
||||||
* wants to have a look at them, but they will be recomputed if the
|
|
||||||
* group is added again onto a map anyway.
|
|
||||||
* @param {L.Map} map - The leaflet map
|
|
||||||
* @public
|
|
||||||
* @inheritdoc
|
|
||||||
*/
|
|
||||||
onRemove: function (map) {
|
|
||||||
this._featureGroup.removeFrom(map);
|
|
||||||
this._map = null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redraw all icons that are marked for update (after zooming in for
|
|
||||||
* instance)
|
|
||||||
* @private
|
|
||||||
* @inheritdoc
|
|
||||||
*/
|
|
||||||
_refreshIcons: function() {
|
|
||||||
for (const [baseMarker,marker] of this._iterateOnOwnAndOtherGroupsMarkers()) {
|
|
||||||
if (marker._iconNeedsUpdate)
|
|
||||||
marker.redraw();
|
|
||||||
marker._iconNeedsUpdate = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inflate as many deflated markers as possible without causing
|
|
||||||
* collisions
|
|
||||||
*
|
|
||||||
* We don't actually go clever and guarantee that we display the
|
|
||||||
* theoretical maximum number of markers. We just go through them in
|
|
||||||
* order and avoid inflating a marker if it would collision with already
|
|
||||||
* inflated markers.
|
|
||||||
*
|
|
||||||
* @param {boolean} reset - Start by deflating all markers before
|
|
||||||
* inflating as many as possible
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
inflateAsManyAsPossible: function(reset = false) {
|
|
||||||
const inhibited = new Set();
|
|
||||||
if (!reset) {
|
|
||||||
// add all the markers that could obstruct the already inflated
|
|
||||||
// markers
|
|
||||||
for (const [layer, marker] of this._markers) {
|
|
||||||
if (marker.inflated) {
|
|
||||||
for (const other of marker._obstructiveMarkers) {
|
|
||||||
inhibited.add(other);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [layer, marker] of this._markers) {
|
|
||||||
if (!inhibited.has(marker)) {
|
|
||||||
marker._inflate();
|
|
||||||
for (const other of marker._obstructiveMarkers) {
|
|
||||||
inhibited.add(other);
|
|
||||||
other._deflate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._refreshIcons();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deflate all markers
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
deflateAll: function() {
|
|
||||||
for (const [layer, marker] of this._markers) {
|
|
||||||
marker._deflate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React to zoom/dezoom by recomputing the collision between markers and
|
|
||||||
* inflate as many as possible
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_zoomend: function() {
|
|
||||||
this._recomputeObstructions().then(() =>
|
|
||||||
this.inflateAsManyAsPossible()
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles between showing the inflating markers on top of all markers
|
|
||||||
* (the normal state) and showing the deflating markers (so that we can
|
|
||||||
* locate deflated markers previously hidden).
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
toggleInflatedMarkersAbove() {
|
|
||||||
if (this._inflatedMarkersAbove) {
|
|
||||||
for (const [layer, marker] of this._markers) {
|
|
||||||
if (!marker._inflated) {
|
|
||||||
marker._bringToFront(DEFLATED_MARKERS_ZINDEX_OFFSET);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._inflatedMarkersAbove = false;
|
|
||||||
} else {
|
|
||||||
for (const [layer, marker] of this._markers) {
|
|
||||||
if (!marker._inflated) {
|
|
||||||
marker._bringBackFromFront();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._inflatedMarkersAbove = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
makeAwareOfOtherGroup(other) {
|
|
||||||
this._otherGroups.add(other);
|
|
||||||
other._otherGroups.add(this);
|
|
||||||
if (this._map) {
|
|
||||||
this._recomputeObstructions().then(() =>
|
|
||||||
this.inflateAsManyAsPossible()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removeOtherGroup(other) {
|
|
||||||
this._otherGroups.delete(other);
|
|
||||||
other._otherGroups.delete(this);
|
|
||||||
if (this._map) {
|
|
||||||
this._recomputeObstructions().then(() =>
|
|
||||||
this.inflateAsManyAsPossible()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bisect a map of markers to find the last one before the west boundary in parameter
|
|
||||||
* The array has to be sorted.
|
|
||||||
* @param {[L.Marker, L.InflatableMarker][]} markers An array of pairs [Marker, InflatableMarker]
|
|
||||||
* @param {number} x The minimum X boundary
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_bisectWest(markers, x) {
|
|
||||||
if (!markers.length)
|
|
||||||
return markers.length;
|
|
||||||
let left = 0;
|
|
||||||
let right = markers.length - 1;
|
|
||||||
let pos = Math.trunc((left + right) / 2);
|
|
||||||
while (pos > left) {
|
|
||||||
if (x > markers[pos][1]._borders[1]) {
|
|
||||||
left = pos;
|
|
||||||
} else {
|
|
||||||
right = pos;
|
|
||||||
}
|
|
||||||
pos = Math.trunc((left + right) / 2);
|
|
||||||
}
|
|
||||||
return markers.length > left && x > markers[left][1]._borders[1] ? left + 1 : left;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bisect a map of markers to find the first one past the east boundary in parameter
|
|
||||||
* The array has to be sorted.
|
|
||||||
* @param {[L.Marker, L.InflatableMarker][]} markers An array of pairs [Marker, InflatableMarker]
|
|
||||||
* @param {number} x The minimum X boundary
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_bisectEast(markers, x) {
|
|
||||||
if (!markers.length)
|
|
||||||
return -1;
|
|
||||||
let left = 0;
|
|
||||||
let right = markers.length - 1;
|
|
||||||
let pos = Math.ceil((left + right) / 2);
|
|
||||||
while (pos < right) {
|
|
||||||
if (x > markers[pos][1]._borders[3]) {
|
|
||||||
left = pos;
|
|
||||||
} else {
|
|
||||||
right = pos;
|
|
||||||
}
|
|
||||||
pos = Math.ceil((left + right) / 2);
|
|
||||||
}
|
|
||||||
return markers.length > right && x < markers[right][1]._borders[3] ? right : right - 1;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bisect a map of markers to find the first one past the east boundary in parameter
|
|
||||||
* The array has to be sorted.
|
|
||||||
* @param {[L.Marker, L.InflatableMarker][]} markers An array of pairs [Marker, InflatableMarker]
|
|
||||||
* @param {number} y The minimum Y boundary
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_bisectNorth(markers, y) {
|
|
||||||
if (!markers.length)
|
|
||||||
return markers.length;
|
|
||||||
let left = 0;
|
|
||||||
let right = markers.length - 1;
|
|
||||||
let pos = Math.trunc((left + right) / 2);
|
|
||||||
while (pos > left) {
|
|
||||||
if (y > markers[pos][1]._borders[2]) {
|
|
||||||
left = pos;
|
|
||||||
} else {
|
|
||||||
right = pos;
|
|
||||||
}
|
|
||||||
pos = Math.trunc((left + right) / 2);
|
|
||||||
}
|
|
||||||
return y > markers[left][1]._borders[2] ? left : left + 1;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bisect a map of markers to find the first one past the east boundary in parameter
|
|
||||||
* The array has to be sorted.
|
|
||||||
* @param {[L.Marker, L.InflatableMarker][]} markers An array of pairs [Marker, InflatableMarker]
|
|
||||||
* @param {number} y The minimum Y boundary
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_bisectSouth(markers, y) {
|
|
||||||
if (!markers.length)
|
|
||||||
return -1;
|
|
||||||
let left = 0;
|
|
||||||
let right = markers.length - 1;
|
|
||||||
let pos = Math.ceil((left + right) / 2);
|
|
||||||
while (pos < right) {
|
|
||||||
if (y > markers[pos][1]._borders[0]) {
|
|
||||||
left = pos;
|
|
||||||
} else {
|
|
||||||
right = pos;
|
|
||||||
}
|
|
||||||
pos = Math.ceil((left + right) / 2);
|
|
||||||
}
|
|
||||||
return markers.length > right && y < markers[right][1]._borders[0] ? right : right - 1;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs an inflatable markers group
|
|
||||||
* @constructs {L.InflatableMarkersGroup}
|
|
||||||
* @param {L.InflatableMarkersGroup.options} options - the configuration
|
|
||||||
* @return {L.InflatableMarkersGroup} The newly constructed group
|
|
||||||
*/
|
|
||||||
L.inflatableMarkersGroup = function (options) {
|
|
||||||
return new L.InflatableMarkersGroup(options);
|
|
||||||
};
|
|
||||||
}, window));
|
|
||||||
@ -34,7 +34,7 @@ service.interceptors.request.use(
|
|||||||
config.headers._sysid = '10EC2E0B-AEA9-4757-83A2-201BA1BC54E9';
|
config.headers._sysid = '10EC2E0B-AEA9-4757-83A2-201BA1BC54E9';
|
||||||
|
|
||||||
config.headers.authorization =
|
config.headers.authorization =
|
||||||
'bearer cb68557d-2861-497d-b708-6b73ae68ba95';
|
'bearer 863782c5-5e51-49c6-95fd-13e6f25b9bf2';
|
||||||
config.baseURL = '/';
|
config.baseURL = '/';
|
||||||
} else {
|
} else {
|
||||||
const user = useUserStoreHook();
|
const user = useUserStoreHook();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user