地图模块修改
This commit is contained in:
parent
0398be3d83
commit
3200f1f36f
Binary file not shown.
@ -17,6 +17,9 @@
|
||||
- 如果只是文字发生碰撞,希望点图标保留,仅文字隐藏或延后显示。
|
||||
|
||||
本文件先整理当前代码现状和问题成因,不直接修改实现。
|
||||
如果需要查看**当前已经落地的抽吸规则、默认参数和调参入口**,请同时参考:
|
||||
|
||||
- `docs/地图模块-抽吸规则与参数说明.md`
|
||||
|
||||
## 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>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
1.地图抽吸
|
||||
2.地图文字和点显示
|
||||
-->
|
||||
<el-config-provider :locale="appStore.locale" :size="appStore.size">
|
||||
<a-config-provider :theme="usetTheme" :locale="locale">
|
||||
<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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-pagination {
|
||||
|
||||
@ -80,6 +80,7 @@ const init = async () => {
|
||||
|
||||
// 备注:地图控制器只分发简单视图切换命令,避免在页面入口堆叠复杂业务逻辑。
|
||||
const handleMapController = async (e: any, mapType: any) => {
|
||||
console.log(e, mapType);
|
||||
switch (e) {
|
||||
case 'dim':
|
||||
await mapClass.switchView(mapType);
|
||||
|
||||
@ -122,6 +122,12 @@ export class MapCesium implements MapInterface {
|
||||
checked: boolean
|
||||
): void {}
|
||||
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {}
|
||||
hasLayer(layerKey: string): boolean {
|
||||
return false;
|
||||
}
|
||||
hasBaseLayer(layerKey: string): boolean {
|
||||
return false;
|
||||
}
|
||||
setLegendPointVisible(
|
||||
layerKey: string,
|
||||
anchoPointState: string,
|
||||
|
||||
@ -134,9 +134,13 @@ export class MapClass implements MapClassInterface {
|
||||
this.service.mapOutPut();
|
||||
}
|
||||
// 检查图层是否存在
|
||||
hasLayer(layerKey: string): void {
|
||||
hasLayer(layerKey: string): boolean {
|
||||
return this.service.hasLayer(layerKey);
|
||||
}
|
||||
// 检查 GIS/底图图层是否存在
|
||||
hasBaseLayer(layerKey: string): boolean {
|
||||
return this.service.hasBaseLayer(layerKey);
|
||||
}
|
||||
// 删除描点图层
|
||||
removePointLayer(layerKey: string): void {
|
||||
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
|
||||
* @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 {
|
||||
defaults as defaultInteractions,
|
||||
Draw,
|
||||
DoubleClickZoom
|
||||
DoubleClickZoom,
|
||||
DragPan
|
||||
} from 'ol/interaction';
|
||||
import { getTopLeft, getWidth } from 'ol/extent';
|
||||
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
|
||||
@ -28,6 +29,7 @@ import { servers } from './mapurlManage';
|
||||
import { XYZ } from 'ol/source';
|
||||
import Feature from 'ol/Feature';
|
||||
import LineString from 'ol/geom/LineString';
|
||||
import Point from 'ol/geom/Point';
|
||||
import {
|
||||
getLength as getSphericalLength,
|
||||
getArea as getSphericalArea
|
||||
@ -38,6 +40,7 @@ import { useModelStore } from '@/store/modules/model';
|
||||
import { PointLayerManager } from './ol/point-layer-manager';
|
||||
import { PopupManager } from './ol/popup-manager';
|
||||
import { RegionMaskManager } from './ol/region-mask-manager';
|
||||
import { getNearbyPointDensityDisplayRules } from '@/modules/map/domain/nearby-point-rules';
|
||||
|
||||
const modelStore = useModelStore();
|
||||
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 MAX_ZOOM = 22;
|
||||
const INITIAL_ZOOM = 4.5;
|
||||
const BATCH_POPUP_MODE_ZOOM = 15;
|
||||
const FULL_DISPLAY_NO_COLLISION_ZOOM = 15;
|
||||
|
||||
// 定义边界 [minX, minY, maxX, maxY] (Web Mercator 坐标)
|
||||
const BOUNDS_SW = [26.5, -9.99999999999929];
|
||||
@ -56,6 +61,7 @@ export class MapOl implements MapInterface {
|
||||
map: OlMap | null = null;
|
||||
view: View | null = null;
|
||||
private layerRegistry: Map<string, any> = new Map();
|
||||
private iconLoadState = new Map<string, 'loading' | 'loaded' | 'error'>();
|
||||
private baseLayerConfig: any | null = null;
|
||||
private hydropBaseConfig: any | null = null;
|
||||
private REGISTRY_KEY = 'customBaseLayer';
|
||||
@ -73,6 +79,8 @@ export class MapOl implements MapInterface {
|
||||
private popupManager: PopupManager;
|
||||
private regionMaskManager: RegionMaskManager;
|
||||
private isBatchPopupMode = false;
|
||||
private batchPopupRefreshFrameId: number | null = null;
|
||||
private labelVisibilityRefreshFrameId: number | null = null;
|
||||
constructor() {
|
||||
this.pointLayerManager = new PointLayerManager({
|
||||
map: null,
|
||||
@ -134,10 +142,15 @@ export class MapOl implements MapInterface {
|
||||
controls: [], // 对应 Leaflet 的 attributionControl: false, zoomControl: false
|
||||
interactions: defaultInteractions({
|
||||
doubleClickZoom: true,
|
||||
dragPan: true,
|
||||
dragPan: false,
|
||||
|
||||
pinchRotate: false // 通常禁用旋转,除非需要
|
||||
}).extend([mouseWheelInteraction])
|
||||
}).extend([
|
||||
new DragPan({
|
||||
kinetic: undefined
|
||||
}),
|
||||
mouseWheelInteraction
|
||||
])
|
||||
});
|
||||
this.pointLayerManager.setMap(this.map);
|
||||
this.popupManager.setMap(this.map);
|
||||
@ -145,13 +158,10 @@ export class MapOl implements MapInterface {
|
||||
|
||||
this.view.on('change:resolution', () => {
|
||||
this.handleZoomChange();
|
||||
const zoom = this.view.getZoom();
|
||||
console.log('mouse zoom:', zoom);
|
||||
});
|
||||
this.map.on('click', evt => {
|
||||
this.popupManager.showPopup(undefined, undefined);
|
||||
this.popupManager.handleMapClick(evt.pixel, detectedFeature => {
|
||||
console.log(detectedFeature);
|
||||
if (detectedFeature.values_.sttpMap == 'ylfb') {
|
||||
modelStore.ylfbModalVisible = true;
|
||||
modelStore.params = detectedFeature.values_;
|
||||
@ -174,7 +184,7 @@ export class MapOl implements MapInterface {
|
||||
targetElement.style.cursor = payload.hoveredId ? 'pointer' : '';
|
||||
}
|
||||
|
||||
// 批量 popup 模式下(>= 14 级)不触发 hover popup
|
||||
// 批量 popup 模式下不触发 hover popup
|
||||
if (!this.isBatchPopupMode) {
|
||||
this.showPopup(payload.detectedFeature, payload.coordinate);
|
||||
}
|
||||
@ -183,12 +193,13 @@ export class MapOl implements MapInterface {
|
||||
|
||||
this.map.on('pointerdrag', () => {
|
||||
if (this.isBatchPopupMode) {
|
||||
this.updateBatchPopups();
|
||||
this.requestUpdateBatchPopups();
|
||||
}
|
||||
});
|
||||
this.map.on('moveend', () => {
|
||||
this.requestRefreshPointLabelVisibility(true);
|
||||
if (this.isBatchPopupMode) {
|
||||
this.updateBatchPopups();
|
||||
this.requestUpdateBatchPopups(true);
|
||||
}
|
||||
});
|
||||
|
||||
@ -211,6 +222,9 @@ export class MapOl implements MapInterface {
|
||||
_mdoptions?: MDOptions
|
||||
): void {
|
||||
this.pointLayerManager.addDataLayer(pointData, layerType);
|
||||
const currentZoom = this.view ? this.view.getZoom() : INITIAL_ZOOM;
|
||||
this.pointLayerManager.updateNearbyFeatureLayout(currentZoom);
|
||||
this.requestRefreshPointLabelVisibility(true);
|
||||
}
|
||||
/**
|
||||
* 初始化 Popup Overlay
|
||||
@ -242,7 +256,7 @@ export class MapOl implements MapInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听缩放层级变化,>= 15 时自动显示可视区域内所有锚点的 popup
|
||||
* 监听缩放层级变化,到达指定层级时自动显示可视区域内所有锚点的 popup
|
||||
*/
|
||||
private handleZoomChange() {
|
||||
if (!this.view) return;
|
||||
@ -250,7 +264,10 @@ export class MapOl implements MapInterface {
|
||||
const zoom = this.view.getZoom();
|
||||
if (zoom === undefined) return;
|
||||
|
||||
if (zoom >= 15) {
|
||||
this.pointLayerManager.updateNearbyFeatureLayout(zoom);
|
||||
this.requestRefreshPointLabelVisibility();
|
||||
|
||||
if (zoom >= BATCH_POPUP_MODE_ZOOM) {
|
||||
this.enableBatchPopupMode();
|
||||
} else {
|
||||
this.disableBatchPopupMode();
|
||||
@ -263,7 +280,7 @@ export class MapOl implements MapInterface {
|
||||
private enableBatchPopupMode() {
|
||||
this.isBatchPopupMode = true;
|
||||
this.popupManager.clearBatchPopups();
|
||||
this.updateBatchPopups();
|
||||
this.requestUpdateBatchPopups(true);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -271,6 +288,10 @@ export class MapOl implements MapInterface {
|
||||
*/
|
||||
private disableBatchPopupMode() {
|
||||
this.isBatchPopupMode = false;
|
||||
if (this.batchPopupRefreshFrameId !== null) {
|
||||
cancelAnimationFrame(this.batchPopupRefreshFrameId);
|
||||
this.batchPopupRefreshFrameId = null;
|
||||
}
|
||||
this.popupManager.clearBatchPopups();
|
||||
this.popupManager.showPopup(undefined, undefined);
|
||||
}
|
||||
@ -289,7 +310,7 @@ export class MapOl implements MapInterface {
|
||||
/**
|
||||
* 创建点样式 (模拟 Leaflet 的 DivIcon 效果,支持随缩放动态调整大小)
|
||||
*/
|
||||
private createPointStyle(feature: Feature): Style {
|
||||
private createPointStyle(feature: Feature): Style[] | null {
|
||||
const iconUrl = feature.get('_iconUrl') as string;
|
||||
const labelText = feature.get('_labelText') as string;
|
||||
const legendVisible = feature.get('_legendVisible');
|
||||
@ -299,47 +320,504 @@ export class MapOl implements MapInterface {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (legendVisible === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (regionVisible === false) {
|
||||
if (!this.ensureIconReady(iconUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentZoom: any = this.view ? this.view.getZoom() : 4.5;
|
||||
if (!this.shouldRenderFeatureByDistance(feature, currentZoom)) {
|
||||
return null;
|
||||
}
|
||||
const iconTargetVisible =
|
||||
legendVisible !== false &&
|
||||
regionVisible !== false &&
|
||||
this.shouldRenderFeatureByDensity(feature, currentZoom) &&
|
||||
this.shouldRenderNearbyFeature(feature, currentZoom);
|
||||
|
||||
const dynamicScale = this.getDynamicPointScale(currentZoom);
|
||||
const fontSize = this.getPointFontSize(dynamicScale);
|
||||
const formattedLabelText = this.formatPointLabelText(labelText);
|
||||
const labelLineCount = formattedLabelText
|
||||
? formattedLabelText.split('\n').length
|
||||
: 1;
|
||||
const labelOffsetY =
|
||||
labelLineCount > 1 ? -30 * dynamicScale : -22 * dynamicScale;
|
||||
const labelOffsetY = this.getPointLabelRenderOffsetY(
|
||||
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 = {
|
||||
image: new Icon({
|
||||
src: iconUrl,
|
||||
scale: dynamicScale,
|
||||
anchor: [0.5, 0.5],
|
||||
crossOrigin: 'anonymous',
|
||||
declutterMode: 'declutter'
|
||||
if (!finalIconVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles: Style[] = [];
|
||||
styles.push(
|
||||
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({
|
||||
text: formattedLabelText,
|
||||
offsetY: labelOffsetY,
|
||||
font: `${fontSize}px sans-serif`,
|
||||
fill: new Fill({ color: '#fff' }),
|
||||
stroke: new Stroke({ color: 'rgba(0, 0, 0, .9)', width: 2 }),
|
||||
textAlign: 'center',
|
||||
declutterMode: 'declutter'
|
||||
});
|
||||
return new Style(styleOptions);
|
||||
return !(
|
||||
narrowedIconRect.right + padding <= labelRect.left ||
|
||||
narrowedIconRect.left - padding >= labelRect.right ||
|
||||
narrowedIconRect.bottom + padding <= labelRect.top ||
|
||||
narrowedIconRect.top - padding >= labelRect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -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,
|
||||
currentZoom: number
|
||||
): boolean {
|
||||
const distance = feature.get('distance');
|
||||
if (distance === undefined || distance === null) {
|
||||
const minZoom = this.getFeatureDensityMinZoom(feature);
|
||||
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;
|
||||
}
|
||||
|
||||
let minDistanceRequired = 0;
|
||||
if (currentZoom <= 6) {
|
||||
minDistanceRequired = 1500000;
|
||||
} else if (currentZoom <= 8) {
|
||||
minDistanceRequired = 800000;
|
||||
} else if (currentZoom <= 10.5) {
|
||||
minDistanceRequired = 400000;
|
||||
if (!Number.isFinite(priority) || priority <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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 {
|
||||
const color = (feature.get('iconCode') as string) || '#3399CC';
|
||||
@ -728,7 +1373,7 @@ export class MapOl implements MapInterface {
|
||||
layerInstance.setVisible(checked);
|
||||
// 图层显隐变化时刷新 batch popup
|
||||
if (this.isBatchPopupMode) {
|
||||
this.updateBatchPopups();
|
||||
this.requestUpdateBatchPopups(true);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
@ -739,16 +1384,21 @@ export class MapOl implements MapInterface {
|
||||
}
|
||||
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {
|
||||
this.pointLayerManager.setLayerVisible(layerType, checked);
|
||||
this.requestRefreshPointLabelVisibility(true);
|
||||
// 图层显隐变化时刷新 batch popup
|
||||
if (this.isBatchPopupMode) {
|
||||
this.updateBatchPopups();
|
||||
this.requestUpdateBatchPopups(true);
|
||||
}
|
||||
}
|
||||
|
||||
hasLayer(layerType: string): void {
|
||||
hasLayer(layerType: string): boolean {
|
||||
return this.pointLayerManager.hasLayer(layerType as string);
|
||||
}
|
||||
|
||||
hasBaseLayer(layerKey: string): boolean {
|
||||
return this.layerRegistry.has(layerKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩放地图
|
||||
* @param type 'out' 缩小, 'in' 放大
|
||||
@ -1383,7 +2033,7 @@ export class MapOl implements MapInterface {
|
||||
);
|
||||
// 图例变化时刷新 batch popup
|
||||
if (this.isBatchPopupMode) {
|
||||
this.updateBatchPopups();
|
||||
this.requestUpdateBatchPopups(true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1403,9 +2053,10 @@ export class MapOl implements MapInterface {
|
||||
anchoPointState,
|
||||
checked
|
||||
);
|
||||
this.requestRefreshPointLabelVisibility(true);
|
||||
// 图例变化时刷新 batch popup
|
||||
if (this.isBatchPopupMode) {
|
||||
this.updateBatchPopups();
|
||||
this.requestUpdateBatchPopups(true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1487,6 +2138,10 @@ export class MapOl implements MapInterface {
|
||||
* 移除地图对象,释放资源
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.labelVisibilityRefreshFrameId !== null) {
|
||||
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
|
||||
this.labelVisibilityRefreshFrameId = null;
|
||||
}
|
||||
this.removeQueryLayer();
|
||||
this.regionMaskManager.destroy();
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import { fromLonLat } from 'ol/proj';
|
||||
import { getIconPath } from '@/utils/index';
|
||||
import { getNearbyPointConfig } from '@/modules/map/domain/nearby-point-rules';
|
||||
|
||||
type PointLayerStyleFactory = (feature: Feature) => any;
|
||||
|
||||
@ -75,6 +76,99 @@ export class PointLayerManager {
|
||||
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 的点图层是否已存在。
|
||||
hasLayer(layerKey: string): boolean {
|
||||
return this.layerRegistry.has(layerKey);
|
||||
@ -304,6 +398,7 @@ export class PointLayerManager {
|
||||
'_labelText',
|
||||
item.sttpMap === 'ylfb' ? item.ftp || '' : titleName || stnm || ennm || ''
|
||||
);
|
||||
feature.set('_baseCoordinates', coord);
|
||||
feature.set('_legendVisible', true);
|
||||
feature.set('_sttpMap', item.sttpMap);
|
||||
if (item.popupHtml) {
|
||||
@ -353,4 +448,20 @@ export class PointLayerManager {
|
||||
|
||||
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 blockedIds: Array<string | number | null> = [];
|
||||
|
||||
// 用于 declutter 模拟检测的已放置要素边界框列表
|
||||
const placedAnchors: Array<{
|
||||
bbLeft: number;
|
||||
bbRight: number;
|
||||
bbTop: number;
|
||||
bbBottom: number;
|
||||
}> = [];
|
||||
|
||||
visibleFeatures.forEach(feature => {
|
||||
const geom = feature.getGeometry();
|
||||
if (!geom || geom.getType() !== 'Point') return;
|
||||
@ -273,59 +265,10 @@ export class PopupManager {
|
||||
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 popupHtml = props.popupHtml || generatePopupHtml(props);
|
||||
if (!popupHtml) return;
|
||||
|
||||
// 记录边界框用于 declutter 检测
|
||||
placedAnchors.push({ bbLeft, bbRight, bbTop, bbBottom });
|
||||
|
||||
// 创建与原始 popupElement 完全相同的样式
|
||||
const popupEl = document.createElement('div');
|
||||
popupEl.className = this.popupElement.className;
|
||||
|
||||
@ -110,6 +110,7 @@ const route = useRoute();
|
||||
const mapOrchestrator = useMapOrchestrator();
|
||||
const mapDataStore = useMapDataStore();
|
||||
const mapViewStore = useMapViewStore();
|
||||
const ENG_POINT_LAYER_KEY = 'eng_point';
|
||||
const siteRangePicker = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '大型电站', value: 'large_eng_built' },
|
||||
@ -147,6 +148,7 @@ watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
mapOrchestrator.resetFilterState();
|
||||
mapOrchestrator.changeEngPointCapacity('all');
|
||||
searchTimeRange.value = [
|
||||
dayjs(mapViewStore.searchTimeRange[0]).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'
|
||||
) {
|
||||
if (capacityValue === 'large_eng_built') {
|
||||
filteredData = filteredData.filter((item: any) =>
|
||||
largeScale.includes(item.anchoPointState)
|
||||
);
|
||||
filteredData = filteredData.filter((item: any) => {
|
||||
if (item.layerKey !== ENG_POINT_LAYER_KEY) {
|
||||
return true;
|
||||
}
|
||||
return largeScale.includes(item.anchoPointState);
|
||||
});
|
||||
} else if (capacityValue === 'mid_eng_built') {
|
||||
filteredData = filteredData.filter((item: any) =>
|
||||
mediumScale.includes(item.anchoPointState)
|
||||
);
|
||||
filteredData = filteredData.filter((item: any) => {
|
||||
if (item.layerKey !== ENG_POINT_LAYER_KEY) {
|
||||
return true;
|
||||
}
|
||||
return mediumScale.includes(item.anchoPointState);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -228,13 +236,7 @@ watch(
|
||||
|
||||
const handleCapacityChange = (value: string) => {
|
||||
formModel.value.anchorPointSelect = null;
|
||||
|
||||
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);
|
||||
mapOrchestrator.changeEngPointCapacity(value);
|
||||
};
|
||||
|
||||
// 备注:搜索定位只分发定位命令,不在筛选组件里直接操作地图实例。
|
||||
|
||||
@ -68,19 +68,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
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 LegendItem from '@/components/mapLegend/LegendItem.vue';
|
||||
|
||||
const mapStore = useMapStore();
|
||||
const mapDataStore = useMapDataStore();
|
||||
const mapConfigStore = useMapConfigStore();
|
||||
const mapOrchestrator = useMapOrchestrator();
|
||||
const isOpen = ref(true);
|
||||
|
||||
// 备注:图例组件直接渲染当前已派生好的图例运行态,不再维护本地同步副本。
|
||||
const legendData = computed(() => mapStore.legendDataSelected || []);
|
||||
// 备注:loading 状态直接取数据缓存态,组件本身不再 watch 后再二次赋值。
|
||||
const loadLegendData = computed(() => mapDataStore.loading);
|
||||
// 备注:图例转圈只跟图例配置接口绑定,不再等待全部锚点接口加载完成。
|
||||
const loadLegendData = computed(() => mapConfigStore.legendLoading);
|
||||
// 备注:环保设施分组展示状态直接从当前图例数据派生,保持组件只负责渲染。
|
||||
const hasEnvFac = computed(() =>
|
||||
legendData.value.some((item: any) => item.name === '环保设施')
|
||||
|
||||
@ -41,6 +41,10 @@ const HYDRO_DYNAMIC_LAYER_KEYS = [
|
||||
'va_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 = () => {
|
||||
const mapClass = MapClass.getInstance();
|
||||
@ -109,18 +113,23 @@ export const useMapOrchestrator = () => {
|
||||
try {
|
||||
const previousPageKey = mapConfigStore.lastLoadOptions?.pageKey || '';
|
||||
const previousCheckedKeys = mapViewStore.getCheckedLayerKeys();
|
||||
const { layerConfig, legendOriginal, pageLegend } =
|
||||
await mapConfigStore.loadPageMapConfig({
|
||||
systemId: SYSTEM_ID,
|
||||
moduleId: MODULE_ID,
|
||||
pageKey,
|
||||
description: 'true'
|
||||
});
|
||||
const loadOptions = {
|
||||
systemId: SYSTEM_ID,
|
||||
moduleId: MODULE_ID,
|
||||
pageKey,
|
||||
description: 'true'
|
||||
};
|
||||
const layerConfigPromise =
|
||||
mapConfigStore.loadPageLayerConfig(loadOptions);
|
||||
const legendConfigPromise = mapConfigStore.loadPageLegendConfig(pageKey);
|
||||
const { legendOriginal, pageLegend } = await legendConfigPromise;
|
||||
|
||||
if (legendOriginal.length > 0) {
|
||||
mapStore.setLegendData(legendOriginal, pageLegend);
|
||||
}
|
||||
|
||||
const { layerConfig } = await layerConfigPromise;
|
||||
|
||||
if (layerConfig.length > 0) {
|
||||
mapStore.setLayerData(layerConfig);
|
||||
ensureBaseLayersInitialized(layerConfig);
|
||||
@ -137,6 +146,7 @@ export const useMapOrchestrator = () => {
|
||||
);
|
||||
if (runtimeCheckedKeys.length > 0) {
|
||||
mapViewStore.setCheckedLayerKeys(runtimeCheckedKeys);
|
||||
mapStore.setSelectedLegendData();
|
||||
checkedKeys = runtimeCheckedKeys;
|
||||
}
|
||||
}
|
||||
@ -304,6 +314,19 @@ export const useMapOrchestrator = () => {
|
||||
) => {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@ -391,6 +414,38 @@ export const useMapOrchestrator = () => {
|
||||
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 = () => {
|
||||
mapViewStore.setSearchTimeRange([dayjs().subtract(1, 'M'), dayjs()]);
|
||||
@ -436,6 +491,7 @@ export const useMapOrchestrator = () => {
|
||||
toggleLegendBatch,
|
||||
changeBaseId,
|
||||
changeTimeRange,
|
||||
changeEngPointCapacity,
|
||||
resetFilterState,
|
||||
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 pageLegendConfig = ref<any[]>([]);
|
||||
const configLoading = ref(false);
|
||||
const legendLoading = ref(false);
|
||||
const lastLoadOptions = ref<MapConfigLoadOptions | null>(null);
|
||||
|
||||
// 备注:统一规范图例 nameEn,避免 `_测试` 后缀影响索引命中。
|
||||
@ -141,45 +142,70 @@ export const useMapConfigStore = defineStore('map-config', () => {
|
||||
legendConfigByNameEn.value = {};
|
||||
legendConfigByLayerCode.value = {};
|
||||
pageLegendConfig.value = [];
|
||||
configLoading.value = false;
|
||||
legendLoading.value = false;
|
||||
lastLoadOptions.value = null;
|
||||
};
|
||||
|
||||
// 备注:加载页面地图配置,只负责请求和保存配置数据,不处理锚点或地图渲染逻辑。
|
||||
const loadPageMapConfig = async (options: MapConfigLoadOptions) => {
|
||||
const loadPageLayerConfig = async (options: MapConfigLoadOptions) => {
|
||||
configLoading.value = true;
|
||||
lastLoadOptions.value = { ...options };
|
||||
|
||||
try {
|
||||
const [layerRes, legendAllRes, legendPageRes] = await Promise.all([
|
||||
getMapList({
|
||||
systemId: options.systemId,
|
||||
moduleId: options.moduleId,
|
||||
description: options.description ?? 'true'
|
||||
}),
|
||||
getModuleMapLegendList(),
|
||||
getModuleMapLegendList(
|
||||
options.pageKey ? { moduleId: options.pageKey } : undefined
|
||||
)
|
||||
]);
|
||||
const layerRes = await getMapList({
|
||||
systemId: options.systemId,
|
||||
moduleId: options.moduleId,
|
||||
description: options.description ?? 'true'
|
||||
});
|
||||
|
||||
const layerConfig = layerRes?.data?.mapLayerVos || [];
|
||||
const legendOriginal = legendAllRes?.data || [];
|
||||
const pageLegend = legendPageRes?.data || [];
|
||||
|
||||
setLayerConfigTree(layerConfig);
|
||||
setLegendConfigOriginal(legendOriginal);
|
||||
setPageLegendConfig(pageLegend);
|
||||
|
||||
return {
|
||||
layerConfig,
|
||||
legendOriginal,
|
||||
pageLegend
|
||||
layerConfig
|
||||
};
|
||||
} finally {
|
||||
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 {
|
||||
layerConfigTree,
|
||||
layerConfigByKey,
|
||||
@ -188,6 +214,7 @@ export const useMapConfigStore = defineStore('map-config', () => {
|
||||
legendConfigByLayerCode,
|
||||
pageLegendConfig,
|
||||
configLoading,
|
||||
legendLoading,
|
||||
lastLoadOptions,
|
||||
normalizeLegendNameEn,
|
||||
rebuildLayerConfigIndex,
|
||||
@ -200,6 +227,8 @@ export const useMapConfigStore = defineStore('map-config', () => {
|
||||
getLegendConfigByLayerCode,
|
||||
getLegendConfigByNameEn,
|
||||
clearConfigState,
|
||||
loadPageLayerConfig,
|
||||
loadPageLegendConfig,
|
||||
loadPageMapConfig
|
||||
};
|
||||
});
|
||||
|
||||
@ -98,11 +98,12 @@ export const useMapDataStore = defineStore('map-data', () => {
|
||||
const cache = pointDataCache.value[layerKey];
|
||||
if (!cache?.data?.length) return;
|
||||
|
||||
const pointsWithLayerKey = cache.data.map((point: any) => ({
|
||||
...point,
|
||||
layerKey
|
||||
}));
|
||||
allPointData.push(...pointsWithLayerKey);
|
||||
cache.data.forEach((point: any) => {
|
||||
if (point?.layerKey !== layerKey) {
|
||||
point.layerKey = layerKey;
|
||||
}
|
||||
allPointData.push(point);
|
||||
});
|
||||
});
|
||||
|
||||
pointData.value = allPointData;
|
||||
|
||||
@ -2,12 +2,14 @@ import { defineStore, storeToRefs } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { MapClass } from '@/components/gis/map.class';
|
||||
import { getMapConfig } from '@/components/gis/gisUtils';
|
||||
import { applyLayerMutualExclusionRules } from '@/modules/map/domain/map-layer-rules';
|
||||
import {
|
||||
applyEnvFacilityLegendRule,
|
||||
buildLegendCheckedState,
|
||||
buildLegendTree
|
||||
} 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 { useMapDataStore } from '@/modules/map/stores/map-data.store';
|
||||
import { useMapViewStore } from '@/modules/map/stores/map-view.store';
|
||||
@ -15,6 +17,18 @@ import request from '@/utils/request';
|
||||
import { urlList } from '@/utils/GisUrlList';
|
||||
const mapClass = MapClass.getInstance();
|
||||
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 => {
|
||||
if (Array.isArray(value)) {
|
||||
@ -99,6 +113,46 @@ export const useMapStore = defineStore('map', () => {
|
||||
|
||||
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[] => {
|
||||
return getLegendConfigByLayerCode(layerCode);
|
||||
};
|
||||
@ -109,6 +163,25 @@ export const useMapStore = defineStore('map', () => {
|
||||
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 = (
|
||||
items: any[] = [],
|
||||
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[]) => {
|
||||
mapConfigStore.setLayerConfigTree(data);
|
||||
mapViewStore.setCheckedLayerKeys(
|
||||
mapConfigStore.extractCheckedLayerKeys(data)
|
||||
);
|
||||
const nextCheckedLayerKeys = mapConfigStore.extractCheckedLayerKeys(data);
|
||||
mapViewStore.setCheckedLayerKeys(nextCheckedLayerKeys);
|
||||
rebuildLegendRuntimeData(nextCheckedLayerKeys);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -329,12 +436,14 @@ export const useMapStore = defineStore('map', () => {
|
||||
// 使用初始化时已加载的数据(存入 layer.data)
|
||||
const cachedData = layer?.data || getPointLayerData(key);
|
||||
if (cachedData && cachedData.length > 0) {
|
||||
console.log(`图层 ${key} 使用缓存数据,共 ${cachedData.length} 条`);
|
||||
layer.checked = 1;
|
||||
const displayData = filterPointLayerDataForDisplay(key, cachedData);
|
||||
if (!mapClass.hasLayer(key)) {
|
||||
console.log(`向地图添加图层 ${key}`);
|
||||
mapClass.addInitDataLayer(cachedData, key);
|
||||
mapClass.addInitDataLayer(displayData, key);
|
||||
} else {
|
||||
mapClass.addInitDataLayer(displayData, key);
|
||||
}
|
||||
restoreLayerLegendVisibility(key);
|
||||
if (mapDataStore.getPointLayerCache(key)) {
|
||||
mapDataStore.setPointLayerCacheChecked(key, true);
|
||||
}
|
||||
@ -393,27 +502,26 @@ export const useMapStore = defineStore('map', () => {
|
||||
const shouldBeVisible = checkKeys.includes(key);
|
||||
await handlePointMapLayer(layerItem, shouldBeVisible);
|
||||
if (shouldBeVisible) {
|
||||
if (legendDataOriginal.value.length > 0) {
|
||||
// 先将该图层所有锚点隐藏
|
||||
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);
|
||||
}
|
||||
restoreLayerLegendVisibility(key);
|
||||
mapClass.mdLayerTreeShowOrHidden(key, true);
|
||||
}
|
||||
} else if (layerItem && layerItem.type === 'GISMap') {
|
||||
// 处理 GISMap 类型的图层(基础图层已在 LayerController 中加载,这里只需控制显示隐藏)
|
||||
// 处理 GISMap 类型的图层:勾选时若地图实例中不存在,则先重新加载再控制显隐。
|
||||
if (key && key !== '-' && key !== 'powerBaseStation') {
|
||||
console.log('layerItem 接口', 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(用于优先加载)
|
||||
*/
|
||||
const loadAllLayerData = async (items: any[], checkedKeys: string[] = []) => {
|
||||
console.log('开始加载所有图层数据:', items);
|
||||
mapDataStore.setLoading(true);
|
||||
const currentSessionId = startLoadSession();
|
||||
const checkedTasks: Array<() => Promise<any>> = [];
|
||||
@ -475,30 +582,47 @@ export const useMapStore = defineStore('map', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('所有图层数据加载完成');
|
||||
|
||||
// 确保所有图层的数据都已经缓存到 pointDataCache 和 layer.data
|
||||
const allLayerKeys = getAllLayerKeys(items);
|
||||
for (const key of allLayerKeys) {
|
||||
const layer = findLayerByKey(items, key);
|
||||
if (layer && hasPointLayerData(key)) {
|
||||
layer.data = getPointLayerData(key);
|
||||
console.log(
|
||||
`确认图层 ${key} 数据已准备好,共 ${layer.data.length} 条`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// 调用 updateLayerData 显示选中图层的锚点
|
||||
if (checkedKeys.length > 0) {
|
||||
await updateLayerData(checkedKeys, true);
|
||||
}
|
||||
// 备注:初始化 loading 期间用户可能已经手动改过图层勾选,这里必须以最新运行态收尾,
|
||||
// 不能再回放 load 启动瞬间的默认 checked 快照。
|
||||
await updateLayerData(runtimeCheckedKeys, true);
|
||||
} finally {
|
||||
mapDataStore.setLoading(false);
|
||||
}
|
||||
@ -532,11 +656,21 @@ export const useMapStore = defineStore('map', () => {
|
||||
return keys;
|
||||
};
|
||||
|
||||
const getRuntimeLegendName = (nameEn: string): string => {
|
||||
const getRuntimeLegendNames = (
|
||||
layerKey: string,
|
||||
nameEn: string
|
||||
): string[] => {
|
||||
void layerKey;
|
||||
const normalizedNameEn = normalizeLegendNameEn(nameEn);
|
||||
return normalizedNameEn.includes('alarm_range_')
|
||||
? `large_eng_built_${normalizedNameEn}`
|
||||
: normalizedNameEn;
|
||||
if (!normalizedNameEn) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (normalizedNameEn.includes('alarm_range_')) {
|
||||
return [`large_eng_built_${normalizedNameEn}`];
|
||||
}
|
||||
|
||||
return [normalizedNameEn];
|
||||
};
|
||||
|
||||
const syncLegendCheckedInTree = (
|
||||
@ -562,7 +696,10 @@ export const useMapStore = defineStore('map', () => {
|
||||
const layerKey = legendItem.layerCode;
|
||||
const layerItem = getLayerConfigByKey(layerKey);
|
||||
const shouldBeVisible = checked === 1;
|
||||
const runtimeLegendName = getRuntimeLegendName(legendItem.nameEn);
|
||||
const runtimeLegendNames = getRuntimeLegendNames(
|
||||
layerKey,
|
||||
legendItem.nameEn
|
||||
);
|
||||
|
||||
if (layerItem?.type === 'GISMap') {
|
||||
mapClass.controlBaseLayerTreeShowAndHidden(
|
||||
@ -573,11 +710,13 @@ export const useMapStore = defineStore('map', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
mapClass.setLegendPointVisible(
|
||||
layerKey,
|
||||
runtimeLegendName,
|
||||
shouldBeVisible
|
||||
);
|
||||
runtimeLegendNames.forEach(runtimeLegendName => {
|
||||
mapClass.setLegendPointVisible(
|
||||
layerKey,
|
||||
runtimeLegendName,
|
||||
shouldBeVisible
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@ -736,9 +875,7 @@ export const useMapStore = defineStore('map', () => {
|
||||
field: 'startTime',
|
||||
operator: 'eq',
|
||||
dataType: 'date',
|
||||
value: dayjs(yearTime)
|
||||
.startOf('year')
|
||||
.format('YYYY-MM-DD 00:00:00')
|
||||
value: dayjs(yearTime).startOf('year').format('YYYY-MM-DD 00:00:00')
|
||||
});
|
||||
requestParams.filters.push({
|
||||
field: 'endTime',
|
||||
@ -787,12 +924,14 @@ export const useMapStore = defineStore('map', () => {
|
||||
});
|
||||
|
||||
if (hasValidPointLayerCache(key, requestIdentifier)) {
|
||||
console.log(`图层 ${key} 命中有效缓存,跳过请求`);
|
||||
layer.data = getPointLayerData(key);
|
||||
layer.checked = 0;
|
||||
syncPointDataForFilter();
|
||||
if (!mapClass.hasLayer(key)) {
|
||||
console.log(`图层 ${key} 未在地图中,添加到地图(隐藏状态)`);
|
||||
mapClass.addInitDataLayer(getPointLayerData(key), key);
|
||||
mapClass.addInitDataLayer(
|
||||
filterPointLayerDataForDisplay(key, getPointLayerData(key)),
|
||||
key
|
||||
);
|
||||
mapClass.mdLayerTreeShowOrHidden(key, false);
|
||||
}
|
||||
return layer.data;
|
||||
@ -875,20 +1014,19 @@ export const useMapStore = defineStore('map', () => {
|
||||
data: list,
|
||||
cacheKey: requestIdentifier
|
||||
});
|
||||
syncPointDataForFilter();
|
||||
|
||||
layer.data = list;
|
||||
|
||||
if (list.length > 0) {
|
||||
console.log(
|
||||
`向地图添加图层 ${key}(${
|
||||
isLayerChecked ? '显示状态' : '隐藏状态'
|
||||
})`
|
||||
);
|
||||
mapClass.addInitDataLayer(list, key);
|
||||
const displayData = filterPointLayerDataForDisplay(key, list);
|
||||
mapClass.addInitDataLayer(displayData, key);
|
||||
if (isLayerChecked) {
|
||||
restoreLayerLegendVisibility(key);
|
||||
}
|
||||
mapClass.mdLayerTreeShowOrHidden(key, isLayerChecked);
|
||||
}
|
||||
|
||||
console.log(`图层 ${key} 数据加载完成,共 ${list.length} 条记录`);
|
||||
mapDataStore.setLayerLoaded(key);
|
||||
return list;
|
||||
} catch (error) {
|
||||
@ -936,7 +1074,6 @@ export const useMapStore = defineStore('map', () => {
|
||||
* 根据当前搜索时间范围重新加载描点数据
|
||||
*/
|
||||
const reloadBySearchTimeRange = async () => {
|
||||
console.log('searchTimeRange updated:', searchTimeRange.value);
|
||||
mapDataStore.setLoading(true);
|
||||
const currentSessionId = startLoadSession();
|
||||
|
||||
@ -952,7 +1089,6 @@ export const useMapStore = defineStore('map', () => {
|
||||
if (mapDataStore.getPointLayerCache(key)) {
|
||||
mapDataStore.removePointLayerCache(key);
|
||||
mapDataStore.clearLayerLoadState(key);
|
||||
console.log(`已清空图层 ${key} 的缓存`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -963,7 +1099,6 @@ export const useMapStore = defineStore('map', () => {
|
||||
for (const key of timeRangeLayerKeys) {
|
||||
const layerItem = findLayerByKey(layerData.value, key);
|
||||
if (layerItem && currentCheckedKeys.includes(key)) {
|
||||
console.log(`重新加载图层 ${key}`);
|
||||
await loadLayerData(layerItem, currentSessionId);
|
||||
}
|
||||
}
|
||||
@ -973,7 +1108,6 @@ export const useMapStore = defineStore('map', () => {
|
||||
}
|
||||
|
||||
const allPointData = mapDataStore.rebuildPointDataFromCache();
|
||||
console.log('锚点数据已更新,共', allPointData.length, '条');
|
||||
|
||||
// 更新地图锚点显示
|
||||
await updateLayerData(currentCheckedKeys, false);
|
||||
@ -1015,6 +1149,7 @@ export const useMapStore = defineStore('map', () => {
|
||||
getLegendItemsByLayerCode,
|
||||
getLayerBranchKeys,
|
||||
normalizeCheckedLayerKeys,
|
||||
refreshPointLayerDisplayData,
|
||||
searchTimeRange,
|
||||
reloadBySearchTimeRange,
|
||||
updateSearchTimeRange
|
||||
|
||||
@ -144,11 +144,11 @@ export const urlList = [
|
||||
title: '视频监控',
|
||||
params: { sttpCode: 'VD', lgtd: [{ field: 'lgtd', operator: 'isnotnull' }] }
|
||||
},
|
||||
{
|
||||
url: '/wmp-env-server/env/vd/aiPoint/GetKendoListCust',
|
||||
title: 'AI视频监控站',
|
||||
params: { sttpCode: 'AIVD' }
|
||||
},
|
||||
// {
|
||||
// url: '/wmp-env-server/env/vd/aiPoint/GetKendoListCust',
|
||||
// title: 'AI视频监控站',
|
||||
// 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_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' }] } },
|
||||
|
||||
@ -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.authorization =
|
||||
'bearer cb68557d-2861-497d-b708-6b73ae68ba95';
|
||||
'bearer 863782c5-5e51-49c6-95fd-13e6f25b9bf2';
|
||||
config.baseURL = '/';
|
||||
} else {
|
||||
const user = useUserStoreHook();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user