2026-04-20 16:58:33 +08:00
|
|
|
|
import { MapInterface } from './map';
|
|
|
|
|
|
import OlMap from 'ol/Map';
|
|
|
|
|
|
import View from 'ol/View';
|
2026-06-03 15:03:31 +08:00
|
|
|
|
import Overlay from 'ol/Overlay';
|
2026-04-20 16:58:33 +08:00
|
|
|
|
import TileLayer from 'ol/layer/Tile';
|
|
|
|
|
|
import VectorLayer from 'ol/layer/Vector';
|
|
|
|
|
|
import VectorSource from 'ol/source/Vector';
|
|
|
|
|
|
import GeoJSON from 'ol/format/GeoJSON';
|
|
|
|
|
|
import Style from 'ol/style/Style';
|
|
|
|
|
|
import Fill from 'ol/style/Fill';
|
|
|
|
|
|
import Stroke from 'ol/style/Stroke';
|
|
|
|
|
|
import Icon from 'ol/style/Icon';
|
|
|
|
|
|
import Text from 'ol/style/Text';
|
|
|
|
|
|
import WMTS from 'ol/source/WMTS';
|
|
|
|
|
|
import WMTSTileGrid from 'ol/tilegrid/WMTS';
|
2026-04-22 17:53:20 +08:00
|
|
|
|
import { get as getProjection, fromLonLat } from 'ol/proj';
|
2026-04-20 16:58:33 +08:00
|
|
|
|
import {
|
|
|
|
|
|
defaults as defaultInteractions,
|
|
|
|
|
|
Draw,
|
2026-07-01 08:48:39 +08:00
|
|
|
|
DoubleClickZoom,
|
|
|
|
|
|
DragPan
|
2026-04-20 16:58:33 +08:00
|
|
|
|
} from 'ol/interaction';
|
|
|
|
|
|
import { getTopLeft, getWidth } from 'ol/extent';
|
|
|
|
|
|
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
|
|
|
|
|
|
import { servers } from './mapurlManage';
|
|
|
|
|
|
import { XYZ } from 'ol/source';
|
|
|
|
|
|
import Feature from 'ol/Feature';
|
|
|
|
|
|
import LineString from 'ol/geom/LineString';
|
2026-07-01 08:48:39 +08:00
|
|
|
|
import Point from 'ol/geom/Point';
|
2026-04-20 16:58:33 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getLength as getSphericalLength,
|
|
|
|
|
|
getArea as getSphericalArea
|
|
|
|
|
|
} from 'ol/sphere';
|
|
|
|
|
|
import { unByKey } from 'ol/Observable';
|
|
|
|
|
|
import { MDOptions } from './map.class';
|
2026-06-01 08:42:06 +08:00
|
|
|
|
import { useModelStore } from '@/store/modules/model';
|
2026-06-03 15:03:31 +08:00
|
|
|
|
import { PointLayerManager } from './ol/point-layer-manager';
|
|
|
|
|
|
import { PopupManager } from './ol/popup-manager';
|
|
|
|
|
|
import { RegionMaskManager } from './ol/region-mask-manager';
|
2026-07-01 08:48:39 +08:00
|
|
|
|
import { getNearbyPointDensityDisplayRules } from '@/modules/map/domain/nearby-point-rules';
|
2026-07-14 16:08:10 +08:00
|
|
|
|
import router from '@/router';
|
2026-06-01 08:42:06 +08:00
|
|
|
|
|
|
|
|
|
|
const modelStore = useModelStore();
|
2026-06-03 15:03:31 +08:00
|
|
|
|
const VITE_APP_MAP_URL = import.meta.env.VITE_APP_MAP_URL;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 定义与 leaflet 中相同的常量
|
|
|
|
|
|
const CENTER_positionCN = [114.17112499999996, 38]; // OpenLayers 使用 [lon, lat]
|
|
|
|
|
|
const MIN_ZOOM = 4.23;
|
|
|
|
|
|
const MAX_ZOOM = 22;
|
|
|
|
|
|
const INITIAL_ZOOM = 4.5;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const BATCH_POPUP_MODE_ZOOM = 15;
|
2026-07-10 17:56:16 +08:00
|
|
|
|
const FULL_DISPLAY_NO_COLLISION_ZOOM = Number.POSITIVE_INFINITY;
|
2026-08-05 17:12:38 +08:00
|
|
|
|
const POINT_FADE_DURATION = 300; // 锚点/文字碰撞显隐的淡入淡出时长(ms)
|
|
|
|
|
|
// 标签文字上方的避让边距(px):防止下方锚点文字紧贴上方锚点图标底部,看着像重叠
|
|
|
|
|
|
const LABEL_ICON_TOP_CLEARANCE = 6;
|
2026-08-06 15:57:46 +08:00
|
|
|
|
// 栖息地锚点(sttpMap=FH) 联动 fh_qxd 河段高亮:锚点 idss 字段值暂未确定,先写死为 233
|
|
|
|
|
|
const FH_ANCHOR_HIGHLIGHT_IDSS = 233;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 定义边界 [minX, minY, maxX, maxY] (Web Mercator 坐标)
|
|
|
|
|
|
const BOUNDS_SW = [26.5, -9.99999999999929];
|
|
|
|
|
|
const BOUNDS_NE = [180.00000000000074, 60.06349386538693];
|
|
|
|
|
|
|
|
|
|
|
|
export class MapOl implements MapInterface {
|
|
|
|
|
|
map: OlMap | null = null;
|
|
|
|
|
|
view: View | null = null;
|
|
|
|
|
|
private layerRegistry: Map<string, any> = new Map();
|
2026-07-01 08:48:39 +08:00
|
|
|
|
private iconLoadState = new Map<string, 'loading' | 'loaded' | 'error'>();
|
2026-08-06 15:57:46 +08:00
|
|
|
|
// 图标图片加载完成后缓存其原始尺寸,供 hover 命中框按实际图标大小计算
|
|
|
|
|
|
private iconSizeCache = new Map<string, { width: number; height: number }>();
|
2026-07-09 11:33:04 +08:00
|
|
|
|
private pointStyleCache = new Map<string, Style[]>();
|
2026-08-05 17:12:38 +08:00
|
|
|
|
private measureCanvas: HTMLCanvasElement | null = null;
|
|
|
|
|
|
private measureTextCache = new Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
{ width: number; height: number }
|
|
|
|
|
|
>();
|
|
|
|
|
|
private fadeRefreshFrameId: number | null = null;
|
|
|
|
|
|
private fadingFeatures = new Set<Feature>();
|
2026-06-03 15:03:31 +08:00
|
|
|
|
private baseLayerConfig: any | null = null;
|
2026-05-13 08:44:35 +08:00
|
|
|
|
private hydropBaseConfig: any | null = null;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
private REGISTRY_KEY = 'customBaseLayer';
|
2026-06-03 15:03:31 +08:00
|
|
|
|
private activeBaseLayerKey = 's_province_boundaries';
|
|
|
|
|
|
private readonly rasterBaseLayerKeys = ['BASEMAP-white', 'BASEMAP-img'];
|
2026-04-22 17:53:20 +08:00
|
|
|
|
private drawInteraction: any = null;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
private measureLayer: VectorLayer | null = null;
|
|
|
|
|
|
private measureSource: VectorSource | null = null;
|
2026-06-03 15:03:31 +08:00
|
|
|
|
private pointLayerManager: PointLayerManager;
|
2026-04-22 17:53:20 +08:00
|
|
|
|
geoJsonData: any = null;
|
|
|
|
|
|
geoJsonData1: any = null;
|
2026-06-01 08:42:06 +08:00
|
|
|
|
private BASEID = '';
|
2026-06-03 15:03:31 +08:00
|
|
|
|
private currentClipGeoJson: any | null = null;
|
2026-07-03 15:59:05 +08:00
|
|
|
|
private clipRequestController: AbortController | null = null;
|
2026-07-31 11:19:35 +08:00
|
|
|
|
private wfsHighlightLayer: VectorLayer | null = null;
|
2026-06-03 15:03:31 +08:00
|
|
|
|
private popupManager: PopupManager;
|
|
|
|
|
|
private regionMaskManager: RegionMaskManager;
|
2026-06-16 11:36:17 +08:00
|
|
|
|
private isBatchPopupMode = false;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
private batchPopupRefreshFrameId: number | null = null;
|
2026-07-10 17:56:16 +08:00
|
|
|
|
private batchPopupPendingRebuild = false;
|
|
|
|
|
|
private batchPopupPostRenderListenerKey: any = null;
|
2026-07-09 11:33:04 +08:00
|
|
|
|
private pointLayerStyleRefreshFrameId: number | null = null;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
private labelVisibilityRefreshFrameId: number | null = null;
|
2026-07-09 11:33:04 +08:00
|
|
|
|
private labelVisibilityDebounceTimerId: number | null = null;
|
2026-06-03 15:03:31 +08:00
|
|
|
|
constructor() {
|
|
|
|
|
|
this.pointLayerManager = new PointLayerManager({
|
|
|
|
|
|
map: null,
|
|
|
|
|
|
createStyle: feature => this.createPointStyle(feature)
|
|
|
|
|
|
});
|
|
|
|
|
|
this.popupManager = new PopupManager({
|
|
|
|
|
|
map: null,
|
|
|
|
|
|
getPointLayers: () => this.pointLayerManager.getLayers()
|
|
|
|
|
|
});
|
|
|
|
|
|
this.regionMaskManager = new RegionMaskManager({
|
|
|
|
|
|
map: null,
|
|
|
|
|
|
view: null,
|
|
|
|
|
|
pointLayerManager: this.pointLayerManager,
|
|
|
|
|
|
defaultCenter: CENTER_positionCN as [number, number],
|
|
|
|
|
|
defaultZoom: INITIAL_ZOOM
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-03 15:59:05 +08:00
|
|
|
|
private async loadGeoJsonData(url, signal?: AbortSignal): Promise<any> {
|
2026-05-13 08:44:35 +08:00
|
|
|
|
try {
|
2026-07-03 15:59:05 +08:00
|
|
|
|
const response = await fetch(url, { signal });
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
|
|
|
|
}
|
2026-05-13 08:44:35 +08:00
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
return data;
|
2026-07-03 15:59:05 +08:00
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
if (error.name !== 'AbortError') {
|
|
|
|
|
|
console.error('配置加载失败:', error);
|
|
|
|
|
|
}
|
2026-05-13 08:44:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
//地图初始化
|
2026-04-22 17:53:20 +08:00
|
|
|
|
init(container: HTMLElement): Promise<any> {
|
2026-04-20 16:58:33 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const minCoordinate = fromLonLat(BOUNDS_SW);
|
|
|
|
|
|
const maxCoordinate = fromLonLat(BOUNDS_NE);
|
|
|
|
|
|
const extent = [
|
|
|
|
|
|
minCoordinate[0],
|
|
|
|
|
|
minCoordinate[1],
|
|
|
|
|
|
maxCoordinate[0],
|
|
|
|
|
|
maxCoordinate[1]
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
this.view = new View({
|
|
|
|
|
|
center: fromLonLat(CENTER_positionCN), // 设置中心点
|
|
|
|
|
|
zoom: INITIAL_ZOOM,
|
|
|
|
|
|
minZoom: MIN_ZOOM,
|
|
|
|
|
|
maxZoom: MAX_ZOOM,
|
|
|
|
|
|
constrainOnlyCenter: false, // 允许部分视图超出边界,但中心点受限(类似 Leaflet 默认行为)
|
|
|
|
|
|
extent: extent, // 限制视图范围
|
|
|
|
|
|
smoothExtentConstraint: false // 平滑边界约束
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const mouseWheelInteraction = new MouseWheelZoom({
|
|
|
|
|
|
duration: 100, // 缩放动画持续时间 (ms),Leaflet 默认也有动画
|
2026-05-25 08:54:17 +08:00
|
|
|
|
maxDelta: 2,
|
2026-04-20 16:58:33 +08:00
|
|
|
|
constrainResolution: false // ✅ 关键:false 允许缩放到非整数级别 (如 4.5, 4.6),实现平滑缩放
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.map = new OlMap({
|
|
|
|
|
|
target: container,
|
|
|
|
|
|
layers: [],
|
|
|
|
|
|
view: this.view,
|
|
|
|
|
|
controls: [], // 对应 Leaflet 的 attributionControl: false, zoomControl: false
|
|
|
|
|
|
interactions: defaultInteractions({
|
|
|
|
|
|
doubleClickZoom: true,
|
2026-07-01 08:48:39 +08:00
|
|
|
|
dragPan: false,
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
pinchRotate: false // 通常禁用旋转,除非需要
|
2026-07-01 08:48:39 +08:00
|
|
|
|
}).extend([
|
|
|
|
|
|
new DragPan({
|
|
|
|
|
|
kinetic: undefined
|
|
|
|
|
|
}),
|
|
|
|
|
|
mouseWheelInteraction
|
|
|
|
|
|
])
|
2026-04-20 16:58:33 +08:00
|
|
|
|
});
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.pointLayerManager.setMap(this.map);
|
|
|
|
|
|
this.popupManager.setMap(this.map);
|
|
|
|
|
|
this.regionMaskManager.setContext(this.map, this.view);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-07-31 11:19:35 +08:00
|
|
|
|
// WFS 河段悬停高亮图层:与 demo 项目保持一致,命中要素复制到该图层以加深色描边高亮
|
|
|
|
|
|
this.wfsHighlightLayer = new VectorLayer({
|
|
|
|
|
|
source: new VectorSource(),
|
|
|
|
|
|
style: new Style({
|
|
|
|
|
|
stroke: new Stroke({
|
|
|
|
|
|
color: '#FFFF00',
|
|
|
|
|
|
width: 2
|
|
|
|
|
|
})
|
|
|
|
|
|
}),
|
|
|
|
|
|
zIndex: 20
|
|
|
|
|
|
});
|
|
|
|
|
|
this.map.addLayer(this.wfsHighlightLayer);
|
|
|
|
|
|
|
2026-06-16 11:36:17 +08:00
|
|
|
|
this.view.on('change:resolution', () => {
|
|
|
|
|
|
this.handleZoomChange();
|
|
|
|
|
|
});
|
2026-06-01 08:42:06 +08:00
|
|
|
|
this.map.on('click', evt => {
|
2026-07-14 16:08:10 +08:00
|
|
|
|
if (
|
|
|
|
|
|
router.currentRoute.value.path.includes(
|
|
|
|
|
|
'shengTaiLiuLiangManZuQingKuangJiangJu'
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-05 16:10:14 +08:00
|
|
|
|
this.popupManager.showPopup(undefined, undefined);
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.popupManager.handleMapClick(evt.pixel, detectedFeature => {
|
2026-08-06 15:57:46 +08:00
|
|
|
|
console.log(detectedFeature);
|
2026-06-15 09:18:00 +08:00
|
|
|
|
if (detectedFeature.values_.sttpMap == 'ylfb') {
|
|
|
|
|
|
modelStore.ylfbModalVisible = true;
|
|
|
|
|
|
modelStore.params = detectedFeature.values_;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
modelStore.modalVisible = true;
|
|
|
|
|
|
modelStore.params = detectedFeature.values_;
|
|
|
|
|
|
modelStore.title =
|
|
|
|
|
|
detectedFeature.values_.titleName ||
|
|
|
|
|
|
detectedFeature.values_.stnm + ' 详情信息';
|
|
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
});
|
2026-06-01 08:42:06 +08:00
|
|
|
|
});
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-06-01 08:42:06 +08:00
|
|
|
|
this.map.on('pointermove', evt => {
|
2026-07-31 11:19:35 +08:00
|
|
|
|
// fh_qxd 河段悬停高亮
|
|
|
|
|
|
const hitFeature = this.map.forEachFeatureAtPixel(
|
|
|
|
|
|
evt.pixel,
|
2026-08-06 15:57:46 +08:00
|
|
|
|
feature => feature,
|
2026-07-31 11:19:35 +08:00
|
|
|
|
{
|
|
|
|
|
|
hitTolerance: 0,
|
|
|
|
|
|
layerFilter: layer => layer.get('key') === 'fh_qxd'
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-06 15:57:46 +08:00
|
|
|
|
// 命中河段或锚点时直接切换鼠标样式(与 demo 一致,popup 是节流回调不能依赖它)
|
|
|
|
|
|
const targetElement = this.map?.getTargetElement() as HTMLElement;
|
|
|
|
|
|
let anchorDetect: {
|
|
|
|
|
|
detectedFeature?: Feature;
|
|
|
|
|
|
isHitIcon: boolean;
|
|
|
|
|
|
coordinate?: number[];
|
|
|
|
|
|
} = { isHitIcon: false };
|
|
|
|
|
|
if (targetElement) {
|
|
|
|
|
|
anchorDetect = this.detectFeatureAtPixel(evt.pixel);
|
|
|
|
|
|
targetElement.style.cursor =
|
|
|
|
|
|
anchorDetect.isHitIcon || hitFeature ? 'pointer' : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 第二种高亮方式:悬停栖息地锚点(sttpMap=FH)时,按其 idss 关联值联动高亮对应 fh_qxd 河段
|
|
|
|
|
|
let anchorLinkedFeature: Feature | undefined;
|
|
|
|
|
|
const hoveredAnchor = anchorDetect.isHitIcon
|
|
|
|
|
|
? anchorDetect.detectedFeature
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
// FH 锚点识别与 popup 的 getNormalizedPopupSttpMap 保持一致:优先用 sttpMap(_sttpMap/popupSttpMap)
|
|
|
|
|
|
const isFhAnchor =
|
|
|
|
|
|
hoveredAnchor &&
|
|
|
|
|
|
(String(
|
|
|
|
|
|
hoveredAnchor.get('_sttpMap') ||
|
|
|
|
|
|
hoveredAnchor.get('sttpMap') ||
|
|
|
|
|
|
hoveredAnchor.get('popupSttpMap') ||
|
|
|
|
|
|
''
|
|
|
|
|
|
).toUpperCase() === 'FH' ||
|
|
|
|
|
|
String(hoveredAnchor.get('sttp') ?? '').toUpperCase() === 'FH' ||
|
|
|
|
|
|
String(hoveredAnchor.get('sttpCode') ?? '').toUpperCase() === 'FH');
|
|
|
|
|
|
if (isFhAnchor) {
|
|
|
|
|
|
// 模拟获取锚点的 idss 字段:实际数据暂未提供该字段,先注释掉真实读取,写死固定值
|
|
|
|
|
|
// 后期字段确定后,只需把下面固定值替换为:hoveredAnchor.get('idss')
|
|
|
|
|
|
// const anchorIdss = hoveredAnchor.get('idss');
|
|
|
|
|
|
const anchorIdss = FH_ANCHOR_HIGHLIGHT_IDSS;
|
|
|
|
|
|
|
|
|
|
|
|
// 与直接悬停检测(forEachFeatureAtPixel 的 layerFilter)取同一图层来源,避免 registry 过期取不到图层
|
|
|
|
|
|
const fhQxdLayer = this.map
|
|
|
|
|
|
?.getLayers()
|
|
|
|
|
|
.getArray()
|
|
|
|
|
|
.find(layer => layer.get('key') === 'fh_qxd') as
|
|
|
|
|
|
| VectorLayer<VectorSource>
|
|
|
|
|
|
| undefined;
|
|
|
|
|
|
anchorLinkedFeature = fhQxdLayer
|
|
|
|
|
|
?.getSource()
|
|
|
|
|
|
?.getFeatures()
|
|
|
|
|
|
.find(f => String(f.get('Id')) === String(anchorIdss));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 高亮图层存在时,命中要素复制到高亮图层,未命中则清空(锚点联动高亮优先于直接悬停河段)
|
2026-07-31 11:19:35 +08:00
|
|
|
|
if (this.wfsHighlightLayer) {
|
|
|
|
|
|
const highlightSource = this.wfsHighlightLayer.getSource();
|
2026-08-06 15:57:46 +08:00
|
|
|
|
const highlightFeature = anchorLinkedFeature || hitFeature;
|
|
|
|
|
|
if (highlightFeature) {
|
2026-07-31 11:19:35 +08:00
|
|
|
|
this.wfsHighlightLayer.setStyle(
|
|
|
|
|
|
new Style({
|
|
|
|
|
|
stroke: new Stroke({
|
|
|
|
|
|
color: '#37ff00ff',
|
|
|
|
|
|
width: 4
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
highlightSource?.clear();
|
2026-08-06 15:57:46 +08:00
|
|
|
|
highlightSource?.addFeature(highlightFeature);
|
2026-07-31 11:19:35 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
highlightSource?.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.popupManager.handlePointerMove(evt.pixel, payload => {
|
|
|
|
|
|
if (targetElement) {
|
2026-07-31 11:19:35 +08:00
|
|
|
|
// 命中河段或 hover popup 命中锚点时显示 pointer 光标
|
|
|
|
|
|
targetElement.style.cursor =
|
2026-08-06 15:57:46 +08:00
|
|
|
|
anchorDetect.isHitIcon || payload.hoveredId || hitFeature
|
|
|
|
|
|
? 'pointer'
|
|
|
|
|
|
: '';
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
|
2026-08-05 17:12:38 +08:00
|
|
|
|
// 该锚点已有固定展示(批量 popup)的 popup,跳过 hover popup,避免同一内容出现双份;
|
|
|
|
|
|
// 未被固定展示的锚点(如碰撞被隐藏)仍可通过 hover 查看,且 hover popup 展示时会临时提升层级,不被固定展示遮挡
|
|
|
|
|
|
const fixedShown =
|
|
|
|
|
|
payload.detectedFeature?.get('_fixedPopupShown') === true;
|
|
|
|
|
|
this.showPopup(
|
|
|
|
|
|
fixedShown ? undefined : payload.detectedFeature,
|
|
|
|
|
|
fixedShown ? undefined : payload.coordinate
|
|
|
|
|
|
);
|
2026-05-15 17:38:03 +08:00
|
|
|
|
});
|
2026-04-20 16:58:33 +08:00
|
|
|
|
});
|
2026-06-16 11:36:17 +08:00
|
|
|
|
|
|
|
|
|
|
this.map.on('pointerdrag', () => {
|
|
|
|
|
|
if (this.isBatchPopupMode) {
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.ensureBatchPopupPostRenderSync();
|
2026-06-16 11:36:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
this.map.on('moveend', () => {
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.clearBatchPopupPostRenderSync();
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.requestRefreshPointLabelVisibility(false, 60);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
if (this.isBatchPopupMode) {
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.requestUpdateBatchPopups(false, true);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 16:58:33 +08:00
|
|
|
|
return Promise.resolve(this.map);
|
|
|
|
|
|
} catch (e) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
console.error('OL Init Error', e);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
return Promise.reject(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 初始化加载描点数据
|
|
|
|
|
|
* @param pointData 图层配置对象或数据数组
|
|
|
|
|
|
* @param layerType 图层类型/Key
|
|
|
|
|
|
* @param mdoptions 描点选项配置
|
|
|
|
|
|
*/
|
|
|
|
|
|
addInitDataLayer(
|
|
|
|
|
|
pointData: any,
|
|
|
|
|
|
layerType: any,
|
2026-06-03 15:03:31 +08:00
|
|
|
|
_mdoptions?: MDOptions
|
2026-04-20 16:58:33 +08:00
|
|
|
|
): void {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.pointLayerManager.addDataLayer(pointData, layerType);
|
2026-07-03 15:59:05 +08:00
|
|
|
|
|
|
|
|
|
|
if (this.currentClipGeoJson) {
|
|
|
|
|
|
this.regionMaskManager.filterPointsByRegion(this.currentClipGeoJson);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const currentZoom = this.view ? this.view.getZoom() : INITIAL_ZOOM;
|
|
|
|
|
|
this.pointLayerManager.updateNearbyFeatureLayout(currentZoom);
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.requestRefreshPointLabelVisibility(false, 80);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
2026-05-15 17:38:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 初始化 Popup Overlay
|
|
|
|
|
|
* @param container 父组件传入的 DOM 容器
|
|
|
|
|
|
*/
|
|
|
|
|
|
initPopupOverlay(container: HTMLElement) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.popupManager.initPopupOverlay(container);
|
2026-05-15 17:38:03 +08:00
|
|
|
|
}
|
2026-06-01 08:42:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 在指定像素位置检测要素(共享方法)
|
|
|
|
|
|
* @param pixel 像素坐标
|
|
|
|
|
|
* @returns 检测到的要素和是否命中图标区域
|
|
|
|
|
|
*/
|
|
|
|
|
|
private detectFeatureAtPixel(pixel: number[]): {
|
|
|
|
|
|
detectedFeature: Feature | undefined;
|
|
|
|
|
|
isHitIcon: boolean;
|
2026-06-03 15:03:31 +08:00
|
|
|
|
coordinate?: number[];
|
2026-06-01 08:42:06 +08:00
|
|
|
|
} {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
return this.popupManager.detectFeatureAtPixel(pixel);
|
2026-06-01 08:42:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 17:38:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 在 pointermove 中调用此方法显示/隐藏 Popup
|
|
|
|
|
|
* @param feature 当前悬停的要素
|
|
|
|
|
|
* @param coordinate 地理坐标
|
|
|
|
|
|
*/
|
|
|
|
|
|
showPopup(feature: Feature | undefined, coordinate: number[] | undefined) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.popupManager.showPopup(feature, coordinate);
|
2026-05-15 17:38:03 +08:00
|
|
|
|
}
|
2026-06-16 11:36:17 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-01 08:48:39 +08:00
|
|
|
|
* 监听缩放层级变化,到达指定层级时自动显示可视区域内所有锚点的 popup
|
2026-06-16 11:36:17 +08:00
|
|
|
|
*/
|
|
|
|
|
|
private handleZoomChange() {
|
|
|
|
|
|
if (!this.view) return;
|
|
|
|
|
|
|
|
|
|
|
|
const zoom = this.view.getZoom();
|
|
|
|
|
|
if (zoom === undefined) return;
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.pointLayerManager.updateNearbyFeatureLayout(zoom);
|
|
|
|
|
|
this.requestRefreshPointLabelVisibility();
|
|
|
|
|
|
|
|
|
|
|
|
if (zoom >= BATCH_POPUP_MODE_ZOOM) {
|
2026-06-16 11:36:17 +08:00
|
|
|
|
this.enableBatchPopupMode();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.disableBatchPopupMode();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 启用批量 popup 模式:自动显示可视区域内所有锚点的 popup
|
|
|
|
|
|
*/
|
|
|
|
|
|
private enableBatchPopupMode() {
|
|
|
|
|
|
this.isBatchPopupMode = true;
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.clearBatchPopupPostRenderSync();
|
2026-06-16 11:36:17 +08:00
|
|
|
|
this.popupManager.clearBatchPopups();
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.requestUpdateBatchPopups(true, true);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 禁用批量 popup 模式:恢复鼠标悬停显示 popup
|
|
|
|
|
|
*/
|
|
|
|
|
|
private disableBatchPopupMode() {
|
|
|
|
|
|
this.isBatchPopupMode = false;
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.batchPopupPendingRebuild = false;
|
|
|
|
|
|
this.clearBatchPopupPostRenderSync();
|
2026-07-01 08:48:39 +08:00
|
|
|
|
if (this.batchPopupRefreshFrameId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(this.batchPopupRefreshFrameId);
|
|
|
|
|
|
this.batchPopupRefreshFrameId = null;
|
|
|
|
|
|
}
|
2026-06-16 11:36:17 +08:00
|
|
|
|
this.popupManager.clearBatchPopups();
|
|
|
|
|
|
this.popupManager.showPopup(undefined, undefined);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 更新批量显示的 popups(拖动地图后调用)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private updateBatchPopups() {
|
|
|
|
|
|
const features = this.pointLayerManager.getFeaturesInViewport();
|
|
|
|
|
|
// 传入样式检查器,过滤掉 declutter/距离过滤等隐藏的锚点
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.popupManager.showPopupsForFeatures(
|
|
|
|
|
|
features,
|
|
|
|
|
|
feature => {
|
|
|
|
|
|
const style = this.createPointStyle(feature);
|
|
|
|
|
|
return style !== null;
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
rebuild: this.batchPopupPendingRebuild
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private ensureBatchPopupPostRenderSync() {
|
|
|
|
|
|
if (!this.map || this.batchPopupPostRenderListenerKey) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.batchPopupPostRenderListenerKey = this.map.on('postrender', () => {
|
|
|
|
|
|
if (!this.isBatchPopupMode) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.popupManager.updateBatchPopupPositions(feature => {
|
|
|
|
|
|
const style = this.createPointStyle(feature);
|
|
|
|
|
|
return style !== null;
|
|
|
|
|
|
});
|
2026-06-16 11:36:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-10 17:56:16 +08:00
|
|
|
|
|
|
|
|
|
|
private clearBatchPopupPostRenderSync() {
|
|
|
|
|
|
if (this.batchPopupPostRenderListenerKey) {
|
|
|
|
|
|
unByKey(this.batchPopupPostRenderListenerKey);
|
|
|
|
|
|
this.batchPopupPostRenderListenerKey = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 创建点样式 (模拟 Leaflet 的 DivIcon 效果,支持随缩放动态调整大小)
|
|
|
|
|
|
*/
|
2026-07-01 08:48:39 +08:00
|
|
|
|
private createPointStyle(feature: Feature): Style[] | null {
|
2026-04-20 16:58:33 +08:00
|
|
|
|
const iconUrl = feature.get('_iconUrl') as string;
|
|
|
|
|
|
const labelText = feature.get('_labelText') as string;
|
2026-06-01 08:42:06 +08:00
|
|
|
|
const legendVisible = feature.get('_legendVisible');
|
|
|
|
|
|
const regionVisible = feature.get('_regionVisible');
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
if (!iconUrl) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
return null;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
if (!this.ensureIconReady(iconUrl)) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
return null;
|
2026-06-01 08:42:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 15:57:46 +08:00
|
|
|
|
// 把图标原始尺寸写到要素上,供 PopupManager 按实际图标大小计算 hover 命中框
|
|
|
|
|
|
const iconSize = this.iconSizeCache.get(iconUrl);
|
|
|
|
|
|
if (iconSize) {
|
|
|
|
|
|
feature.set('_iconWidth', iconSize.width);
|
|
|
|
|
|
feature.set('_iconHeight', iconSize.height);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 17:45:09 +08:00
|
|
|
|
const currentZoom: any = this.view ? this.view.getZoom() : 4.5;
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const cachedDensityVisible = feature.get('_densityVisible');
|
|
|
|
|
|
const densityVisible =
|
|
|
|
|
|
typeof cachedDensityVisible === 'boolean'
|
|
|
|
|
|
? cachedDensityVisible
|
|
|
|
|
|
: currentZoom >= this.getFeatureDensityMinZoom(feature);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const iconTargetVisible =
|
|
|
|
|
|
legendVisible !== false &&
|
|
|
|
|
|
regionVisible !== false &&
|
2026-07-09 11:33:04 +08:00
|
|
|
|
densityVisible &&
|
2026-07-14 16:09:01 +08:00
|
|
|
|
this.shouldRenderNearbyFeature();
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
const dynamicScale = this.getDynamicPointScale(currentZoom);
|
|
|
|
|
|
const fontSize = this.getPointFontSize(dynamicScale);
|
2026-07-10 17:56:16 +08:00
|
|
|
|
const engPriority = this.getFeatureEngRenderPriority(feature);
|
2026-06-15 09:18:00 +08:00
|
|
|
|
const formattedLabelText = this.formatPointLabelText(labelText);
|
|
|
|
|
|
const labelLineCount = formattedLabelText
|
|
|
|
|
|
? formattedLabelText.split('\n').length
|
|
|
|
|
|
: 1;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const labelOffsetY = this.getPointLabelRenderOffsetY(
|
|
|
|
|
|
dynamicScale,
|
|
|
|
|
|
labelLineCount
|
|
|
|
|
|
);
|
|
|
|
|
|
const iconCollisionVisible = feature.get('_iconCollisionVisible') !== false;
|
|
|
|
|
|
const labelCollisionVisible =
|
|
|
|
|
|
feature.get('_labelCollisionVisible') === true;
|
2026-08-05 17:12:38 +08:00
|
|
|
|
|
|
|
|
|
|
// 淡入淡出 alpha(null = 未在淡变中)
|
|
|
|
|
|
const iconAlpha = this.getFadeAlpha(feature, 'icon');
|
|
|
|
|
|
const labelAlpha = this.getFadeAlpha(feature, 'label');
|
|
|
|
|
|
const isIconFading = iconAlpha !== null;
|
|
|
|
|
|
const isLabelFading = labelAlpha !== null;
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const finalIconVisible = iconTargetVisible && iconCollisionVisible;
|
2026-08-05 17:12:38 +08:00
|
|
|
|
// 碰撞隐藏时仍在淡出中则继续渲染,动画结束(alpha 归 0)后才会真正隐藏
|
|
|
|
|
|
const iconRendered = finalIconVisible || isIconFading;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
2026-08-05 17:12:38 +08:00
|
|
|
|
// 图例/区域/密度隐藏:不做淡出,直接隐藏
|
|
|
|
|
|
if (!iconTargetVisible) {
|
2026-07-01 08:48:39 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-08-05 17:12:38 +08:00
|
|
|
|
// 碰撞隐藏且不在淡出中:直接隐藏
|
|
|
|
|
|
if (!iconRendered) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const labelRenderVisible =
|
|
|
|
|
|
!this.isBatchPopupMode &&
|
|
|
|
|
|
iconRendered &&
|
|
|
|
|
|
!!formattedLabelText &&
|
|
|
|
|
|
(labelCollisionVisible || isLabelFading);
|
|
|
|
|
|
|
|
|
|
|
|
const iconRenderAlpha = iconAlpha ?? 1;
|
|
|
|
|
|
const labelRenderAlpha = labelAlpha ?? 1;
|
|
|
|
|
|
// 文字淡变跟随整锚点:取锚点与文字各自 alpha 的最小值
|
|
|
|
|
|
const effectiveLabelAlpha = Math.min(iconRenderAlpha, labelRenderAlpha);
|
|
|
|
|
|
const fading = isIconFading || isLabelFading;
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const styleCacheKey = [
|
|
|
|
|
|
iconUrl,
|
|
|
|
|
|
dynamicScale.toFixed(2),
|
|
|
|
|
|
fontSize,
|
2026-07-10 17:56:16 +08:00
|
|
|
|
engPriority,
|
2026-07-09 11:33:04 +08:00
|
|
|
|
labelOffsetY,
|
2026-08-05 17:12:38 +08:00
|
|
|
|
labelRenderVisible ? formattedLabelText : '',
|
|
|
|
|
|
labelRenderVisible ? 1 : 0,
|
|
|
|
|
|
fading ? 1 : 0
|
2026-07-09 11:33:04 +08:00
|
|
|
|
].join('|');
|
|
|
|
|
|
const cachedStyles = this.pointStyleCache.get(styleCacheKey);
|
|
|
|
|
|
if (cachedStyles) {
|
|
|
|
|
|
return cachedStyles;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const styles: Style[] = [
|
2026-07-01 08:48:39 +08:00
|
|
|
|
new Style({
|
2026-07-10 17:56:16 +08:00
|
|
|
|
zIndex: engPriority,
|
2026-07-01 08:48:39 +08:00
|
|
|
|
image: new Icon({
|
|
|
|
|
|
src: iconUrl,
|
|
|
|
|
|
scale: dynamicScale,
|
|
|
|
|
|
anchor: [0.5, 0.5],
|
|
|
|
|
|
crossOrigin: 'anonymous',
|
2026-08-05 17:12:38 +08:00
|
|
|
|
declutterMode: 'none',
|
|
|
|
|
|
opacity: iconRenderAlpha
|
2026-07-01 08:48:39 +08:00
|
|
|
|
})
|
2026-04-20 16:58:33 +08:00
|
|
|
|
})
|
2026-07-09 11:33:04 +08:00
|
|
|
|
];
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
2026-08-05 17:12:38 +08:00
|
|
|
|
if (formattedLabelText && labelRenderVisible) {
|
2026-07-01 08:48:39 +08:00
|
|
|
|
styles.push(
|
|
|
|
|
|
new Style({
|
2026-07-10 17:56:16 +08:00
|
|
|
|
zIndex: engPriority + 1,
|
2026-07-01 08:48:39 +08:00
|
|
|
|
text: new Text({
|
|
|
|
|
|
text: formattedLabelText,
|
|
|
|
|
|
offsetY: labelOffsetY,
|
|
|
|
|
|
font: `${fontSize}px sans-serif`,
|
2026-08-05 17:12:38 +08:00
|
|
|
|
fill: new Fill({
|
|
|
|
|
|
color: `rgba(255, 255, 255, ${effectiveLabelAlpha})`
|
|
|
|
|
|
}),
|
2026-07-01 08:48:39 +08:00
|
|
|
|
stroke: new Stroke({
|
2026-08-05 17:12:38 +08:00
|
|
|
|
color: `rgba(0, 0, 0, ${0.9 * effectiveLabelAlpha})`,
|
2026-07-01 08:48:39 +08:00
|
|
|
|
width: 2
|
|
|
|
|
|
}),
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
declutterMode: 'none'
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 17:12:38 +08:00
|
|
|
|
if (!fading) {
|
|
|
|
|
|
this.pointStyleCache.set(styleCacheKey, styles);
|
|
|
|
|
|
}
|
2026-07-01 08:48:39 +08:00
|
|
|
|
return styles;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
private markFeatureVisibilityDirty(
|
|
|
|
|
|
feature: Feature,
|
|
|
|
|
|
key: '_densityVisible' | '_iconCollisionVisible' | '_labelCollisionVisible',
|
|
|
|
|
|
visible: boolean,
|
|
|
|
|
|
dirtyLayerKeys?: Set<string>
|
|
|
|
|
|
) {
|
2026-08-05 17:12:38 +08:00
|
|
|
|
const prev = feature.get(key);
|
|
|
|
|
|
if (prev === visible) {
|
2026-07-09 11:33:04 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
feature.set(key, visible, true);
|
|
|
|
|
|
const layerKey = feature.get('_layerKey');
|
|
|
|
|
|
if (dirtyLayerKeys && layerKey) {
|
|
|
|
|
|
dirtyLayerKeys.add(String(layerKey));
|
|
|
|
|
|
}
|
2026-08-05 17:12:38 +08:00
|
|
|
|
|
|
|
|
|
|
// 碰撞显隐切换时启动淡入淡出(初始状态 prev 为 undefined 时不做动画)
|
|
|
|
|
|
if (key === '_iconCollisionVisible') {
|
|
|
|
|
|
if (prev === true && !visible) {
|
|
|
|
|
|
this.startFade(feature, 'out', 'icon');
|
|
|
|
|
|
} else if (prev === false && visible) {
|
|
|
|
|
|
this.startFade(feature, 'in', 'icon');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (key === '_labelCollisionVisible') {
|
|
|
|
|
|
if (prev === true && !visible) {
|
|
|
|
|
|
this.startFade(feature, 'out', 'label');
|
|
|
|
|
|
} else if (prev === false && visible) {
|
|
|
|
|
|
this.startFade(feature, 'in', 'label');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-09 11:33:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private flushDirtyPointLayers(dirtyLayerKeys: Set<string>) {
|
|
|
|
|
|
dirtyLayerKeys.forEach(layerKey => {
|
|
|
|
|
|
const layer = this.pointLayerManager.getLayer(layerKey);
|
|
|
|
|
|
layer?.changed();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 17:12:38 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 启动锚点(icon)或文字(label)的淡入/淡出动画。
|
|
|
|
|
|
* 对应需求:碰撞隐藏时不直接 show=false,而是打上淡出标记 + 起始时间,
|
|
|
|
|
|
* 由 createPointStyle(等价于 Cesium 的 alpha 回调)每帧计算 alpha 驱动淡出。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private startFade(
|
|
|
|
|
|
feature: Feature,
|
|
|
|
|
|
dir: 'in' | 'out',
|
|
|
|
|
|
target: 'icon' | 'label'
|
|
|
|
|
|
) {
|
|
|
|
|
|
const outFlag = target === 'icon' ? '_isFadingOut' : '_labelFadingOut';
|
|
|
|
|
|
const outStart =
|
|
|
|
|
|
target === 'icon' ? '_fadeStartTime' : '_labelFadeStartTime';
|
|
|
|
|
|
const inFlag = target === 'icon' ? '_isFadingIn' : '_labelFadingIn';
|
|
|
|
|
|
const inStart =
|
|
|
|
|
|
target === 'icon' ? '_fadeInStartTime' : '_labelFadeInStartTime';
|
|
|
|
|
|
|
|
|
|
|
|
if (dir === 'out') {
|
|
|
|
|
|
if (feature.get(outFlag)) return;
|
|
|
|
|
|
feature.set(outFlag, true, true);
|
|
|
|
|
|
feature.set(outStart, performance.now(), true);
|
|
|
|
|
|
// 若正在淡入则中断,改向淡出
|
|
|
|
|
|
feature.set(inFlag, false, true);
|
|
|
|
|
|
feature.set(inStart, 0, true);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (feature.get(inFlag)) return;
|
|
|
|
|
|
feature.set(inFlag, true, true);
|
|
|
|
|
|
feature.set(inStart, performance.now(), true);
|
|
|
|
|
|
// 若正在淡出则中断,改向淡入
|
|
|
|
|
|
feature.set(outFlag, false, true);
|
|
|
|
|
|
feature.set(outStart, 0, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.fadingFeatures.add(feature);
|
|
|
|
|
|
this.requestFadeRefresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 计算锚点/文字的当前 alpha。
|
|
|
|
|
|
* 淡出 alpha 1.0→0.0,淡入 0.0→1.0;未在淡变中返回 null。
|
|
|
|
|
|
* 动画结束分支:清除淡变标记(自动清理),并返回 null,
|
|
|
|
|
|
* 由调用方按碰撞标志决定最终显隐(等价于"结束时可安全置 show=false")。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getFadeAlpha(
|
|
|
|
|
|
feature: Feature,
|
|
|
|
|
|
target: 'icon' | 'label'
|
|
|
|
|
|
): number | null {
|
|
|
|
|
|
const outFlag = target === 'icon' ? '_isFadingOut' : '_labelFadingOut';
|
|
|
|
|
|
const outStart =
|
|
|
|
|
|
target === 'icon' ? '_fadeStartTime' : '_labelFadeStartTime';
|
|
|
|
|
|
const inFlag = target === 'icon' ? '_isFadingIn' : '_labelFadingIn';
|
|
|
|
|
|
const inStart =
|
|
|
|
|
|
target === 'icon' ? '_fadeInStartTime' : '_labelFadeInStartTime';
|
|
|
|
|
|
|
|
|
|
|
|
if (feature.get(outFlag)) {
|
|
|
|
|
|
const alpha = this.computeFadeAlpha(feature.get(outStart), false);
|
|
|
|
|
|
if (alpha === null) {
|
|
|
|
|
|
// 淡出结束:自动清理标记
|
|
|
|
|
|
feature.set(outFlag, false, true);
|
|
|
|
|
|
feature.set(outStart, 0, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
return alpha;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (feature.get(inFlag)) {
|
|
|
|
|
|
const alpha = this.computeFadeAlpha(feature.get(inStart), true);
|
|
|
|
|
|
if (alpha === null) {
|
|
|
|
|
|
// 淡入结束:自动清理标记
|
|
|
|
|
|
feature.set(inFlag, false, true);
|
|
|
|
|
|
feature.set(inStart, 0, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
return alpha;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 根据起始时间与当前时间计算 alpha;动画已结束返回 null */
|
|
|
|
|
|
private computeFadeAlpha(
|
|
|
|
|
|
startTime: unknown,
|
|
|
|
|
|
fadingIn: boolean
|
|
|
|
|
|
): number | null {
|
|
|
|
|
|
const start = Number(startTime) || 0;
|
|
|
|
|
|
const elapsed = performance.now() - start;
|
|
|
|
|
|
if (elapsed >= POINT_FADE_DURATION) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const progress = Math.max(0, elapsed / POINT_FADE_DURATION);
|
|
|
|
|
|
return fadingIn ? progress : 1 - progress;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 请求一次淡变重绘;已有循环在跑则忽略 */
|
|
|
|
|
|
private requestFadeRefresh() {
|
|
|
|
|
|
if (this.fadeRefreshFrameId !== null) return;
|
|
|
|
|
|
this.fadeRefreshFrameId = requestAnimationFrame(() =>
|
|
|
|
|
|
this.tickFadeRefresh()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 淡变驱动循环:每帧让所有点图层重绘一次(createPointStyle 会读取最新 alpha),
|
|
|
|
|
|
* 直到没有任何 feature 处于淡变中(此时已由 getFadeAlpha 清理标记,样式函数返回最终显隐)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private tickFadeRefresh() {
|
|
|
|
|
|
this.fadeRefreshFrameId = null;
|
|
|
|
|
|
const stillFading: Feature[] = [];
|
|
|
|
|
|
this.fadingFeatures.forEach(feature => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
this.getFadeAlpha(feature, 'icon') !== null ||
|
|
|
|
|
|
this.getFadeAlpha(feature, 'label') !== null
|
|
|
|
|
|
) {
|
|
|
|
|
|
stillFading.push(feature);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
this.fadingFeatures = new Set(stillFading);
|
|
|
|
|
|
this.pointLayerManager.forEachLayer(layer => layer.changed());
|
|
|
|
|
|
if (this.fadingFeatures.size > 0) {
|
|
|
|
|
|
this.fadeRefreshFrameId = requestAnimationFrame(() =>
|
|
|
|
|
|
this.tickFadeRefresh()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
private syncFeatureDensityVisible(
|
|
|
|
|
|
feature: Feature,
|
|
|
|
|
|
visible: boolean,
|
|
|
|
|
|
dirtyLayerKeys?: Set<string>
|
|
|
|
|
|
) {
|
|
|
|
|
|
this.markFeatureVisibilityDirty(
|
|
|
|
|
|
feature,
|
|
|
|
|
|
'_densityVisible',
|
|
|
|
|
|
visible,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private getFeaturePixelGridKey(
|
|
|
|
|
|
layerKey: string,
|
|
|
|
|
|
pixelX: number,
|
|
|
|
|
|
pixelY: number,
|
|
|
|
|
|
cellSize: number
|
|
|
|
|
|
) {
|
|
|
|
|
|
return `${layerKey}:${Math.floor(pixelX / cellSize)}:${Math.floor(
|
|
|
|
|
|
pixelY / cellSize
|
|
|
|
|
|
)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private hasNearbyViewportFeature(
|
|
|
|
|
|
target: {
|
|
|
|
|
|
feature: Feature;
|
|
|
|
|
|
layerKey: string;
|
|
|
|
|
|
pixelX: number;
|
|
|
|
|
|
pixelY: number;
|
|
|
|
|
|
},
|
|
|
|
|
|
pixelGrid: Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
Array<{
|
|
|
|
|
|
feature: Feature;
|
|
|
|
|
|
layerKey: string;
|
|
|
|
|
|
pixelX: number;
|
|
|
|
|
|
pixelY: number;
|
|
|
|
|
|
}>
|
|
|
|
|
|
>,
|
|
|
|
|
|
threshold: number
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
const cellX = Math.floor(target.pixelX / threshold);
|
|
|
|
|
|
const cellY = Math.floor(target.pixelY / threshold);
|
|
|
|
|
|
|
|
|
|
|
|
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
|
|
|
|
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
|
|
|
|
const bucket = pixelGrid.get(
|
|
|
|
|
|
`${target.layerKey}:${cellX + offsetX}:${cellY + offsetY}`
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!bucket?.length) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const candidate of bucket) {
|
|
|
|
|
|
if (candidate.feature === target.feature) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
Math.hypot(
|
|
|
|
|
|
candidate.pixelX - target.pixelX,
|
|
|
|
|
|
candidate.pixelY - target.pixelY
|
|
|
|
|
|
) <= threshold
|
|
|
|
|
|
) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private getCollisionGridKeys(
|
|
|
|
|
|
rect: { left: number; right: number; top: number; bottom: number },
|
|
|
|
|
|
cellSize: number
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const startX = Math.floor(rect.left / cellSize);
|
|
|
|
|
|
const endX = Math.floor(rect.right / cellSize);
|
|
|
|
|
|
const startY = Math.floor(rect.top / cellSize);
|
|
|
|
|
|
const endY = Math.floor(rect.bottom / cellSize);
|
|
|
|
|
|
const keys: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (let x = startX; x <= endX; x += 1) {
|
|
|
|
|
|
for (let y = startY; y <= endY; y += 1) {
|
|
|
|
|
|
keys.push(`${x}:${y}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return keys;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private addRectToCollisionGrid<
|
|
|
|
|
|
T extends {
|
|
|
|
|
|
left: number;
|
|
|
|
|
|
right: number;
|
|
|
|
|
|
top: number;
|
|
|
|
|
|
bottom: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
>(rect: T, grid: Map<string, T[]>, cellSize: number) {
|
|
|
|
|
|
this.getCollisionGridKeys(rect, cellSize).forEach(key => {
|
|
|
|
|
|
const bucket = grid.get(key) || [];
|
|
|
|
|
|
bucket.push(rect);
|
|
|
|
|
|
grid.set(key, bucket);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private findCollidingRectInGrid<
|
|
|
|
|
|
T extends {
|
|
|
|
|
|
left: number;
|
|
|
|
|
|
right: number;
|
|
|
|
|
|
top: number;
|
|
|
|
|
|
bottom: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
>(
|
|
|
|
|
|
rect: { left: number; right: number; top: number; bottom: number },
|
|
|
|
|
|
grid: Map<string, T[]>,
|
|
|
|
|
|
cellSize: number,
|
|
|
|
|
|
isCollision: (candidate: T) => boolean
|
|
|
|
|
|
): T | undefined {
|
|
|
|
|
|
for (const key of this.getCollisionGridKeys(rect, cellSize)) {
|
|
|
|
|
|
const bucket = grid.get(key);
|
|
|
|
|
|
if (!bucket?.length) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const candidate of bucket) {
|
|
|
|
|
|
if (isCollision(candidate)) {
|
|
|
|
|
|
return candidate;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
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;
|
2026-07-10 17:56:16 +08:00
|
|
|
|
engPriority: number;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
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;
|
2026-07-10 17:56:16 +08:00
|
|
|
|
engPriority: number;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
densityPriority: number;
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
pixelX: number;
|
|
|
|
|
|
pixelY: number;
|
|
|
|
|
|
}> = [];
|
|
|
|
|
|
|
|
|
|
|
|
const viewportFeatures = this.pointLayerManager.getFeaturesInViewport();
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const dirtyLayerKeys = new Set<string>();
|
|
|
|
|
|
const densityIsolationThreshold = 56;
|
|
|
|
|
|
const densityPixelGrid = new Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
Array<{
|
|
|
|
|
|
feature: Feature;
|
|
|
|
|
|
layerKey: string;
|
|
|
|
|
|
pixelX: number;
|
|
|
|
|
|
pixelY: number;
|
|
|
|
|
|
}>
|
|
|
|
|
|
>();
|
|
|
|
|
|
const viewportEntries: Array<{
|
|
|
|
|
|
feature: Feature;
|
|
|
|
|
|
layerKey: string;
|
|
|
|
|
|
iconUrl: string;
|
|
|
|
|
|
legendVisible: boolean;
|
|
|
|
|
|
regionVisible: boolean;
|
|
|
|
|
|
densityPriority: number;
|
|
|
|
|
|
nearbyVisible: boolean;
|
|
|
|
|
|
labelText: string;
|
|
|
|
|
|
pixelX?: number;
|
|
|
|
|
|
pixelY?: number;
|
|
|
|
|
|
}> = [];
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
viewportFeatures.forEach(feature => {
|
|
|
|
|
|
const geometry = feature.getGeometry();
|
|
|
|
|
|
const pixel =
|
|
|
|
|
|
geometry && geometry.getType() === 'Point'
|
|
|
|
|
|
? this.map?.getPixelFromCoordinate(
|
|
|
|
|
|
(geometry as Point).getCoordinates()
|
|
|
|
|
|
)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
const layerKey = String(feature.get('_layerKey') || '');
|
|
|
|
|
|
const entry = {
|
|
|
|
|
|
feature,
|
|
|
|
|
|
layerKey,
|
|
|
|
|
|
iconUrl: String(feature.get('_iconUrl') || ''),
|
|
|
|
|
|
legendVisible: feature.get('_legendVisible') !== false,
|
|
|
|
|
|
regionVisible: feature.get('_regionVisible') !== false,
|
|
|
|
|
|
densityPriority: this.getFeatureDensityPriority(feature),
|
2026-07-14 16:09:01 +08:00
|
|
|
|
nearbyVisible: this.shouldRenderNearbyFeature(),
|
2026-07-09 11:33:04 +08:00
|
|
|
|
labelText: this.formatPointLabelText(
|
2026-07-01 08:48:39 +08:00
|
|
|
|
feature.get('_labelText') as string
|
2026-07-09 11:33:04 +08:00
|
|
|
|
),
|
|
|
|
|
|
pixelX: pixel?.[0],
|
|
|
|
|
|
pixelY: pixel?.[1]
|
|
|
|
|
|
};
|
|
|
|
|
|
viewportEntries.push(entry);
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
pixel &&
|
|
|
|
|
|
entry.layerKey &&
|
|
|
|
|
|
entry.legendVisible &&
|
|
|
|
|
|
entry.regionVisible
|
|
|
|
|
|
) {
|
|
|
|
|
|
const gridKey = this.getFeaturePixelGridKey(
|
|
|
|
|
|
entry.layerKey,
|
|
|
|
|
|
pixel[0],
|
|
|
|
|
|
pixel[1],
|
|
|
|
|
|
densityIsolationThreshold
|
2026-07-01 08:48:39 +08:00
|
|
|
|
);
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const bucket = densityPixelGrid.get(gridKey) || [];
|
|
|
|
|
|
bucket.push({
|
|
|
|
|
|
feature,
|
|
|
|
|
|
layerKey: entry.layerKey,
|
|
|
|
|
|
pixelX: pixel[0],
|
|
|
|
|
|
pixelY: pixel[1]
|
|
|
|
|
|
});
|
|
|
|
|
|
densityPixelGrid.set(gridKey, bucket);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
if (currentZoom >= FULL_DISPLAY_NO_COLLISION_ZOOM) {
|
|
|
|
|
|
viewportEntries.forEach(entry => {
|
|
|
|
|
|
const minZoom = this.getFeatureDensityMinZoom(entry.feature);
|
|
|
|
|
|
const densityVisible =
|
|
|
|
|
|
currentZoom >= minZoom ||
|
|
|
|
|
|
(entry.legendVisible &&
|
|
|
|
|
|
entry.regionVisible &&
|
|
|
|
|
|
!!entry.layerKey &&
|
|
|
|
|
|
entry.pixelX !== undefined &&
|
|
|
|
|
|
entry.pixelY !== undefined &&
|
|
|
|
|
|
!this.hasNearbyViewportFeature(
|
|
|
|
|
|
{
|
|
|
|
|
|
feature: entry.feature,
|
|
|
|
|
|
layerKey: entry.layerKey,
|
|
|
|
|
|
pixelX: entry.pixelX,
|
|
|
|
|
|
pixelY: entry.pixelY
|
|
|
|
|
|
},
|
|
|
|
|
|
densityPixelGrid,
|
|
|
|
|
|
densityIsolationThreshold
|
|
|
|
|
|
));
|
|
|
|
|
|
this.syncFeatureDensityVisible(
|
|
|
|
|
|
entry.feature,
|
|
|
|
|
|
densityVisible,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
|
|
|
|
|
const shouldRenderIcon =
|
|
|
|
|
|
!!entry.iconUrl &&
|
|
|
|
|
|
this.isIconReady(entry.iconUrl) &&
|
|
|
|
|
|
entry.legendVisible &&
|
|
|
|
|
|
entry.regionVisible &&
|
|
|
|
|
|
densityVisible &&
|
|
|
|
|
|
entry.nearbyVisible;
|
|
|
|
|
|
const hasLabel = !!entry.labelText;
|
|
|
|
|
|
|
|
|
|
|
|
this.syncFeatureIconCollisionVisible(
|
|
|
|
|
|
entry.feature,
|
|
|
|
|
|
shouldRenderIcon,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.syncFeatureLabelCollisionVisible(
|
2026-07-09 11:33:04 +08:00
|
|
|
|
entry.feature,
|
|
|
|
|
|
shouldRenderIcon && hasLabel,
|
|
|
|
|
|
dirtyLayerKeys
|
2026-07-01 08:48:39 +08:00
|
|
|
|
);
|
|
|
|
|
|
});
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.flushDirtyPointLayers(dirtyLayerKeys);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
viewportEntries.forEach(entry => {
|
|
|
|
|
|
const minZoom = this.getFeatureDensityMinZoom(entry.feature);
|
|
|
|
|
|
const densityVisible =
|
|
|
|
|
|
currentZoom >= minZoom ||
|
|
|
|
|
|
(entry.legendVisible &&
|
|
|
|
|
|
entry.regionVisible &&
|
|
|
|
|
|
!!entry.layerKey &&
|
|
|
|
|
|
entry.pixelX !== undefined &&
|
|
|
|
|
|
entry.pixelY !== undefined &&
|
|
|
|
|
|
!this.hasNearbyViewportFeature(
|
|
|
|
|
|
{
|
|
|
|
|
|
feature: entry.feature,
|
|
|
|
|
|
layerKey: entry.layerKey,
|
|
|
|
|
|
pixelX: entry.pixelX,
|
|
|
|
|
|
pixelY: entry.pixelY
|
|
|
|
|
|
},
|
|
|
|
|
|
densityPixelGrid,
|
|
|
|
|
|
densityIsolationThreshold
|
|
|
|
|
|
));
|
|
|
|
|
|
this.syncFeatureDensityVisible(
|
|
|
|
|
|
entry.feature,
|
|
|
|
|
|
densityVisible,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const shouldRenderIcon =
|
2026-07-09 11:33:04 +08:00
|
|
|
|
!!entry.iconUrl &&
|
|
|
|
|
|
this.isIconReady(entry.iconUrl) &&
|
|
|
|
|
|
entry.legendVisible &&
|
|
|
|
|
|
entry.regionVisible &&
|
|
|
|
|
|
densityVisible &&
|
|
|
|
|
|
entry.nearbyVisible;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
if (!shouldRenderIcon) {
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.syncFeatureIconCollisionVisible(
|
|
|
|
|
|
entry.feature,
|
|
|
|
|
|
false,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
|
|
|
|
|
this.syncFeatureLabelCollisionVisible(
|
|
|
|
|
|
entry.feature,
|
|
|
|
|
|
false,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
if (entry.pixelX === undefined || entry.pixelY === undefined) {
|
|
|
|
|
|
this.syncFeatureIconCollisionVisible(
|
|
|
|
|
|
entry.feature,
|
|
|
|
|
|
false,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
|
|
|
|
|
this.syncFeatureLabelCollisionVisible(
|
|
|
|
|
|
entry.feature,
|
|
|
|
|
|
false,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const dynamicScale = this.getDynamicPointScale(currentZoom);
|
|
|
|
|
|
// 备注:图标碰撞盒适当小于视觉图标尺寸,允许相邻站点轻微贴近显示,
|
|
|
|
|
|
// 避免像“小浪底/三门峡”这类近点被过早裁掉成只剩一个点。
|
2026-07-10 17:56:16 +08:00
|
|
|
|
const iconCollisionSize = Math.max(14, 24 * dynamicScale);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const iconPadding = 0;
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const iconLeft = entry.pixelX - iconCollisionSize / 2 - iconPadding;
|
|
|
|
|
|
const iconRight = entry.pixelX + iconCollisionSize / 2 + iconPadding;
|
|
|
|
|
|
const iconTop = entry.pixelY - iconCollisionSize / 2 - iconPadding;
|
|
|
|
|
|
const iconBottom = entry.pixelY + iconCollisionSize / 2 + iconPadding;
|
|
|
|
|
|
const labelText = entry.labelText;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const fontSize = this.getPointFontSize(dynamicScale);
|
2026-08-05 17:12:38 +08:00
|
|
|
|
const labelLineCount = labelText ? labelText.split('\n').length : 1;
|
|
|
|
|
|
// 碰撞偏移与渲染偏移统一,保证碰撞块与实际渲染文字完全重合
|
|
|
|
|
|
const labelOffsetY = this.getPointLabelRenderOffsetY(
|
2026-07-01 08:48:39 +08:00
|
|
|
|
dynamicScale,
|
|
|
|
|
|
labelLineCount
|
|
|
|
|
|
);
|
2026-08-05 17:12:38 +08:00
|
|
|
|
// 碰撞块大小 = 文字实测像素大小(Canvas 2D measureText 精确测量,与 3D 对齐)
|
|
|
|
|
|
const measured = labelText
|
|
|
|
|
|
? this.measureLabelPixelSize(labelText, fontSize)
|
|
|
|
|
|
: { width: 0, height: 0 };
|
|
|
|
|
|
const labelWidth = measured.width;
|
|
|
|
|
|
const labelHeight = measured.height;
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const centerX = entry.pixelX;
|
|
|
|
|
|
const centerY = entry.pixelY + labelOffsetY;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const candidateId = String(
|
2026-07-09 11:33:04 +08:00
|
|
|
|
entry.feature.getId?.() || entry.feature.get('stcd') || ''
|
2026-07-01 08:48:39 +08:00
|
|
|
|
);
|
|
|
|
|
|
const dedupeKey =
|
|
|
|
|
|
candidateId ||
|
|
|
|
|
|
[
|
2026-07-09 11:33:04 +08:00
|
|
|
|
entry.feature.get('_layerKey') || '',
|
|
|
|
|
|
entry.pixelX.toFixed(2),
|
|
|
|
|
|
entry.pixelY.toFixed(2),
|
2026-07-01 08:48:39 +08:00
|
|
|
|
labelText
|
|
|
|
|
|
].join('|');
|
|
|
|
|
|
|
|
|
|
|
|
candidateMap.set(dedupeKey, {
|
2026-07-09 11:33:04 +08:00
|
|
|
|
feature: entry.feature,
|
2026-07-01 08:48:39 +08:00
|
|
|
|
iconLeft,
|
|
|
|
|
|
iconRight,
|
|
|
|
|
|
iconTop,
|
|
|
|
|
|
iconBottom,
|
|
|
|
|
|
hasLabel: !!labelText,
|
2026-08-05 17:12:38 +08:00
|
|
|
|
left: centerX - labelWidth / 2,
|
|
|
|
|
|
right: centerX + labelWidth / 2,
|
|
|
|
|
|
top: centerY - labelHeight / 2,
|
|
|
|
|
|
bottom: centerY + labelHeight / 2,
|
2026-07-09 11:33:04 +08:00
|
|
|
|
priority: Number(entry.feature.get('_nearbyPriority') || 9999),
|
2026-07-10 17:56:16 +08:00
|
|
|
|
engPriority: this.getFeatureEngRenderPriority(entry.feature),
|
2026-07-09 11:33:04 +08:00
|
|
|
|
densityPriority: entry.densityPriority,
|
2026-07-01 08:48:39 +08:00
|
|
|
|
id: candidateId,
|
2026-07-09 11:33:04 +08:00
|
|
|
|
pixelX: entry.pixelX,
|
|
|
|
|
|
pixelY: entry.pixelY
|
2026-07-01 08:48:39 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
candidateMap.forEach(candidate => {
|
|
|
|
|
|
candidates.push(candidate);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
candidates.sort((left, right) => {
|
2026-07-10 17:56:16 +08:00
|
|
|
|
if (left.engPriority !== right.engPriority) {
|
|
|
|
|
|
return right.engPriority - left.engPriority;
|
|
|
|
|
|
}
|
2026-07-01 08:48:39 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}> = [];
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const placedIconGrid = new Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
Array<{
|
|
|
|
|
|
featureId: string;
|
|
|
|
|
|
left: number;
|
|
|
|
|
|
right: number;
|
|
|
|
|
|
top: number;
|
|
|
|
|
|
bottom: number;
|
|
|
|
|
|
}>
|
|
|
|
|
|
>();
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const placedLabelRects: Array<{
|
|
|
|
|
|
featureId: string;
|
|
|
|
|
|
left: number;
|
|
|
|
|
|
right: number;
|
|
|
|
|
|
top: number;
|
|
|
|
|
|
bottom: number;
|
|
|
|
|
|
}> = [];
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const placedLabelGrid = new Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
Array<{
|
|
|
|
|
|
featureId: string;
|
|
|
|
|
|
left: number;
|
|
|
|
|
|
right: number;
|
|
|
|
|
|
top: number;
|
|
|
|
|
|
bottom: number;
|
|
|
|
|
|
}>
|
|
|
|
|
|
>();
|
|
|
|
|
|
const collisionGridCellSize = 96;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
};
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const iconHasCollision = !!this.findCollidingRectInGrid(
|
|
|
|
|
|
iconRect,
|
|
|
|
|
|
placedIconGrid,
|
|
|
|
|
|
collisionGridCellSize,
|
|
|
|
|
|
rect => this.checkLabelCollision(rect, iconRect)
|
2026-07-01 08:48:39 +08:00
|
|
|
|
);
|
|
|
|
|
|
const iconVisible = !iconHasCollision;
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.syncFeatureIconCollisionVisible(
|
|
|
|
|
|
candidate.feature,
|
|
|
|
|
|
iconVisible,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
if (iconVisible) {
|
|
|
|
|
|
placedIconRects.push({
|
|
|
|
|
|
featureId,
|
|
|
|
|
|
left: iconRect.left,
|
|
|
|
|
|
right: iconRect.right,
|
|
|
|
|
|
top: iconRect.top,
|
|
|
|
|
|
bottom: iconRect.bottom
|
|
|
|
|
|
});
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.addRectToCollisionGrid(
|
|
|
|
|
|
placedIconRects[placedIconRects.length - 1],
|
|
|
|
|
|
placedIconGrid,
|
|
|
|
|
|
collisionGridCellSize
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const labelCandidates = [...candidates].sort((left, right) => {
|
2026-07-10 17:56:16 +08:00
|
|
|
|
if (left.engPriority !== right.engPriority) {
|
|
|
|
|
|
return right.engPriority - left.engPriority;
|
|
|
|
|
|
}
|
2026-07-01 08:48:39 +08:00
|
|
|
|
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) {
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.syncFeatureLabelCollisionVisible(
|
|
|
|
|
|
candidate.feature,
|
|
|
|
|
|
false,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!iconVisible) {
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.syncFeatureLabelCollisionVisible(
|
|
|
|
|
|
candidate.feature,
|
|
|
|
|
|
false,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const collidingIconRect = this.findCollidingRectInGrid(
|
|
|
|
|
|
candidate,
|
|
|
|
|
|
placedIconGrid,
|
|
|
|
|
|
collisionGridCellSize,
|
|
|
|
|
|
rect =>
|
|
|
|
|
|
rect.featureId !== featureId &&
|
|
|
|
|
|
this.checkLabelCollisionWithIcon(rect, candidate)
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const hasCollisionWithIcon = !!collidingIconRect;
|
2026-07-09 11:33:04 +08:00
|
|
|
|
const collidingLabelRect = this.findCollidingRectInGrid(
|
|
|
|
|
|
candidate,
|
|
|
|
|
|
placedLabelGrid,
|
|
|
|
|
|
collisionGridCellSize,
|
|
|
|
|
|
rect => this.checkLabelCollision(rect, candidate)
|
2026-07-01 08:48:39 +08:00
|
|
|
|
);
|
|
|
|
|
|
const hasCollisionWithLabel = !!collidingLabelRect;
|
|
|
|
|
|
const visible = !hasCollisionWithIcon && !hasCollisionWithLabel;
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.syncFeatureLabelCollisionVisible(
|
|
|
|
|
|
candidate.feature,
|
|
|
|
|
|
visible,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
if (visible) {
|
|
|
|
|
|
placedLabelRects.push({
|
|
|
|
|
|
featureId,
|
|
|
|
|
|
left: candidate.left,
|
|
|
|
|
|
right: candidate.right,
|
|
|
|
|
|
top: candidate.top,
|
|
|
|
|
|
bottom: candidate.bottom
|
|
|
|
|
|
});
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.addRectToCollisionGrid(
|
|
|
|
|
|
placedLabelRects[placedLabelRects.length - 1],
|
|
|
|
|
|
placedLabelGrid,
|
|
|
|
|
|
collisionGridCellSize
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.flushDirtyPointLayers(dirtyLayerKeys);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
private requestRefreshPointLabelVisibility(
|
|
|
|
|
|
immediate = false,
|
|
|
|
|
|
debounceMs = 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (this.labelVisibilityDebounceTimerId !== null) {
|
|
|
|
|
|
window.clearTimeout(this.labelVisibilityDebounceTimerId);
|
|
|
|
|
|
this.labelVisibilityDebounceTimerId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
if (immediate) {
|
|
|
|
|
|
if (this.labelVisibilityRefreshFrameId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
|
|
|
|
|
|
this.labelVisibilityRefreshFrameId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.refreshPointLabelVisibility();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
if (debounceMs > 0) {
|
|
|
|
|
|
this.labelVisibilityDebounceTimerId = window.setTimeout(() => {
|
|
|
|
|
|
this.labelVisibilityDebounceTimerId = null;
|
|
|
|
|
|
this.requestRefreshPointLabelVisibility();
|
|
|
|
|
|
}, debounceMs);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
if (this.labelVisibilityRefreshFrameId !== null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.labelVisibilityRefreshFrameId = window.requestAnimationFrame(() => {
|
|
|
|
|
|
this.labelVisibilityRefreshFrameId = null;
|
|
|
|
|
|
this.refreshPointLabelVisibility();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
private syncFeatureIconCollisionVisible(
|
|
|
|
|
|
feature: Feature,
|
|
|
|
|
|
visible: boolean,
|
|
|
|
|
|
dirtyLayerKeys?: Set<string>
|
|
|
|
|
|
) {
|
|
|
|
|
|
this.markFeatureVisibilityDirty(
|
|
|
|
|
|
feature,
|
|
|
|
|
|
'_iconCollisionVisible',
|
|
|
|
|
|
visible,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
private syncFeatureLabelCollisionVisible(
|
|
|
|
|
|
feature: Feature,
|
|
|
|
|
|
visible: boolean,
|
|
|
|
|
|
dirtyLayerKeys?: Set<string>
|
|
|
|
|
|
) {
|
|
|
|
|
|
this.markFeatureVisibilityDirty(
|
|
|
|
|
|
feature,
|
|
|
|
|
|
'_labelCollisionVisible',
|
|
|
|
|
|
visible,
|
|
|
|
|
|
dirtyLayerKeys
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 = () => {
|
2026-08-06 15:57:46 +08:00
|
|
|
|
this.iconSizeCache.set(iconUrl, {
|
|
|
|
|
|
width: image.naturalWidth || 0,
|
|
|
|
|
|
height: image.naturalHeight || 0
|
|
|
|
|
|
});
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.iconLoadState.set(iconUrl, 'loaded');
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.requestRefreshPointLayerStyles();
|
2026-07-01 08:48:39 +08:00
|
|
|
|
};
|
|
|
|
|
|
image.onerror = () => {
|
|
|
|
|
|
this.iconLoadState.set(iconUrl, 'error');
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.requestRefreshPointLayerStyles();
|
2026-05-15 17:38:03 +08:00
|
|
|
|
};
|
2026-07-01 08:48:39 +08:00
|
|
|
|
image.src = iconUrl;
|
|
|
|
|
|
|
|
|
|
|
|
if (image.complete && image.naturalWidth > 0) {
|
2026-08-06 15:57:46 +08:00
|
|
|
|
this.iconSizeCache.set(iconUrl, {
|
|
|
|
|
|
width: image.naturalWidth,
|
|
|
|
|
|
height: image.naturalHeight
|
|
|
|
|
|
});
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.iconLoadState.set(iconUrl, 'loaded');
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-15 17:38:03 +08:00
|
|
|
|
|
2026-07-09 11:33:04 +08:00
|
|
|
|
private requestRefreshPointLayerStyles() {
|
|
|
|
|
|
if (this.pointLayerStyleRefreshFrameId !== null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.pointLayerStyleRefreshFrameId = window.requestAnimationFrame(() => {
|
|
|
|
|
|
this.pointLayerStyleRefreshFrameId = null;
|
|
|
|
|
|
this.refreshPointLayerStyles();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
private refreshPointLayerStyles() {
|
|
|
|
|
|
this.pointLayerManager.forEachLayer(layer => {
|
|
|
|
|
|
layer.changed();
|
2026-04-20 16:58:33 +08:00
|
|
|
|
});
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.requestRefreshPointLabelVisibility();
|
2026-07-01 08:48:39 +08:00
|
|
|
|
if (this.isBatchPopupMode) {
|
2026-07-09 11:33:04 +08:00
|
|
|
|
this.requestUpdateBatchPopups();
|
2026-07-01 08:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 17:56:16 +08:00
|
|
|
|
private requestUpdateBatchPopups(immediate = false, rebuild = true) {
|
2026-07-01 08:48:39 +08:00
|
|
|
|
if (!this.isBatchPopupMode) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.batchPopupPendingRebuild = this.batchPopupPendingRebuild || rebuild;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
if (immediate) {
|
|
|
|
|
|
if (this.batchPopupRefreshFrameId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(this.batchPopupRefreshFrameId);
|
|
|
|
|
|
this.batchPopupRefreshFrameId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.updateBatchPopups();
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.batchPopupPendingRebuild = false;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.batchPopupRefreshFrameId !== null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.batchPopupRefreshFrameId = window.requestAnimationFrame(() => {
|
|
|
|
|
|
this.batchPopupRefreshFrameId = null;
|
|
|
|
|
|
if (!this.isBatchPopupMode) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.updateBatchPopups();
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.batchPopupPendingRebuild = false;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 17:12:38 +08:00
|
|
|
|
/** 复用离屏 canvas 获取 2d context */
|
|
|
|
|
|
private getMeasureCtx(): CanvasRenderingContext2D | null {
|
|
|
|
|
|
if (!this.measureCanvas) {
|
|
|
|
|
|
this.measureCanvas = document.createElement('canvas');
|
|
|
|
|
|
this.measureCanvas.width = 1;
|
|
|
|
|
|
this.measureCanvas.height = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return this.measureCanvas.getContext('2d');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 用 Canvas 2D measureText 精确测量文字在屏幕上的像素宽度和高度(与 3D 实现对齐),
|
|
|
|
|
|
* 碰撞块的大小 = 文字实际大小。结果按 `text@roundedFontSize` 缓存,fontSize 随 zoom 变化时自动失效。
|
|
|
|
|
|
* 宽度优先取墨迹实测(actualBoundingBoxLeft/Right),避免 CJK 字体右留白导致块比文字宽。
|
|
|
|
|
|
* @param text 标签文字(支持多行 \n 分隔)
|
|
|
|
|
|
* @param fontSize 标签实际使用的像素字号
|
|
|
|
|
|
* @returns { width, height } 像素尺寸
|
|
|
|
|
|
*/
|
|
|
|
|
|
private measureLabelPixelSize(
|
|
|
|
|
|
text: string,
|
|
|
|
|
|
fontSize: number
|
|
|
|
|
|
): { width: number; height: number } {
|
|
|
|
|
|
const roundedFs = Math.round(fontSize);
|
|
|
|
|
|
const cacheKey = `${text}@${roundedFs}`;
|
|
|
|
|
|
const cached = this.measureTextCache.get(cacheKey);
|
|
|
|
|
|
if (cached) return cached;
|
|
|
|
|
|
|
|
|
|
|
|
const ctx = this.getMeasureCtx();
|
|
|
|
|
|
// fallback:canvas 不可用时用简单估算(极少发生)
|
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
|
const lines = text.split('\n');
|
|
|
|
|
|
const maxLen = Math.max(...lines.map(l => l.length), 1);
|
|
|
|
|
|
const fallback = {
|
|
|
|
|
|
width: maxLen * roundedFs + 20,
|
|
|
|
|
|
height: lines.length * (roundedFs + 4) + 8
|
|
|
|
|
|
};
|
|
|
|
|
|
this.measureTextCache.set(cacheKey, fallback);
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ctx.font = `${roundedFs}px sans-serif`;
|
|
|
|
|
|
const lines = text.split('\n');
|
|
|
|
|
|
|
|
|
|
|
|
let maxWidth = 0;
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
const metrics = ctx.measureText(line);
|
|
|
|
|
|
// 墨迹宽 = 左右墨迹边界之和;浏览器不支持 actualBoundingBox 时退回排版宽
|
|
|
|
|
|
const inkWidth =
|
|
|
|
|
|
metrics.actualBoundingBoxLeft !== undefined &&
|
|
|
|
|
|
metrics.actualBoundingBoxRight !== undefined
|
|
|
|
|
|
? metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight
|
|
|
|
|
|
: metrics.width;
|
|
|
|
|
|
maxWidth = Math.max(maxWidth, inkWidth);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 宽度 = 文字墨迹像素宽度(取多行中最宽的一行,无额外边距)
|
|
|
|
|
|
const width = maxWidth;
|
|
|
|
|
|
// 高度 = 行数 × 字号(与 3D 对齐,作为文字块的像素高度)
|
|
|
|
|
|
const height = lines.length * roundedFs;
|
|
|
|
|
|
|
|
|
|
|
|
const result = { width, height };
|
|
|
|
|
|
this.measureTextCache.set(cacheKey, result);
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
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
|
|
|
|
|
|
};
|
2026-08-05 17:12:38 +08:00
|
|
|
|
// 标签块向上扩展避让边距(等价于给文字加"上边距"),
|
|
|
|
|
|
// 让下方锚点文字不再紧贴上方锚点图标底部(含文字描边/图标视觉边缘)看着像重叠
|
|
|
|
|
|
const extendedLabelRect = {
|
|
|
|
|
|
left: labelRect.left,
|
|
|
|
|
|
right: labelRect.right,
|
|
|
|
|
|
top: labelRect.top - LABEL_ICON_TOP_CLEARANCE,
|
|
|
|
|
|
bottom: labelRect.bottom
|
|
|
|
|
|
};
|
2026-07-01 08:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
return !(
|
2026-08-05 17:12:38 +08:00
|
|
|
|
narrowedIconRect.right + padding <= extendedLabelRect.left ||
|
|
|
|
|
|
narrowedIconRect.left - padding >= extendedLabelRect.right ||
|
|
|
|
|
|
narrowedIconRect.bottom + padding <= extendedLabelRect.top ||
|
|
|
|
|
|
narrowedIconRect.top - padding >= extendedLabelRect.bottom
|
2026-07-01 08:48:39 +08:00
|
|
|
|
);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
|
2026-06-15 09:18:00 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 点位标注最多显示两行,每行 12 个字符;超出部分在第二行末尾显示省略号。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private formatPointLabelText(text: string): string {
|
|
|
|
|
|
const normalizedText = String(text || '')
|
|
|
|
|
|
.replace(/[()()]/g, '')
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
if (!normalizedText) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const maxLineLength = 12;
|
|
|
|
|
|
if (normalizedText.length <= maxLineLength) {
|
|
|
|
|
|
return normalizedText;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const firstLine = normalizedText.slice(0, maxLineLength);
|
|
|
|
|
|
if (normalizedText.length <= maxLineLength * 2) {
|
|
|
|
|
|
return `${firstLine}\n${normalizedText.slice(maxLineLength)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const secondLine = `${normalizedText.slice(
|
|
|
|
|
|
maxLineLength,
|
|
|
|
|
|
maxLineLength + 9
|
|
|
|
|
|
)}...`;
|
|
|
|
|
|
return `${firstLine}\n${secondLine}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 根据缩放级别计算锚点图标缩放比例,统一复用,减少样式函数中的重复判断。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getDynamicPointScale(currentZoom: number): number {
|
|
|
|
|
|
let dynamicScale = 0.7 + (currentZoom - 4.5) * 0.08;
|
|
|
|
|
|
dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale));
|
|
|
|
|
|
return dynamicScale;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 根据图标缩放比例统一计算文字字号,避免样式函数中散落重复计算。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getPointFontSize(dynamicScale: number): number {
|
|
|
|
|
|
return Math.max(10, Math.min(24, 12 * dynamicScale));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-01 08:48:39 +08:00
|
|
|
|
* 统一计算标签渲染时的纵向偏移,保持视觉位置与原效果一致。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getPointLabelRenderOffsetY(
|
|
|
|
|
|
dynamicScale: number,
|
|
|
|
|
|
labelLineCount: number
|
|
|
|
|
|
): number {
|
|
|
|
|
|
return labelLineCount > 1 ? -30 * dynamicScale : -22 * 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 17:56:16 +08:00
|
|
|
|
private isFeatureEng(feature: Feature): boolean {
|
|
|
|
|
|
const layerKey = String(feature.get('_layerKey') || '').toLowerCase();
|
|
|
|
|
|
const sttpMap = String(
|
|
|
|
|
|
feature.get('_sttpMap') || feature.get('sttpMap') || ''
|
|
|
|
|
|
).toUpperCase();
|
|
|
|
|
|
const sttpCode = String(
|
|
|
|
|
|
feature.get('sttpCode') || feature.get('sttp') || ''
|
|
|
|
|
|
).toUpperCase();
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
layerKey.includes('eng_point') ||
|
|
|
|
|
|
sttpMap === 'ENG' ||
|
|
|
|
|
|
sttpMap === 'ENG2' ||
|
|
|
|
|
|
sttpCode === 'ENG'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 16:09:01 +08:00
|
|
|
|
private isFeatureEngAlarm(feature: Feature): boolean {
|
|
|
|
|
|
const layerKey = String(
|
|
|
|
|
|
feature.get('_layerKey') || feature.get('type') || ''
|
|
|
|
|
|
).toLowerCase();
|
|
|
|
|
|
const legendState = String(feature.get('anchoPointState') || '')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
layerKey.includes('eng_alarm_point') ||
|
|
|
|
|
|
layerKey.includes('alarm_range') ||
|
|
|
|
|
|
legendState.startsWith('alarm_range_') ||
|
|
|
|
|
|
legendState.startsWith('large_eng_built_alarm_range_') ||
|
|
|
|
|
|
legendState.startsWith('mid_eng_built_alarm_range_')
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 17:56:16 +08:00
|
|
|
|
private getFeatureEngRenderPriority(feature: Feature): number {
|
2026-07-14 16:09:01 +08:00
|
|
|
|
if (this.isFeatureEngAlarm(feature)) {
|
|
|
|
|
|
return 300;
|
|
|
|
|
|
}
|
2026-07-10 17:56:16 +08:00
|
|
|
|
return this.isFeatureEng(feature) ? 200 : 100;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 如果点位在当前视图中没有近邻点,则允许跳过静态密度门槛直接参与显示。
|
|
|
|
|
|
* 这样像“戈兰滩”这类 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 minZoom = this.getFeatureDensityMinZoom(feature);
|
|
|
|
|
|
const bypassDensityGate =
|
|
|
|
|
|
currentZoom < minZoom &&
|
|
|
|
|
|
this.shouldBypassDensityGateForIsolatedFeature(feature);
|
|
|
|
|
|
return currentZoom >= minZoom || bypassDensityGate;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-14 16:09:01 +08:00
|
|
|
|
* 近邻点统一参与原位堆叠,具体谁显示在顶层交给碰撞排序和图层优先级控制。
|
2026-07-01 08:48:39 +08:00
|
|
|
|
*/
|
2026-07-14 16:09:01 +08:00
|
|
|
|
private shouldRenderNearbyFeature(): boolean {
|
|
|
|
|
|
return true;
|
2026-06-03 15:03:31 +08:00
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 初始化加载基础图层
|
|
|
|
|
|
* @param layer 图层配置对象
|
|
|
|
|
|
*/
|
2026-07-03 15:59:05 +08:00
|
|
|
|
addBaseDataLayer(layer: any, isShow: boolean, isCache = false): void {
|
2026-04-20 16:58:33 +08:00
|
|
|
|
if (!this.map) return;
|
|
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
// 备注:同 key 的底图重复添加前先移除旧实例,避免注册表覆盖后旧图层仍残留在地图上。
|
|
|
|
|
|
const existingLayer = layer?.key
|
|
|
|
|
|
? this.layerRegistry.get(layer.key as string)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
if (existingLayer) {
|
|
|
|
|
|
this.map.removeLayer(existingLayer);
|
|
|
|
|
|
this.layerRegistry.delete(layer.key);
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
if (layer.type === 'wmts') {
|
|
|
|
|
|
if (!layer.url) return;
|
2026-07-03 15:59:05 +08:00
|
|
|
|
const url = !isCache ? layer.url : layer.url_3d;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
const urlParams = new URLSearchParams(url.split('?')[1]);
|
|
|
|
|
|
if (layer.key === this.REGISTRY_KEY) {
|
|
|
|
|
|
this.baseLayerConfig = layer;
|
|
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
const layerName: string = urlParams.get('LAYER') || '';
|
|
|
|
|
|
const matrixSetName: any = urlParams.get('TILEMATRIXSET');
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
const projection = getProjection('EPSG:3857');
|
|
|
|
|
|
if (!projection) {
|
|
|
|
|
|
console.error('无法获取 EPSG:3857 投影');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const projectionExtent = projection.getExtent();
|
|
|
|
|
|
|
|
|
|
|
|
const size = getWidth(projectionExtent) / 256;
|
2026-06-03 15:03:31 +08:00
|
|
|
|
const maxZoom = 13;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
const resolutions = new Array(maxZoom + 1);
|
|
|
|
|
|
const matrixIds = new Array(maxZoom + 1);
|
|
|
|
|
|
|
|
|
|
|
|
for (let z = 0; z <= maxZoom; ++z) {
|
|
|
|
|
|
resolutions[z] = size / Math.pow(2, z);
|
|
|
|
|
|
matrixIds[z] = `${matrixSetName}:${z}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const wmtsLayer = new TileLayer({
|
|
|
|
|
|
source: new WMTS({
|
2026-06-03 15:03:31 +08:00
|
|
|
|
url: layer.url.split('?')[0],
|
2026-04-20 16:58:33 +08:00
|
|
|
|
layer: layerName,
|
|
|
|
|
|
matrixSet: matrixSetName,
|
|
|
|
|
|
format: 'image/png',
|
2026-06-03 15:03:31 +08:00
|
|
|
|
projection: projection,
|
2026-04-20 16:58:33 +08:00
|
|
|
|
tileGrid: new WMTSTileGrid({
|
2026-06-03 15:03:31 +08:00
|
|
|
|
origin: getTopLeft(projectionExtent),
|
2026-04-20 16:58:33 +08:00
|
|
|
|
resolutions: resolutions,
|
2026-06-03 15:03:31 +08:00
|
|
|
|
matrixIds: matrixIds
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}),
|
|
|
|
|
|
style: 'default',
|
2026-06-03 15:03:31 +08:00
|
|
|
|
wrapX: true,
|
|
|
|
|
|
crossOrigin: 'anonymous'
|
2026-04-20 16:58:33 +08:00
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (layer.key === this.REGISTRY_KEY) {
|
|
|
|
|
|
!isCache ? wmtsLayer.setZIndex(-100) : wmtsLayer.setZIndex(-99);
|
|
|
|
|
|
} else {
|
2026-06-01 08:42:06 +08:00
|
|
|
|
wmtsLayer.setZIndex(-100);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
2026-07-31 11:19:35 +08:00
|
|
|
|
wmtsLayer.set('key', layer.key);
|
2026-06-01 08:42:06 +08:00
|
|
|
|
wmtsLayer.type = 'layer';
|
|
|
|
|
|
wmtsLayer.setVisible(isShow);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
this.layerRegistry.set(layer.key, wmtsLayer);
|
|
|
|
|
|
this.map.addLayer(wmtsLayer);
|
|
|
|
|
|
layer._layer = wmtsLayer;
|
2026-07-31 11:19:35 +08:00
|
|
|
|
} else if (layer.type === 'wfs') {
|
|
|
|
|
|
if (!this.view) return;
|
|
|
|
|
|
// 先加载数据,在原始 GeoJSON 层面对坐标做 4326 有效性筛选,删除混入的异常投影数据
|
|
|
|
|
|
const isCoordIn4326Range = (lon: number, lat: number) =>
|
|
|
|
|
|
lon >= -180 && lon <= 180 && lat >= -90 && lat <= 90;
|
|
|
|
|
|
const isValidGeoJsonFeature = (feature: any): boolean => {
|
|
|
|
|
|
const geom = feature?.geometry;
|
|
|
|
|
|
if (!geom) return false;
|
|
|
|
|
|
const flat = geom.coordinates.flat(Infinity) as number[];
|
|
|
|
|
|
for (let i = 0; i < flat.length; i += 2) {
|
|
|
|
|
|
if (!isCoordIn4326Range(flat[i], flat[i + 1])) return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
const vectorSource = new VectorSource();
|
|
|
|
|
|
// 先加载数据,过滤异常坐标,再创建图层添加到地图
|
|
|
|
|
|
fetch(layer.url)
|
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
|
.then(data => {
|
|
|
|
|
|
data.features = (data.features || []).filter(isValidGeoJsonFeature);
|
|
|
|
|
|
const features = new GeoJSON().readFeatures(data, {
|
|
|
|
|
|
// dataProjection: 'EPSG:4326',
|
|
|
|
|
|
featureProjection: 'EPSG:3857' // 确保转换到地图使用的投影
|
|
|
|
|
|
});
|
|
|
|
|
|
vectorSource.addFeatures(features);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(err => console.error(`WFS 图层加载失败 [${layer.key}]:`, err));
|
|
|
|
|
|
const wfsLayer = new VectorLayer({
|
|
|
|
|
|
source: vectorSource,
|
|
|
|
|
|
style: new Style({
|
|
|
|
|
|
stroke: new Stroke({
|
|
|
|
|
|
color: '#038303',
|
|
|
|
|
|
width: 3
|
|
|
|
|
|
})
|
|
|
|
|
|
}),
|
|
|
|
|
|
zIndex: -98,
|
|
|
|
|
|
visible: layer.visible !== false
|
|
|
|
|
|
});
|
|
|
|
|
|
wfsLayer.set('bjColor', layer.color);
|
|
|
|
|
|
wfsLayer.set('key', layer.key);
|
|
|
|
|
|
wfsLayer.setVisible(isShow);
|
|
|
|
|
|
this.layerRegistry.set(layer.key, wfsLayer);
|
|
|
|
|
|
this.map.addLayer(wfsLayer);
|
|
|
|
|
|
layer._layer = wfsLayer;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
} else if (layer.type == 'raster-dem') {
|
|
|
|
|
|
const tileLayer = new TileLayer({
|
|
|
|
|
|
source: new XYZ({
|
|
|
|
|
|
url: layer.url,
|
2026-07-31 11:19:35 +08:00
|
|
|
|
wrapX: false,
|
2026-04-20 16:58:33 +08:00
|
|
|
|
crossOrigin: 'anonymous'
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
2026-07-31 11:19:35 +08:00
|
|
|
|
|
|
|
|
|
|
tileLayer.set('key', layer.key);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
tileLayer.setZIndex(-100);
|
2026-06-01 08:42:06 +08:00
|
|
|
|
tileLayer.setVisible(isShow);
|
|
|
|
|
|
tileLayer.type = 'layer';
|
|
|
|
|
|
this.layerRegistry.set(layer.key, tileLayer);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
this.map.addLayer(tileLayer);
|
|
|
|
|
|
} else if (layer.type === 'vector') {
|
|
|
|
|
|
if (layer.key === 'hydropBase') {
|
2026-05-13 08:44:35 +08:00
|
|
|
|
this.hydropBaseConfig = layer;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 根据 key 设置底图图层显隐
|
|
|
|
|
|
* @param layerKey 图层 key
|
|
|
|
|
|
* @param visible 是否显示
|
|
|
|
|
|
*/
|
|
|
|
|
|
private setBaseLayerVisible(layerKey: string, visible: boolean): void {
|
|
|
|
|
|
const layer = this.layerRegistry.get(layerKey);
|
|
|
|
|
|
if (layer) {
|
|
|
|
|
|
layer.setVisible(visible);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 同步当前激活底图源的显隐,保证树勾选和下方图源切换使用同一套可见性规则
|
|
|
|
|
|
* @param checked 是否显示当前激活底图
|
|
|
|
|
|
*/
|
|
|
|
|
|
private syncActiveBaseLayerVisibility(checked: boolean): void {
|
|
|
|
|
|
this.setBaseLayerVisible(this.REGISTRY_KEY, checked);
|
|
|
|
|
|
|
|
|
|
|
|
this.rasterBaseLayerKeys.forEach(key => {
|
|
|
|
|
|
const shouldShow =
|
|
|
|
|
|
checked &&
|
|
|
|
|
|
this.activeBaseLayerKey !== 's_province_boundaries' &&
|
|
|
|
|
|
key === this.activeBaseLayerKey;
|
|
|
|
|
|
this.setBaseLayerVisible(key, shouldShow);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取当前应应用基地裁切的底图图层集合
|
|
|
|
|
|
* @returns 当前可见底图图层列表
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getCurrentMaskTargetLayers(): TileLayer[] {
|
|
|
|
|
|
const layers: TileLayer[] = [];
|
|
|
|
|
|
const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY);
|
|
|
|
|
|
|
|
|
|
|
|
if (baseLayer instanceof TileLayer) {
|
|
|
|
|
|
layers.push(baseLayer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.activeBaseLayerKey !== 's_province_boundaries') {
|
|
|
|
|
|
const activeRasterLayer = this.layerRegistry.get(this.activeBaseLayerKey);
|
|
|
|
|
|
if (
|
|
|
|
|
|
activeRasterLayer instanceof TileLayer &&
|
|
|
|
|
|
activeRasterLayer !== baseLayer
|
|
|
|
|
|
) {
|
|
|
|
|
|
layers.push(activeRasterLayer);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return layers;
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 重新把当前基地裁切绑定到最新底图实例,保证切换图源后边界裁切持续生效
|
|
|
|
|
|
*/
|
|
|
|
|
|
private reapplyCurrentBaseMask(): void {
|
|
|
|
|
|
if (!this.currentClipGeoJson) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const targetLayers = this.getCurrentMaskTargetLayers();
|
|
|
|
|
|
if (targetLayers.length === 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.regionMaskManager.applyMapMaskToLayers(
|
|
|
|
|
|
targetLayers,
|
|
|
|
|
|
this.currentClipGeoJson
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-11 17:45:09 +08:00
|
|
|
|
enableNortheastMask(): void {
|
|
|
|
|
|
const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY);
|
|
|
|
|
|
|
|
|
|
|
|
if (!this.geoJsonData1) {
|
|
|
|
|
|
console.warn('东北边界数据尚未加载');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (baseLayer && baseLayer instanceof TileLayer) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.regionMaskManager.applyMapMask(baseLayer, this.geoJsonData1);
|
2026-05-11 17:45:09 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
console.warn('未找到全量底图图层或图层类型错误');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 控制特定区域图层的显示(互斥显示)
|
|
|
|
|
|
* @param regionId 区域ID (例如 "hebei", "13", "01" 等,需与图层 Key 或属性对应)
|
|
|
|
|
|
* @param isAll 是否显示所有 (true: 显示所有图层; false: 仅显示匹配 regionId 的图层,隐藏其他)
|
|
|
|
|
|
*/
|
2026-05-13 08:44:35 +08:00
|
|
|
|
async jdPanelControlShowAndHidden(
|
|
|
|
|
|
_regionId: string,
|
|
|
|
|
|
isAll: boolean
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
if (!this.map || !this.hydropBaseConfig) {
|
|
|
|
|
|
console.warn('地图未初始化或 hydropBaseConfig 未配置');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 15:59:05 +08:00
|
|
|
|
if (this.clipRequestController) {
|
|
|
|
|
|
this.clipRequestController.abort();
|
|
|
|
|
|
this.clipRequestController = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 08:44:35 +08:00
|
|
|
|
this.BASEID = _regionId;
|
|
|
|
|
|
|
|
|
|
|
|
if (!isAll) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.currentClipGeoJson = null;
|
|
|
|
|
|
this.regionMaskManager.clearMapMask();
|
|
|
|
|
|
this.regionMaskManager.showAllPoints();
|
2026-05-13 08:44:35 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
const baseLayers = this.getCurrentMaskTargetLayers();
|
|
|
|
|
|
if (baseLayers.length === 0) {
|
2026-05-13 08:44:35 +08:00
|
|
|
|
console.warn('未找到底图图层,无法应用遮罩');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-01 08:42:06 +08:00
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.regionMaskManager.hideAllPoints();
|
2026-07-03 15:59:05 +08:00
|
|
|
|
this.clipRequestController = new AbortController();
|
|
|
|
|
|
const signal = this.clipRequestController.signal;
|
|
|
|
|
|
|
2026-05-13 08:44:35 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const url =
|
|
|
|
|
|
this.hydropBaseConfig.geojson_url +
|
|
|
|
|
|
`&cql_filter=BASEID='${this.BASEID}'`;
|
|
|
|
|
|
|
2026-07-03 15:59:05 +08:00
|
|
|
|
const geoJsonData = await this.loadGeoJsonData(url, signal);
|
|
|
|
|
|
|
|
|
|
|
|
if (signal.aborted) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.BASEID !== _regionId) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.currentClipGeoJson = geoJsonData;
|
|
|
|
|
|
this.regionMaskManager.filterPointsByRegion(geoJsonData);
|
|
|
|
|
|
this.regionMaskManager.fitViewToGeoJson(geoJsonData);
|
|
|
|
|
|
this.regionMaskManager.applyMapMaskToLayers(baseLayers, geoJsonData);
|
2026-07-03 15:59:05 +08:00
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
if (error.name === 'AbortError') {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.currentClipGeoJson = null;
|
2026-05-13 08:44:35 +08:00
|
|
|
|
console.error('加载裁切数据失败:', error);
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.regionMaskManager.clearMapMask();
|
|
|
|
|
|
this.regionMaskManager.showAllPoints();
|
2026-07-03 15:59:05 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
if (this.clipRequestController?.signal === signal) {
|
|
|
|
|
|
this.clipRequestController = null;
|
|
|
|
|
|
}
|
2026-05-13 08:44:35 +08:00
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 基础图层显示影隐藏方法
|
|
|
|
|
|
* @param layerType 图层类型 (备用,优先使用 key)
|
|
|
|
|
|
* @param key 图层唯一标识 Key
|
|
|
|
|
|
* @param checked true 为显示,false 为隐藏
|
|
|
|
|
|
*/
|
|
|
|
|
|
controlBaseLayerTreeShowAndHidden(
|
2026-06-01 08:42:06 +08:00
|
|
|
|
layerType: string,
|
|
|
|
|
|
key: string,
|
2026-04-20 16:58:33 +08:00
|
|
|
|
checked: boolean
|
|
|
|
|
|
): void {
|
|
|
|
|
|
if (!this.map) return;
|
|
|
|
|
|
const registryKey = key || layerType;
|
|
|
|
|
|
|
|
|
|
|
|
if (!registryKey) {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
'controlBaseLayerTreeShowAndHidden: 缺少有效的 Key 或 LayerType'
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
if (registryKey === this.REGISTRY_KEY) {
|
|
|
|
|
|
this.syncActiveBaseLayerVisibility(checked);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
const layerInstance = this.layerRegistry.get(registryKey as string);
|
|
|
|
|
|
if (layerInstance) {
|
2026-06-01 08:42:06 +08:00
|
|
|
|
layerInstance.setVisible(checked);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
// 图层显隐变化时刷新 batch popup
|
|
|
|
|
|
if (this.isBatchPopupMode) {
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.requestUpdateBatchPopups(true);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
`未找到标识为 [${registryKey}] 的图层实例,当前注册表 keys:`,
|
|
|
|
|
|
Array.from(this.layerRegistry.keys())
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-01 08:42:06 +08:00
|
|
|
|
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.pointLayerManager.setLayerVisible(layerType, checked);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.requestRefreshPointLabelVisibility(true);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
// 图层显隐变化时刷新 batch popup
|
|
|
|
|
|
if (this.isBatchPopupMode) {
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.requestUpdateBatchPopups(true);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
2026-06-01 08:42:06 +08:00
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
hasLayer(layerType: string): boolean {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
return this.pointLayerManager.hasLayer(layerType as string);
|
2026-06-01 08:42:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
hasBaseLayer(layerKey: string): boolean {
|
|
|
|
|
|
return this.layerRegistry.has(layerKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 16:58:33 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 缩放地图
|
|
|
|
|
|
* @param type 'out' 缩小, 'in' 放大
|
|
|
|
|
|
*/
|
|
|
|
|
|
zoomToggle(type: 'out' | 'in'): void {
|
|
|
|
|
|
if (!this.map || !this.view) return;
|
|
|
|
|
|
|
|
|
|
|
|
const currentZoom = this.view.getZoom();
|
|
|
|
|
|
if (currentZoom === undefined) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 定义缩放步长,对应 Leaflet 的 zoomDelta
|
|
|
|
|
|
const zoomDelta = 1;
|
|
|
|
|
|
|
|
|
|
|
|
let targetZoom = currentZoom;
|
|
|
|
|
|
|
|
|
|
|
|
if (type === 'in') {
|
|
|
|
|
|
targetZoom = currentZoom + zoomDelta;
|
|
|
|
|
|
// 限制最大缩放级别
|
|
|
|
|
|
if (targetZoom > MAX_ZOOM) {
|
|
|
|
|
|
targetZoom = MAX_ZOOM;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
targetZoom = currentZoom - zoomDelta;
|
|
|
|
|
|
// 限制最小缩放级别
|
|
|
|
|
|
if (targetZoom < MIN_ZOOM) {
|
|
|
|
|
|
targetZoom = MIN_ZOOM;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 animate 实现平滑缩放动画
|
|
|
|
|
|
this.view.animate({
|
|
|
|
|
|
zoom: targetZoom,
|
|
|
|
|
|
duration: 250, // 动画持续时间 (ms),与滚轮缩放保持一致
|
|
|
|
|
|
easing: t => t // 线性缓动,也可以引入 ol/easing 使用更复杂的曲线
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换底图
|
|
|
|
|
|
* @param key 业务标识 Key (如 's_province_boundaries', 'BASEMAP-white', 'BASEMAP-img')
|
|
|
|
|
|
*/
|
2026-06-01 08:42:06 +08:00
|
|
|
|
baseLayerSwitcher(key: string, checked: boolean): void {
|
2026-04-20 16:58:33 +08:00
|
|
|
|
if (!this.map || !this.view) return;
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.activeBaseLayerKey = key;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
const oldLayer = this.layerRegistry.get(this.REGISTRY_KEY);
|
|
|
|
|
|
if (oldLayer) {
|
|
|
|
|
|
this.map.removeLayer(oldLayer);
|
|
|
|
|
|
this.layerRegistry.delete(this.REGISTRY_KEY);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (key == 's_province_boundaries') {
|
2026-06-01 08:42:06 +08:00
|
|
|
|
this.addBaseDataLayer(this.baseLayerConfig, checked);
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.hideBaseMapLayers(this.rasterBaseLayerKeys);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
} else {
|
2026-06-01 08:42:06 +08:00
|
|
|
|
this.addBaseDataLayer(this.baseLayerConfig, checked, true);
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.rasterBaseLayerKeys.forEach(layerKey => {
|
|
|
|
|
|
if (layerKey === key) {
|
|
|
|
|
|
this.addBaseDataLayer(servers.BaseLayer[layerKey], checked);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.setBaseLayerVisible(layerKey, false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
|
|
|
|
|
|
this.reapplyCurrentBaseMask();
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
2026-06-01 08:42:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 隐藏指定的底图图层
|
|
|
|
|
|
* @param layerKeys 要隐藏的图层 key 数组
|
|
|
|
|
|
*/
|
|
|
|
|
|
hideBaseMapLayers(layerKeys: string[]): void {
|
|
|
|
|
|
layerKeys.forEach(key => {
|
|
|
|
|
|
const layer = this.layerRegistry.get(key);
|
|
|
|
|
|
if (layer) {
|
|
|
|
|
|
layer.setVisible(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 显示指定的底图图层
|
|
|
|
|
|
* @param layerKeys 要显示的图层 key 数组
|
|
|
|
|
|
*/
|
|
|
|
|
|
showBaseMapLayers(layerKeys: string[]): void {
|
|
|
|
|
|
layerKeys.forEach(key => {
|
|
|
|
|
|
const layer = this.layerRegistry.get(key);
|
|
|
|
|
|
if (layer) {
|
|
|
|
|
|
layer.setVisible(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 16:58:33 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 添加梯级流域图 (Vector Layer) - 使用本地 GeoJSON 数据
|
|
|
|
|
|
* @param layer 图层配置对象
|
|
|
|
|
|
* @param fillcolor 填充颜色
|
|
|
|
|
|
* @param outlineColor 边框颜色
|
|
|
|
|
|
* @param datas 需要显示的 RVCD 列表 (例如: ['SJLY25', ...]),如果为空则显示所有或根据业务逻辑处理
|
|
|
|
|
|
*/
|
|
|
|
|
|
addTertiarybasinLayer(
|
|
|
|
|
|
layer: any,
|
|
|
|
|
|
fillcolor: any,
|
|
|
|
|
|
outlineColor: any,
|
|
|
|
|
|
datas: any
|
|
|
|
|
|
): void {
|
|
|
|
|
|
if (!this.map || !this.view) return;
|
|
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
// 1. 检查是否已存在该图层,已存在时直接切换为显示并刷新样式
|
2026-04-20 16:58:33 +08:00
|
|
|
|
const existingLayer = this.layerRegistry.get(layer.key);
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 创建样式函数
|
|
|
|
|
|
const visibleStyle = new Style({
|
|
|
|
|
|
fill: new Fill({ color: fillcolor }),
|
|
|
|
|
|
stroke: new Stroke({ color: outlineColor, width: 1 })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const hiddenStyle = new Style({
|
|
|
|
|
|
fill: new Fill({ color: 'rgba(0,0,0,0)' }),
|
|
|
|
|
|
stroke: new Stroke({ color: 'rgba(0,0,0,0)' })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
if (existingLayer) {
|
|
|
|
|
|
const allowedIds = Array.isArray(datas) ? datas : [];
|
|
|
|
|
|
const existingFeatures =
|
|
|
|
|
|
existingLayer.getSource?.()?.getFeatures?.() ?? [];
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
existingFeatures.forEach(feature => {
|
|
|
|
|
|
const rvcd = feature.get('RVCD');
|
|
|
|
|
|
const isVisible =
|
|
|
|
|
|
allowedIds.length > 0 ? rvcd && allowedIds.includes(rvcd) : true;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
feature.setStyle(isVisible ? visibleStyle : hiddenStyle);
|
|
|
|
|
|
});
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
existingLayer.setVisible(layer.visible !== false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
// 3. 创建矢量源 (VectorSource),并通过 layer.geojson_url 加载数据
|
|
|
|
|
|
const vectorSource = new VectorSource();
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
if (layer?.geojson_url) {
|
|
|
|
|
|
this.loadGeoJsonData(VITE_APP_MAP_URL + layer.geojson_url).then(
|
|
|
|
|
|
geoJsonData => {
|
|
|
|
|
|
if (!geoJsonData) return;
|
|
|
|
|
|
|
|
|
|
|
|
const features = new GeoJSON().readFeatures(geoJsonData, {
|
|
|
|
|
|
featureProjection: 'EPSG:3857' // 确保转换到地图使用的投影
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
vectorSource.addFeatures(features);
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 数据过滤逻辑
|
|
|
|
|
|
const allowedIds = Array.isArray(datas) ? datas : [];
|
|
|
|
|
|
|
|
|
|
|
|
features.forEach(feature => {
|
|
|
|
|
|
// 获取属性中的 RVCD
|
|
|
|
|
|
const rvcd = feature.get('RVCD');
|
|
|
|
|
|
|
|
|
|
|
|
let isVisible = false;
|
|
|
|
|
|
|
|
|
|
|
|
// 逻辑:如果 datas 为空,通常意味着显示所有(或者都不显示,视具体需求而定)
|
|
|
|
|
|
// 这里假设:datas 有值时严格过滤;datas 为空时显示所有
|
|
|
|
|
|
if (allowedIds.length > 0) {
|
|
|
|
|
|
isVisible = rvcd && allowedIds.includes(rvcd);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
isVisible = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 应用样式
|
|
|
|
|
|
feature.setStyle(isVisible ? visibleStyle : hiddenStyle);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 5. 创建矢量图层 (VectorLayer)
|
|
|
|
|
|
const vectorLayer = new VectorLayer({
|
|
|
|
|
|
source: vectorSource,
|
|
|
|
|
|
style: null, // 样式已应用在 Feature 上,这里设为 null 或保留默认
|
|
|
|
|
|
zIndex: 100,
|
|
|
|
|
|
visible: layer.visible !== false
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 6. 注册并添加到地图
|
|
|
|
|
|
this.layerRegistry.set(layer.key, vectorLayer);
|
|
|
|
|
|
this.map.addLayer(vectorLayer);
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
2026-06-03 15:03:31 +08:00
|
|
|
|
* 隐藏梯级流域图
|
2026-04-20 16:58:33 +08:00
|
|
|
|
* @param layer 图层配置对象 (需包含 key 属性)
|
|
|
|
|
|
*/
|
2026-06-03 15:03:31 +08:00
|
|
|
|
hideTertiarybasinLayer(layer: any): void {
|
2026-04-20 16:58:33 +08:00
|
|
|
|
if (!this.map || !layer || !layer.key) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
console.warn('hideTertiarybasinLayer: 无效的图层或地图未初始化');
|
2026-04-20 16:58:33 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 1. 从注册表中获取图层实例
|
|
|
|
|
|
const layerInstance = this.layerRegistry.get(layer.key);
|
|
|
|
|
|
|
|
|
|
|
|
if (layerInstance) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
layerInstance.setVisible(false);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
console.warn(`未找到标识为 [${layer.key}] 的梯级流域图层实例`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 删除描点图层
|
|
|
|
|
|
* @param layerKey 图层 key
|
|
|
|
|
|
*/
|
|
|
|
|
|
removePointLayer(layerKey: string): void {
|
|
|
|
|
|
this.pointLayerManager.removeLayer(layerKey);
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 地图打印/导出
|
|
|
|
|
|
* 将当前地图视图导出为 PNG 图片,背景强制为白色
|
|
|
|
|
|
*/
|
|
|
|
|
|
mapOutPut(): void {
|
|
|
|
|
|
if (!this.map) {
|
|
|
|
|
|
console.warn('地图未初始化,无法打印');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const mapElement = this.map.getTargetElement();
|
|
|
|
|
|
const canvas = mapElement.querySelector('canvas');
|
|
|
|
|
|
|
|
|
|
|
|
if (!canvas) {
|
|
|
|
|
|
console.error('未找到地图 Canvas 元素,无法导出');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const width = canvas.width;
|
|
|
|
|
|
const height = canvas.height;
|
|
|
|
|
|
|
|
|
|
|
|
const offscreenCanvas = document.createElement('canvas');
|
|
|
|
|
|
offscreenCanvas.width = width;
|
|
|
|
|
|
offscreenCanvas.height = height;
|
|
|
|
|
|
const ctx = offscreenCanvas.getContext('2d');
|
|
|
|
|
|
|
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
|
throw new Error('无法获取 Canvas 上下文');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#FFFFFF';
|
|
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
|
|
|
|
|
|
|
|
ctx.drawImage(canvas, 0, 0);
|
|
|
|
|
|
|
|
|
|
|
|
const dataURL = offscreenCanvas.toDataURL('image/png');
|
|
|
|
|
|
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
|
link.download = `map_export_${new Date().getTime()}.png`;
|
|
|
|
|
|
link.href = dataURL;
|
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('地图导出失败', e);
|
|
|
|
|
|
alert('地图导出失败,请检查控制台错误信息。');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 切换双击缩放交互的状态
|
|
|
|
|
|
*/
|
|
|
|
|
|
private toggleDoubleClickZoom(active: boolean): void {
|
|
|
|
|
|
if (!this.map) return;
|
|
|
|
|
|
this.map.getInteractions().forEach(interaction => {
|
|
|
|
|
|
if (interaction instanceof DoubleClickZoom) {
|
|
|
|
|
|
interaction.setActive(active);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 创建并返回测量 Tooltip Overlay
|
|
|
|
|
|
*/
|
|
|
|
|
|
private createMeasureTooltipOverlay(): Overlay {
|
|
|
|
|
|
const element = document.createElement('div');
|
|
|
|
|
|
element.className = 'measure-tooltip tooltip-measure';
|
|
|
|
|
|
Object.assign(element.style, {
|
|
|
|
|
|
background: 'rgba(0, 0, 0, 0.7)',
|
|
|
|
|
|
color: 'white',
|
|
|
|
|
|
padding: '4px 8px',
|
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
|
whiteSpace: 'nowrap'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return new Overlay({
|
|
|
|
|
|
element,
|
|
|
|
|
|
offset: [0, -15],
|
|
|
|
|
|
positioning: 'bottom-center'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 通用测量启动方法
|
|
|
|
|
|
* @param geomType 'LineString' | 'Polygon'
|
|
|
|
|
|
* @param drawStyle 绘制中的样式函数
|
|
|
|
|
|
* @param finishedStyle 完成后的样式
|
|
|
|
|
|
* @param onResult 计算结果的回调 (feature, resultText)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private startMeasurement(
|
|
|
|
|
|
geomType: 'LineString' | 'Polygon',
|
|
|
|
|
|
drawStyle: any,
|
|
|
|
|
|
finishedStyle: Style,
|
|
|
|
|
|
onResult: (feature: Feature, text: string) => void
|
|
|
|
|
|
): void {
|
|
|
|
|
|
if (!this.map || !this.view) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 初始化图层
|
|
|
|
|
|
if (!this.measureLayer) {
|
|
|
|
|
|
this.measureSource = new VectorSource();
|
|
|
|
|
|
this.measureLayer = new VectorLayer({
|
|
|
|
|
|
source: this.measureSource,
|
|
|
|
|
|
zIndex: 1000
|
|
|
|
|
|
});
|
|
|
|
|
|
this.map.addLayer(this.measureLayer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 清理旧交互
|
|
|
|
|
|
if (this.drawInteraction) {
|
|
|
|
|
|
this.map.removeInteraction(this.drawInteraction);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 禁用双击缩放
|
|
|
|
|
|
this.toggleDoubleClickZoom(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 创建绘制交互
|
|
|
|
|
|
this.drawInteraction = new Draw({
|
2026-04-22 17:53:20 +08:00
|
|
|
|
source: this.measureSource!,
|
2026-04-20 16:58:33 +08:00
|
|
|
|
type: geomType,
|
|
|
|
|
|
style: drawStyle
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.map.addInteraction(this.drawInteraction);
|
|
|
|
|
|
|
|
|
|
|
|
let changeListenerKey: any;
|
|
|
|
|
|
let currentTooltip: Overlay | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 监听绘制开始
|
|
|
|
|
|
this.drawInteraction.on('drawstart', (evt: any) => {
|
|
|
|
|
|
const feature = evt.feature;
|
|
|
|
|
|
const geom = feature.getGeometry();
|
|
|
|
|
|
|
|
|
|
|
|
// 创建动态 Tooltip
|
|
|
|
|
|
currentTooltip = this.createMeasureTooltipOverlay();
|
|
|
|
|
|
this.map?.addOverlay(currentTooltip);
|
|
|
|
|
|
|
|
|
|
|
|
// 监听几何变化更新 Tooltip
|
|
|
|
|
|
changeListenerKey = geom!.on('change', () => {
|
|
|
|
|
|
if (currentTooltip && currentTooltip.getElement() && geom) {
|
|
|
|
|
|
// 根据类型计算临时值
|
|
|
|
|
|
let tempText = '';
|
|
|
|
|
|
if (geomType === 'LineString') {
|
|
|
|
|
|
tempText = this.formatLength(geom as LineString);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
tempText = this.formatArea(
|
|
|
|
|
|
geom as import('ol/geom/Polygon').default
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
currentTooltip.getElement()!.innerHTML = tempText;
|
|
|
|
|
|
|
|
|
|
|
|
// 对于多边形,Tooltip 跟随鼠标最后一个点;对于线,也是最后一个点
|
|
|
|
|
|
const lastCoord = (geom as any).getLastCoordinate();
|
|
|
|
|
|
if (lastCoord) currentTooltip.setPosition(lastCoord);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 5. 监听绘制结束
|
|
|
|
|
|
this.drawInteraction.on('drawend', (evt: any) => {
|
|
|
|
|
|
const feature = evt.feature;
|
|
|
|
|
|
const geom = feature.getGeometry();
|
|
|
|
|
|
|
|
|
|
|
|
// ✅ 修复:检查几何体是否有效
|
|
|
|
|
|
// 对于 Polygon,如果坐标点数不足(例如只点了两下),geom 可能是无效的或面积为0
|
|
|
|
|
|
if (!geom || geom.getCoordinates().length === 0) {
|
|
|
|
|
|
console.warn('无效的几何体,已忽略');
|
|
|
|
|
|
this.measureSource?.removeFeature(feature);
|
|
|
|
|
|
if (currentTooltip) this.map?.removeOverlay(currentTooltip);
|
|
|
|
|
|
if (changeListenerKey) unByKey(changeListenerKey);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清理监听和临时 Tooltip
|
|
|
|
|
|
if (changeListenerKey) unByKey(changeListenerKey);
|
|
|
|
|
|
if (currentTooltip) this.map?.removeOverlay(currentTooltip);
|
|
|
|
|
|
|
|
|
|
|
|
// ✅ 修复:强制设置样式,确保即使默认样式有问题也能显示
|
|
|
|
|
|
feature.setStyle(finishedStyle);
|
|
|
|
|
|
|
|
|
|
|
|
// 计算最终结果并回调
|
|
|
|
|
|
let resultText = '';
|
|
|
|
|
|
let isValidResult = true;
|
|
|
|
|
|
|
|
|
|
|
|
if (geomType === 'LineString') {
|
|
|
|
|
|
resultText = this.formatLength(geom as LineString);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 对于多边形,再次检查面积是否过小或无效
|
|
|
|
|
|
const polyGeom = geom as import('ol/geom/Polygon').default;
|
|
|
|
|
|
const area = getSphericalArea(polyGeom);
|
|
|
|
|
|
if (area < 0.0001) {
|
|
|
|
|
|
// 极小面积视为无效绘制
|
|
|
|
|
|
isValidResult = false;
|
|
|
|
|
|
console.warn('面积过小,已忽略');
|
|
|
|
|
|
this.measureSource?.removeFeature(feature);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resultText = this.formatArea(polyGeom);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 只有结果有效才添加标签
|
|
|
|
|
|
if (isValidResult) {
|
|
|
|
|
|
onResult(feature, resultText);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清理交互
|
|
|
|
|
|
this.map?.removeInteraction(this.drawInteraction);
|
|
|
|
|
|
this.drawInteraction = null;
|
|
|
|
|
|
|
|
|
|
|
|
// 恢复双击缩放
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.toggleDoubleClickZoom(true);
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 长度量算
|
|
|
|
|
|
*/
|
|
|
|
|
|
lengthCalculate(): void {
|
|
|
|
|
|
this.startMeasurement(
|
|
|
|
|
|
'LineString',
|
|
|
|
|
|
(feature: any) => {
|
|
|
|
|
|
// 绘制中只显示线,隐藏点
|
|
|
|
|
|
return feature.getGeometry()?.getType() === 'LineString'
|
|
|
|
|
|
? new Style({
|
|
|
|
|
|
stroke: new Stroke({
|
|
|
|
|
|
color: '#ffcc33',
|
|
|
|
|
|
lineDash: [10, 10],
|
|
|
|
|
|
width: 2
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
: [];
|
|
|
|
|
|
},
|
|
|
|
|
|
new Style({
|
|
|
|
|
|
stroke: new Stroke({ color: '#9AD8E7', width: 3 }) // 加粗一点
|
|
|
|
|
|
}),
|
|
|
|
|
|
(feature, text) => {
|
|
|
|
|
|
this.addFixedLabel(feature, text);
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 面积量算
|
|
|
|
|
|
*/
|
|
|
|
|
|
areCalculate(): void {
|
|
|
|
|
|
this.startMeasurement(
|
|
|
|
|
|
'Polygon',
|
|
|
|
|
|
(feature: any) => {
|
|
|
|
|
|
// 绘制中显示线和填充
|
|
|
|
|
|
return feature.getGeometry()?.getType() === 'Polygon'
|
|
|
|
|
|
? new Style({
|
|
|
|
|
|
stroke: new Stroke({
|
|
|
|
|
|
color: '#ffcc33',
|
|
|
|
|
|
lineDash: [10, 10],
|
|
|
|
|
|
width: 2
|
|
|
|
|
|
}),
|
|
|
|
|
|
fill: new Fill({ color: 'rgba(255, 204, 51, 0.3)' }) // 提高透明度以便看清
|
|
|
|
|
|
})
|
|
|
|
|
|
: [];
|
|
|
|
|
|
},
|
|
|
|
|
|
new Style({
|
|
|
|
|
|
stroke: new Stroke({ color: '#9AD8E7', width: 3 }), // 加粗边框
|
|
|
|
|
|
fill: new Fill({ color: 'rgba(154, 216, 231, 0.4)' }) // 提高填充可见度
|
|
|
|
|
|
}),
|
|
|
|
|
|
(feature, text) => {
|
|
|
|
|
|
this.addFixedLabel(feature, text);
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 移除量算结果
|
|
|
|
|
|
*/
|
|
|
|
|
|
removeQueryLayer(): void {
|
|
|
|
|
|
// 1. 清除矢量数据 (线条)
|
|
|
|
|
|
if (this.measureSource) {
|
|
|
|
|
|
this.measureSource.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 清除所有相关 Overlay (Label)
|
|
|
|
|
|
if (this.map) {
|
|
|
|
|
|
// ✅ 修复:先获取所有 Overlay 的副本,避免在遍历过程中修改原数组导致漏删
|
|
|
|
|
|
const overlaysToRemove: Overlay[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
this.map.getOverlays().forEach(overlay => {
|
|
|
|
|
|
const el = overlay.getElement();
|
|
|
|
|
|
// 通过类名判断是否为我们创建的测量标签
|
|
|
|
|
|
if (
|
|
|
|
|
|
el &&
|
|
|
|
|
|
(el.classList.contains('measure-tooltip') ||
|
|
|
|
|
|
el.classList.contains('measure-label-container'))
|
|
|
|
|
|
) {
|
|
|
|
|
|
overlaysToRemove.push(overlay);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 统一移除
|
|
|
|
|
|
overlaysToRemove.forEach(overlay => {
|
|
|
|
|
|
this.map?.removeOverlay(overlay);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 确保恢复双击缩放
|
|
|
|
|
|
this.toggleDoubleClickZoom(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 清除交互
|
|
|
|
|
|
if (this.drawInteraction) {
|
|
|
|
|
|
this.map?.removeInteraction(this.drawInteraction);
|
|
|
|
|
|
this.drawInteraction = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 添加固定的 Label (带删除按钮)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private addFixedLabel(feature: Feature, lengthText: string): void {
|
|
|
|
|
|
if (!this.map) return;
|
|
|
|
|
|
|
|
|
|
|
|
const container = document.createElement('div');
|
|
|
|
|
|
container.className = 'measure-label-container';
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 Object.assign 批量设置样式,代码更整洁
|
|
|
|
|
|
Object.assign(container.style, {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
border: '1px solid #000',
|
|
|
|
|
|
padding: '4px 8px',
|
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
|
color: '#000',
|
|
|
|
|
|
cursor: 'default',
|
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
zIndex: '1001',
|
|
|
|
|
|
pointerEvents: 'auto' // 确保可以点击
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const textSpan = document.createElement('span');
|
|
|
|
|
|
textSpan.innerText = lengthText;
|
|
|
|
|
|
|
|
|
|
|
|
const closeBtn = document.createElement('span');
|
|
|
|
|
|
closeBtn.innerHTML = '×';
|
|
|
|
|
|
Object.assign(closeBtn.style, {
|
|
|
|
|
|
fontWeight: 'bold',
|
|
|
|
|
|
marginLeft: '5px',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
fontSize: '14px',
|
|
|
|
|
|
lineHeight: '1'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const geom: any = feature.getGeometry();
|
|
|
|
|
|
let center: any;
|
|
|
|
|
|
|
|
|
|
|
|
// ✅ 修复:根据几何类型选择标签位置
|
|
|
|
|
|
if (geom?.getType() === 'Polygon') {
|
|
|
|
|
|
// 多边形使用内部点,确保标签在面内
|
|
|
|
|
|
center = (geom as import('ol/geom/Polygon').default)
|
|
|
|
|
|
.getInteriorPoint()
|
|
|
|
|
|
.getCoordinates();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 线使用中点
|
|
|
|
|
|
center = (geom as LineString).getCoordinateAt(0.5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const overlay = new Overlay({
|
|
|
|
|
|
element: container,
|
|
|
|
|
|
positioning: 'top-center',
|
|
|
|
|
|
offset: [0, -10],
|
|
|
|
|
|
position: center
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
closeBtn.onclick = e => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
this.measureSource?.removeFeature(feature);
|
|
|
|
|
|
this.map?.removeOverlay(overlay);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
container.appendChild(textSpan);
|
|
|
|
|
|
container.appendChild(closeBtn);
|
|
|
|
|
|
this.map.addOverlay(overlay);
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 格式化长度输出 (米)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private formatLength(line: LineString): string {
|
|
|
|
|
|
return '长度:' + getSphericalLength(line).toFixed(3) + 'm';
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 格式化面积输出 (km²)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private formatArea(polygon: import('ol/geom/Polygon').default): string {
|
|
|
|
|
|
// 获取球面面积 (平方米)
|
|
|
|
|
|
const areaSqMeters = getSphericalArea(polygon);
|
|
|
|
|
|
// 转换为平方公里
|
|
|
|
|
|
const areaSqKm = areaSqMeters / 1000000;
|
|
|
|
|
|
return '面积:' + areaSqKm.toFixed(3) + 'km²';
|
|
|
|
|
|
}
|
2026-06-01 08:42:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 图例和基地面板控制描点数据显示隐藏方法
|
|
|
|
|
|
* @param layerType 图层类型
|
2026-06-03 15:03:31 +08:00
|
|
|
|
* @param key 图层标识符(锚点状态 nameEn)
|
2026-06-01 08:42:06 +08:00
|
|
|
|
* @param baseid 基地ID
|
|
|
|
|
|
* @param checked 是否选中(显示/隐藏)
|
|
|
|
|
|
* @param isAll 是否全部操作
|
|
|
|
|
|
*/
|
|
|
|
|
|
mdLayerShowOrHidden(
|
|
|
|
|
|
layerType: string,
|
|
|
|
|
|
key?: string,
|
2026-06-03 15:03:31 +08:00
|
|
|
|
baseid: string,
|
2026-06-01 08:42:06 +08:00
|
|
|
|
checked?: boolean,
|
|
|
|
|
|
isAll?: boolean
|
|
|
|
|
|
): void {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
void baseid;
|
|
|
|
|
|
void isAll;
|
|
|
|
|
|
this.pointLayerManager.setLegendVisibleByField(
|
|
|
|
|
|
layerType,
|
|
|
|
|
|
key || '',
|
|
|
|
|
|
!!checked
|
|
|
|
|
|
);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
// 图例变化时刷新 batch popup
|
|
|
|
|
|
if (this.isBatchPopupMode) {
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.requestUpdateBatchPopups(true);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
}
|
2026-06-01 08:42:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 根据 anchoPointState 控制单个图例的锚点显示隐藏
|
|
|
|
|
|
* @param layerKey 图层 key
|
|
|
|
|
|
* @param anchoPointState 锚点状态(对应图例的 nameEn)
|
|
|
|
|
|
* @param checked 是否显示
|
|
|
|
|
|
*/
|
|
|
|
|
|
setLegendPointVisible(
|
|
|
|
|
|
layerKey: string,
|
|
|
|
|
|
anchoPointState: string,
|
|
|
|
|
|
checked: boolean
|
|
|
|
|
|
): void {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.pointLayerManager.setLegendVisibleByField(
|
|
|
|
|
|
layerKey,
|
|
|
|
|
|
anchoPointState,
|
|
|
|
|
|
checked
|
|
|
|
|
|
);
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.requestRefreshPointLabelVisibility(true);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
// 图例变化时刷新 batch popup
|
|
|
|
|
|
if (this.isBatchPopupMode) {
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.requestUpdateBatchPopups(true);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
}
|
2026-06-01 08:42:06 +08:00
|
|
|
|
}
|
2026-05-15 17:38:03 +08:00
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
switchView(_type): void {}
|
2026-05-11 17:45:09 +08:00
|
|
|
|
fitBounds(): void {}
|
2026-05-13 08:44:35 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 飞行定位到指定经纬度和缩放级别
|
|
|
|
|
|
* @param position [经度, 纬度] (EPSG:4326)
|
|
|
|
|
|
* @param zoom 缩放级别
|
|
|
|
|
|
*/
|
|
|
|
|
|
flyTopanto(position: number[], zoom: number): void {
|
|
|
|
|
|
if (!this.map || !this.view) {
|
|
|
|
|
|
console.warn('地图未初始化,无法执行飞行定位');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 将经纬度 [lon, lat] 转换为地图投影坐标 (EPSG:3857)
|
|
|
|
|
|
const targetCenter = fromLonLat(position);
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 执行动画
|
|
|
|
|
|
// duration: 动画持续时间,单位毫秒,例如 1000ms (1秒)
|
|
|
|
|
|
this.view.animate(
|
|
|
|
|
|
{
|
|
|
|
|
|
center: targetCenter,
|
|
|
|
|
|
duration: 1000
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
zoom: zoom,
|
|
|
|
|
|
duration: 1000
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-25 08:54:17 +08:00
|
|
|
|
|
2026-07-10 17:56:16 +08:00
|
|
|
|
getCurrentZoom(): number | undefined {
|
|
|
|
|
|
return this.view?.getZoom();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 16:23:10 +08:00
|
|
|
|
/** 3D 专用飞入默认视角,2D 下无需操作 */
|
|
|
|
|
|
async flyToDefaultView(): Promise<void> {
|
|
|
|
|
|
// 2D 无动画,空实现
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 08:54:17 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 生成带完整圆边框的上半圆 Canvas
|
|
|
|
|
|
* @param radius 半径(像素)
|
|
|
|
|
|
* @param color 填充颜色
|
|
|
|
|
|
* @returns HTMLCanvasElement
|
|
|
|
|
|
*/
|
|
|
|
|
|
private createSemiCircleCanvas(
|
|
|
|
|
|
radius: number,
|
|
|
|
|
|
color: string
|
|
|
|
|
|
): HTMLCanvasElement {
|
|
|
|
|
|
const size = radius * 2 + 2;
|
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
|
canvas.width = size;
|
|
|
|
|
|
canvas.height = size;
|
|
|
|
|
|
const ctx = canvas.getContext('2d')!;
|
|
|
|
|
|
ctx.clearRect(0, 0, size, size);
|
|
|
|
|
|
|
|
|
|
|
|
const centerX = size / 2;
|
|
|
|
|
|
const centerY = size / 2;
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 绘制下半圆填充(给定颜色)— 圆弧从 π 到 2π
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.arc(centerX, centerY, radius, 0, Math.PI, false);
|
|
|
|
|
|
ctx.closePath();
|
|
|
|
|
|
ctx.fillStyle = color;
|
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 绘制上半圆填充(白色)
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.arc(centerX, centerY, radius, Math.PI, 2 * Math.PI, false);
|
|
|
|
|
|
ctx.closePath();
|
|
|
|
|
|
ctx.fillStyle = '#FFFFFF'; // 白色
|
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 绘制完整圆边框
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
|
|
|
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
|
|
|
|
|
|
ctx.lineWidth = 1.5;
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
|
|
|
|
return canvas;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 16:58:33 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 移除地图对象,释放资源
|
|
|
|
|
|
*/
|
|
|
|
|
|
destroy(): void {
|
2026-07-09 11:33:04 +08:00
|
|
|
|
if (this.pointLayerStyleRefreshFrameId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(this.pointLayerStyleRefreshFrameId);
|
|
|
|
|
|
this.pointLayerStyleRefreshFrameId = null;
|
|
|
|
|
|
}
|
2026-07-01 08:48:39 +08:00
|
|
|
|
if (this.labelVisibilityRefreshFrameId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
|
|
|
|
|
|
this.labelVisibilityRefreshFrameId = null;
|
|
|
|
|
|
}
|
2026-07-09 11:33:04 +08:00
|
|
|
|
if (this.labelVisibilityDebounceTimerId !== null) {
|
|
|
|
|
|
window.clearTimeout(this.labelVisibilityDebounceTimerId);
|
|
|
|
|
|
this.labelVisibilityDebounceTimerId = null;
|
|
|
|
|
|
}
|
2026-07-10 17:56:16 +08:00
|
|
|
|
this.clearBatchPopupPostRenderSync();
|
2026-04-20 16:58:33 +08:00
|
|
|
|
this.removeQueryLayer();
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.regionMaskManager.destroy();
|
2026-08-05 17:12:38 +08:00
|
|
|
|
this.measureTextCache.clear();
|
|
|
|
|
|
this.measureCanvas = null;
|
|
|
|
|
|
if (this.fadeRefreshFrameId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(this.fadeRefreshFrameId);
|
|
|
|
|
|
this.fadeRefreshFrameId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.fadingFeatures.clear();
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-05-15 17:38:03 +08:00
|
|
|
|
const clearLayer = (layer: any) => {
|
|
|
|
|
|
if (layer && layer.getSource) {
|
|
|
|
|
|
const source = layer.getSource();
|
|
|
|
|
|
if (source && source.clear) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
source.clear();
|
2026-05-15 17:38:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.map && this.map.getLayers().getArray().includes(layer)) {
|
|
|
|
|
|
this.map.removeLayer(layer);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (layer.setMap) layer.setMap(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (this.layerRegistry) {
|
|
|
|
|
|
this.layerRegistry.forEach(clearLayer);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
this.layerRegistry.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.popupManager.destroy();
|
|
|
|
|
|
this.pointLayerManager.destroy();
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
if (this.map) {
|
|
|
|
|
|
this.map.getInteractions().clear();
|
|
|
|
|
|
this.map.getOverlays().clear();
|
2026-04-22 17:53:20 +08:00
|
|
|
|
this.map.setTarget(undefined);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
this.map.dispose();
|
|
|
|
|
|
this.map = null;
|
|
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.popupManager.setMap(null);
|
|
|
|
|
|
this.pointLayerManager.setMap(null);
|
|
|
|
|
|
this.regionMaskManager.setContext(null, null);
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
|
|
|
|
|
if (this.view) {
|
|
|
|
|
|
this.view.dispose();
|
|
|
|
|
|
this.view = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.baseLayerConfig = null;
|
|
|
|
|
|
this.measureSource = null;
|
|
|
|
|
|
this.measureLayer = null;
|
|
|
|
|
|
this.drawInteraction = null;
|
2026-05-15 17:38:03 +08:00
|
|
|
|
this.geoJsonData = null;
|
|
|
|
|
|
this.geoJsonData1 = null;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
2026-07-17 18:03:34 +08:00
|
|
|
|
|
|
|
|
|
|
// 倾斜摄影方法仅3D支持,2D为空实现
|
|
|
|
|
|
addQxsyLayer(_item: any): void {}
|
|
|
|
|
|
removeQxsyLayer(_item: any): void {}
|
|
|
|
|
|
qxsyChangeClick(_item: any, _checked: boolean): void {}
|
|
|
|
|
|
qxsyToPosition(_item: any): void {}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|