地图模块修改

This commit is contained in:
扈兆增 2026-07-01 08:48:39 +08:00
parent 0398be3d83
commit 3200f1f36f
27 changed files with 2373 additions and 1645 deletions

View File

@ -17,6 +17,9 @@
- 如果只是文字发生碰撞,希望点图标保留,仅文字隐藏或延后显示。
本文件先整理当前代码现状和问题成因,不直接修改实现。
如果需要查看**当前已经落地的抽吸规则、默认参数和调参入口**,请同时参考:
- `docs/地图模块-抽吸规则与参数说明.md`
## 2. 当前代码结构

View 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

View File

@ -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 />

View 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

View 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

View 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

View File

@ -1071,6 +1071,7 @@ watch(selectedMonth, val => {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.sidebar-pagination {

View File

@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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;
/**
*
*/

View File

@ -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 = {
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: 'declutter'
declutterMode: 'none'
})
};
})
);
styleOptions.text = new Text({
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, .9)', width: 2 }),
stroke: new Stroke({
color: 'rgba(0, 0, 0, 0.9)',
width: 2
}),
textAlign: 'center',
declutterMode: 'declutter'
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 new Style(styleOptions);
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
};
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();

View File

@ -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]]);
}
}

View File

@ -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;

View File

@ -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);
};
//

View File

@ -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 === '环保设施')

View File

@ -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({
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
};

View 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;
};

View File

@ -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,43 +142,68 @@ 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({
const layerRes = await getMapList({
systemId: options.systemId,
moduleId: options.moduleId,
description: options.description ?? 'true'
}),
getModuleMapLegendList(),
getModuleMapLegendList(
options.pageKey ? { moduleId: options.pageKey } : undefined
)
]);
});
const layerConfig = layerRes?.data?.mapLayerVos || [];
setLayerConfigTree(layerConfig);
return {
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 || [];
setLayerConfigTree(layerConfig);
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
};
} finally {
configLoading.value = false;
}
};
return {
@ -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
};
});

View File

@ -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;

View File

@ -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);
}
});
// 图例状态设置完成后再显示图层
restoreLayerLegendVisibility(key);
mapClass.mdLayerTreeShowOrHidden(key, true);
} else {
// 没有图例数据:直接显示图层
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;
}
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

View File

@ -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' }] } },

View File

@ -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);
};

View File

@ -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));

View File

@ -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();