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';
|
2026-05-25 08:54:17 +08:00
|
|
|
|
import Circle from 'ol/style/Circle';
|
2026-04-20 16:58:33 +08:00
|
|
|
|
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-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;
|
|
|
|
|
|
const FULL_DISPLAY_NO_COLLISION_ZOOM = 15;
|
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-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-05-15 17:38:03 +08:00
|
|
|
|
private hoveredFeatureId: string | number | 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;
|
|
|
|
|
|
private labelVisibilityRefreshFrameId: 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-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-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-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-06-03 15:03:31 +08:00
|
|
|
|
this.popupManager.handlePointerMove(evt.pixel, payload => {
|
|
|
|
|
|
this.hoveredFeatureId = payload.hoveredId;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
const targetElement = this.map?.getTargetElement() as HTMLElement;
|
|
|
|
|
|
if (targetElement) {
|
|
|
|
|
|
targetElement.style.cursor = payload.hoveredId ? 'pointer' : '';
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
2026-06-03 15:03:31 +08:00
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
// 批量 popup 模式下不触发 hover popup
|
2026-06-16 11:36:17 +08:00
|
|
|
|
if (!this.isBatchPopupMode) {
|
|
|
|
|
|
this.showPopup(payload.detectedFeature, 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-01 08:48:39 +08:00
|
|
|
|
this.requestUpdateBatchPopups();
|
2026-06-16 11:36:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
this.map.on('moveend', () => {
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.requestRefreshPointLabelVisibility(true);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
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
|
|
|
|
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);
|
|
|
|
|
|
this.requestRefreshPointLabelVisibility(true);
|
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;
|
|
|
|
|
|
this.popupManager.clearBatchPopups();
|
2026-07-01 08:48:39 +08:00
|
|
|
|
this.requestUpdateBatchPopups(true);
|
2026-06-16 11:36:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 禁用批量 popup 模式:恢复鼠标悬停显示 popup
|
|
|
|
|
|
*/
|
|
|
|
|
|
private disableBatchPopupMode() {
|
|
|
|
|
|
this.isBatchPopupMode = false;
|
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/距离过滤等隐藏的锚点
|
|
|
|
|
|
this.popupManager.showPopupsForFeatures(features, feature => {
|
|
|
|
|
|
const style = this.createPointStyle(feature);
|
|
|
|
|
|
return style !== 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-05-11 17:45:09 +08:00
|
|
|
|
const currentZoom: any = this.view ? this.view.getZoom() : 4.5;
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const iconTargetVisible =
|
|
|
|
|
|
legendVisible !== false &&
|
|
|
|
|
|
regionVisible !== false &&
|
|
|
|
|
|
this.shouldRenderFeatureByDensity(feature, currentZoom) &&
|
|
|
|
|
|
this.shouldRenderNearbyFeature(feature, currentZoom);
|
|
|
|
|
|
|
2026-06-03 15:03:31 +08:00
|
|
|
|
const dynamicScale = this.getDynamicPointScale(currentZoom);
|
|
|
|
|
|
const fontSize = this.getPointFontSize(dynamicScale);
|
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;
|
|
|
|
|
|
const finalIconVisible = iconTargetVisible && iconCollisionVisible;
|
|
|
|
|
|
const labelTargetVisible =
|
|
|
|
|
|
finalIconVisible && !!formattedLabelText && labelCollisionVisible;
|
|
|
|
|
|
|
|
|
|
|
|
if (!finalIconVisible) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const styles: Style[] = [];
|
|
|
|
|
|
styles.push(
|
|
|
|
|
|
new Style({
|
|
|
|
|
|
image: new Icon({
|
|
|
|
|
|
src: iconUrl,
|
|
|
|
|
|
scale: dynamicScale,
|
|
|
|
|
|
anchor: [0.5, 0.5],
|
|
|
|
|
|
crossOrigin: 'anonymous',
|
|
|
|
|
|
declutterMode: 'none'
|
|
|
|
|
|
})
|
2026-04-20 16:58:33 +08:00
|
|
|
|
})
|
2026-07-01 08:48:39 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (formattedLabelText && labelTargetVisible) {
|
|
|
|
|
|
styles.push(
|
|
|
|
|
|
new Style({
|
|
|
|
|
|
zIndex: 101,
|
|
|
|
|
|
text: new Text({
|
|
|
|
|
|
text: formattedLabelText,
|
|
|
|
|
|
offsetY: labelOffsetY,
|
|
|
|
|
|
font: `${fontSize}px sans-serif`,
|
|
|
|
|
|
fill: new Fill({ color: '#fff' }),
|
|
|
|
|
|
stroke: new Stroke({
|
|
|
|
|
|
color: 'rgba(0, 0, 0, 0.9)',
|
|
|
|
|
|
width: 2
|
|
|
|
|
|
}),
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
declutterMode: 'none'
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return styles;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private refreshPointLabelVisibility() {
|
|
|
|
|
|
if (!this.map || !this.view) return;
|
|
|
|
|
|
|
|
|
|
|
|
const currentZoom = this.view.getZoom();
|
|
|
|
|
|
if (currentZoom === undefined) return;
|
|
|
|
|
|
|
|
|
|
|
|
const candidateMap = new Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
{
|
|
|
|
|
|
feature: Feature;
|
|
|
|
|
|
iconLeft: number;
|
|
|
|
|
|
iconRight: number;
|
|
|
|
|
|
iconTop: number;
|
|
|
|
|
|
iconBottom: number;
|
|
|
|
|
|
hasLabel: boolean;
|
|
|
|
|
|
left: number;
|
|
|
|
|
|
right: number;
|
|
|
|
|
|
top: number;
|
|
|
|
|
|
bottom: number;
|
|
|
|
|
|
priority: number;
|
|
|
|
|
|
densityPriority: number;
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
pixelX: number;
|
|
|
|
|
|
pixelY: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
>();
|
|
|
|
|
|
const candidates: Array<{
|
|
|
|
|
|
feature: Feature;
|
|
|
|
|
|
iconLeft: number;
|
|
|
|
|
|
iconRight: number;
|
|
|
|
|
|
iconTop: number;
|
|
|
|
|
|
iconBottom: number;
|
|
|
|
|
|
hasLabel: boolean;
|
|
|
|
|
|
left: number;
|
|
|
|
|
|
right: number;
|
|
|
|
|
|
top: number;
|
|
|
|
|
|
bottom: number;
|
|
|
|
|
|
priority: number;
|
|
|
|
|
|
densityPriority: number;
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
pixelX: number;
|
|
|
|
|
|
pixelY: number;
|
|
|
|
|
|
}> = [];
|
|
|
|
|
|
|
|
|
|
|
|
const viewportFeatures = this.pointLayerManager.getFeaturesInViewport();
|
|
|
|
|
|
|
|
|
|
|
|
if (currentZoom >= FULL_DISPLAY_NO_COLLISION_ZOOM) {
|
|
|
|
|
|
viewportFeatures.forEach(feature => {
|
|
|
|
|
|
const iconUrl = feature.get('_iconUrl') as string;
|
|
|
|
|
|
const shouldRenderIcon =
|
|
|
|
|
|
!!iconUrl &&
|
|
|
|
|
|
this.isIconReady(iconUrl) &&
|
|
|
|
|
|
feature.get('_legendVisible') !== false &&
|
|
|
|
|
|
feature.get('_regionVisible') !== false &&
|
|
|
|
|
|
this.shouldRenderFeatureByDensity(feature, currentZoom) &&
|
|
|
|
|
|
this.shouldRenderNearbyFeature(feature, currentZoom);
|
|
|
|
|
|
const hasLabel = !!this.formatPointLabelText(
|
|
|
|
|
|
feature.get('_labelText') as string
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
this.syncFeatureIconCollisionVisible(feature, shouldRenderIcon);
|
|
|
|
|
|
this.syncFeatureLabelCollisionVisible(
|
|
|
|
|
|
feature,
|
|
|
|
|
|
shouldRenderIcon && hasLabel
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
viewportFeatures.forEach(feature => {
|
|
|
|
|
|
const iconUrl = feature.get('_iconUrl') as string;
|
|
|
|
|
|
const shouldRenderIcon =
|
|
|
|
|
|
!!iconUrl &&
|
|
|
|
|
|
this.isIconReady(iconUrl) &&
|
|
|
|
|
|
feature.get('_legendVisible') !== false &&
|
|
|
|
|
|
feature.get('_regionVisible') !== false &&
|
|
|
|
|
|
this.shouldRenderFeatureByDensity(feature, currentZoom) &&
|
|
|
|
|
|
this.shouldRenderNearbyFeature(feature, currentZoom);
|
|
|
|
|
|
|
|
|
|
|
|
if (!shouldRenderIcon) {
|
|
|
|
|
|
this.syncFeatureIconCollisionVisible(feature, false);
|
|
|
|
|
|
this.syncFeatureLabelCollisionVisible(feature, false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const geometry = feature.getGeometry();
|
|
|
|
|
|
if (!geometry || geometry.getType() !== 'Point') {
|
|
|
|
|
|
this.syncFeatureIconCollisionVisible(feature, false);
|
|
|
|
|
|
this.syncFeatureLabelCollisionVisible(feature, false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const coordinates = (geometry as any).getCoordinates?.();
|
|
|
|
|
|
if (!coordinates) {
|
|
|
|
|
|
this.syncFeatureIconCollisionVisible(feature, false);
|
|
|
|
|
|
this.syncFeatureLabelCollisionVisible(feature, false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pixel = this.map?.getPixelFromCoordinate(coordinates);
|
|
|
|
|
|
if (!pixel) {
|
|
|
|
|
|
this.syncFeatureIconCollisionVisible(feature, false);
|
|
|
|
|
|
this.syncFeatureLabelCollisionVisible(feature, false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const dynamicScale = this.getDynamicPointScale(currentZoom);
|
|
|
|
|
|
// 备注:图标碰撞盒适当小于视觉图标尺寸,允许相邻站点轻微贴近显示,
|
|
|
|
|
|
// 避免像“小浪底/三门峡”这类近点被过早裁掉成只剩一个点。
|
|
|
|
|
|
const iconCollisionSize = Math.max(12, 16 * dynamicScale);
|
|
|
|
|
|
const iconPadding = 0;
|
|
|
|
|
|
const iconLeft = pixel[0] - iconCollisionSize / 2 - iconPadding;
|
|
|
|
|
|
const iconRight = pixel[0] + iconCollisionSize / 2 + iconPadding;
|
|
|
|
|
|
const iconTop = pixel[1] - iconCollisionSize / 2 - iconPadding;
|
|
|
|
|
|
const iconBottom = pixel[1] + iconCollisionSize / 2 + iconPadding;
|
|
|
|
|
|
const labelText = this.formatPointLabelText(
|
|
|
|
|
|
feature.get('_labelText') as string
|
|
|
|
|
|
);
|
|
|
|
|
|
const fontSize = this.getPointFontSize(dynamicScale);
|
|
|
|
|
|
const lines = labelText ? labelText.split('\n') : [''];
|
|
|
|
|
|
const maxLineLength = Math.max(...lines.map(line => line.length), 1);
|
|
|
|
|
|
const labelLineCount = lines.length;
|
|
|
|
|
|
const labelOffsetY = this.getPointLabelCollisionOffsetY(
|
|
|
|
|
|
dynamicScale,
|
|
|
|
|
|
labelLineCount
|
|
|
|
|
|
);
|
|
|
|
|
|
const estimatedWidth = maxLineLength * fontSize * 0.6 + 16;
|
|
|
|
|
|
const estimatedHeight = labelLineCount * (fontSize + 4) + 8;
|
|
|
|
|
|
const centerX = pixel[0];
|
|
|
|
|
|
const centerY = pixel[1] + labelOffsetY;
|
|
|
|
|
|
const candidateId = String(
|
|
|
|
|
|
feature.getId?.() || feature.get('stcd') || ''
|
|
|
|
|
|
);
|
|
|
|
|
|
const dedupeKey =
|
|
|
|
|
|
candidateId ||
|
|
|
|
|
|
[
|
|
|
|
|
|
feature.get('_layerKey') || '',
|
|
|
|
|
|
pixel[0].toFixed(2),
|
|
|
|
|
|
pixel[1].toFixed(2),
|
|
|
|
|
|
labelText
|
|
|
|
|
|
].join('|');
|
|
|
|
|
|
|
|
|
|
|
|
candidateMap.set(dedupeKey, {
|
|
|
|
|
|
feature,
|
|
|
|
|
|
iconLeft,
|
|
|
|
|
|
iconRight,
|
|
|
|
|
|
iconTop,
|
|
|
|
|
|
iconBottom,
|
|
|
|
|
|
hasLabel: !!labelText,
|
|
|
|
|
|
left: centerX - estimatedWidth / 2,
|
|
|
|
|
|
right: centerX + estimatedWidth / 2,
|
|
|
|
|
|
top: centerY - estimatedHeight / 2,
|
|
|
|
|
|
bottom: centerY + estimatedHeight / 2,
|
|
|
|
|
|
priority: Number(feature.get('_nearbyPriority') || 9999),
|
|
|
|
|
|
densityPriority: this.getFeatureDensityPriority(feature),
|
|
|
|
|
|
id: candidateId,
|
|
|
|
|
|
pixelX: pixel[0],
|
|
|
|
|
|
pixelY: pixel[1]
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
candidateMap.forEach(candidate => {
|
|
|
|
|
|
candidates.push(candidate);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
candidates.sort((left, right) => {
|
|
|
|
|
|
if (left.priority !== right.priority) {
|
|
|
|
|
|
return left.priority - right.priority;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (left.densityPriority !== right.densityPriority) {
|
|
|
|
|
|
return left.densityPriority - right.densityPriority;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (left.pixelY !== right.pixelY) {
|
|
|
|
|
|
return left.pixelY - right.pixelY;
|
|
|
|
|
|
}
|
|
|
|
|
|
return left.id.localeCompare(right.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const placedIconRects: Array<{
|
|
|
|
|
|
featureId: string;
|
|
|
|
|
|
left: number;
|
|
|
|
|
|
right: number;
|
|
|
|
|
|
top: number;
|
|
|
|
|
|
bottom: number;
|
|
|
|
|
|
}> = [];
|
|
|
|
|
|
const placedLabelRects: Array<{
|
|
|
|
|
|
featureId: string;
|
|
|
|
|
|
left: number;
|
|
|
|
|
|
right: number;
|
|
|
|
|
|
top: number;
|
|
|
|
|
|
bottom: number;
|
|
|
|
|
|
}> = [];
|
|
|
|
|
|
let hiddenByIconCollisionCount = 0;
|
|
|
|
|
|
let hiddenByLabelCollisionCount = 0;
|
|
|
|
|
|
let hiddenByIconHiddenCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
candidates.forEach(candidate => {
|
|
|
|
|
|
const featureId = String(
|
|
|
|
|
|
candidate.feature.getId?.() || candidate.feature.get('stcd') || ''
|
|
|
|
|
|
);
|
|
|
|
|
|
const iconRect = {
|
|
|
|
|
|
left: candidate.iconLeft,
|
|
|
|
|
|
right: candidate.iconRight,
|
|
|
|
|
|
top: candidate.iconTop,
|
|
|
|
|
|
bottom: candidate.iconBottom
|
|
|
|
|
|
};
|
|
|
|
|
|
const iconHasCollision = placedIconRects.some(rect =>
|
|
|
|
|
|
this.checkLabelCollision(rect, iconRect)
|
|
|
|
|
|
);
|
|
|
|
|
|
const iconVisible = !iconHasCollision;
|
|
|
|
|
|
this.syncFeatureIconCollisionVisible(candidate.feature, iconVisible);
|
|
|
|
|
|
|
|
|
|
|
|
if (iconVisible) {
|
|
|
|
|
|
placedIconRects.push({
|
|
|
|
|
|
featureId,
|
|
|
|
|
|
left: iconRect.left,
|
|
|
|
|
|
right: iconRect.right,
|
|
|
|
|
|
top: iconRect.top,
|
|
|
|
|
|
bottom: iconRect.bottom
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const labelCandidates = [...candidates].sort((left, right) => {
|
|
|
|
|
|
if (left.priority !== right.priority) {
|
|
|
|
|
|
return left.priority - right.priority;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (left.pixelY !== right.pixelY) {
|
|
|
|
|
|
return left.pixelY - right.pixelY;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (left.pixelX !== right.pixelX) {
|
|
|
|
|
|
return left.pixelX - right.pixelX;
|
|
|
|
|
|
}
|
|
|
|
|
|
return left.id.localeCompare(right.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
labelCandidates.forEach(candidate => {
|
|
|
|
|
|
const featureId = String(
|
|
|
|
|
|
candidate.feature.getId?.() || candidate.feature.get('stcd') || ''
|
|
|
|
|
|
);
|
|
|
|
|
|
const iconVisible =
|
|
|
|
|
|
candidate.feature.get('_iconCollisionVisible') !== false;
|
|
|
|
|
|
if (!candidate.hasLabel) {
|
|
|
|
|
|
this.syncFeatureLabelCollisionVisible(candidate.feature, false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!iconVisible) {
|
|
|
|
|
|
hiddenByIconHiddenCount += 1;
|
|
|
|
|
|
this.syncFeatureLabelCollisionVisible(candidate.feature, false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const collidingIconRect = placedIconRects.find(rect => {
|
|
|
|
|
|
if (rect.featureId === featureId) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return this.checkLabelCollisionWithIcon(rect, candidate);
|
|
|
|
|
|
});
|
|
|
|
|
|
const hasCollisionWithIcon = !!collidingIconRect;
|
|
|
|
|
|
const collidingLabelRect = placedLabelRects.find(rect =>
|
|
|
|
|
|
this.checkLabelCollision(rect, candidate)
|
|
|
|
|
|
);
|
|
|
|
|
|
const hasCollisionWithLabel = !!collidingLabelRect;
|
|
|
|
|
|
const visible = !hasCollisionWithIcon && !hasCollisionWithLabel;
|
|
|
|
|
|
|
|
|
|
|
|
if (!visible) {
|
|
|
|
|
|
if (hasCollisionWithIcon) {
|
|
|
|
|
|
hiddenByIconCollisionCount += 1;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
hiddenByLabelCollisionCount += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.syncFeatureLabelCollisionVisible(candidate.feature, visible);
|
|
|
|
|
|
|
|
|
|
|
|
if (visible) {
|
|
|
|
|
|
placedLabelRects.push({
|
|
|
|
|
|
featureId,
|
|
|
|
|
|
left: candidate.left,
|
|
|
|
|
|
right: candidate.right,
|
|
|
|
|
|
top: candidate.top,
|
|
|
|
|
|
bottom: candidate.bottom
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private requestRefreshPointLabelVisibility(immediate = false) {
|
|
|
|
|
|
if (immediate) {
|
|
|
|
|
|
if (this.labelVisibilityRefreshFrameId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
|
|
|
|
|
|
this.labelVisibilityRefreshFrameId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.refreshPointLabelVisibility();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.labelVisibilityRefreshFrameId !== null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.labelVisibilityRefreshFrameId = window.requestAnimationFrame(() => {
|
|
|
|
|
|
this.labelVisibilityRefreshFrameId = null;
|
|
|
|
|
|
this.refreshPointLabelVisibility();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private syncFeatureIconCollisionVisible(feature: Feature, visible: boolean) {
|
|
|
|
|
|
if (feature.get('_iconCollisionVisible') !== visible) {
|
|
|
|
|
|
feature.set('_iconCollisionVisible', visible);
|
|
|
|
|
|
feature.changed();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private syncFeatureLabelCollisionVisible(feature: Feature, visible: boolean) {
|
|
|
|
|
|
if (feature.get('_labelCollisionVisible') !== visible) {
|
|
|
|
|
|
feature.set('_labelCollisionVisible', visible);
|
|
|
|
|
|
feature.changed();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private isIconReady(iconUrl: string): boolean {
|
|
|
|
|
|
return this.iconLoadState.get(iconUrl) === 'loaded';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private ensureIconReady(iconUrl: string): boolean {
|
|
|
|
|
|
if (!iconUrl) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const status = this.iconLoadState.get(iconUrl);
|
|
|
|
|
|
if (status === 'loaded') {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (status === 'loading' || status === 'error') {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.iconLoadState.set(iconUrl, 'loading');
|
|
|
|
|
|
const image = new Image();
|
|
|
|
|
|
image.crossOrigin = 'anonymous';
|
|
|
|
|
|
image.onload = () => {
|
|
|
|
|
|
this.iconLoadState.set(iconUrl, 'loaded');
|
|
|
|
|
|
this.refreshPointLayerStyles();
|
|
|
|
|
|
};
|
|
|
|
|
|
image.onerror = () => {
|
|
|
|
|
|
this.iconLoadState.set(iconUrl, 'error');
|
|
|
|
|
|
this.refreshPointLayerStyles();
|
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) {
|
|
|
|
|
|
this.iconLoadState.set(iconUrl, 'loaded');
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-05-15 17:38:03 +08:00
|
|
|
|
|
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-01 08:48:39 +08:00
|
|
|
|
this.requestRefreshPointLabelVisibility(true);
|
|
|
|
|
|
if (this.isBatchPopupMode) {
|
|
|
|
|
|
this.requestUpdateBatchPopups(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private requestUpdateBatchPopups(immediate = false) {
|
|
|
|
|
|
if (!this.isBatchPopupMode) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (immediate) {
|
|
|
|
|
|
if (this.batchPopupRefreshFrameId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(this.batchPopupRefreshFrameId);
|
|
|
|
|
|
this.batchPopupRefreshFrameId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.updateBatchPopups();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.batchPopupRefreshFrameId !== null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.batchPopupRefreshFrameId = window.requestAnimationFrame(() => {
|
|
|
|
|
|
this.batchPopupRefreshFrameId = null;
|
|
|
|
|
|
if (!this.isBatchPopupMode) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.updateBatchPopups();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private checkLabelCollision(
|
|
|
|
|
|
left: { left: number; right: number; top: number; bottom: number },
|
|
|
|
|
|
right: { left: number; right: number; top: number; bottom: number }
|
|
|
|
|
|
) {
|
|
|
|
|
|
const padding = 4;
|
|
|
|
|
|
return !(
|
|
|
|
|
|
left.right + padding < right.left ||
|
|
|
|
|
|
left.left - padding > right.right ||
|
|
|
|
|
|
left.bottom + padding < right.top ||
|
|
|
|
|
|
left.top - padding > right.bottom
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private checkLabelCollisionWithIcon(
|
|
|
|
|
|
iconRect: { left: number; right: number; top: number; bottom: number },
|
|
|
|
|
|
labelRect: { left: number; right: number; top: number; bottom: number }
|
|
|
|
|
|
) {
|
|
|
|
|
|
// 标签避让图标时仅保留图标中心更小的保护区,并允许边缘接触不算碰撞,
|
|
|
|
|
|
// 避免像“三峡/黄龙滩”这类仅在边界轻微擦到时把文字过度压掉。
|
|
|
|
|
|
const inset = 4;
|
|
|
|
|
|
const padding = 0;
|
|
|
|
|
|
const narrowedIconRect = {
|
|
|
|
|
|
left: iconRect.left + inset,
|
|
|
|
|
|
right: iconRect.right - inset,
|
|
|
|
|
|
top: iconRect.top + inset,
|
|
|
|
|
|
bottom: iconRect.bottom - inset
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return !(
|
|
|
|
|
|
narrowedIconRect.right + padding <= labelRect.left ||
|
|
|
|
|
|
narrowedIconRect.left - padding >= labelRect.right ||
|
|
|
|
|
|
narrowedIconRect.bottom + padding <= labelRect.top ||
|
|
|
|
|
|
narrowedIconRect.top - padding >= labelRect.bottom
|
|
|
|
|
|
);
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 统一计算标签碰撞时的纵向偏移。
|
|
|
|
|
|
* 这里允许比视觉位置略高一点,避免相邻点图标过早压掉主标签。
|
2026-06-03 15:03:31 +08:00
|
|
|
|
*/
|
2026-07-01 08:48:39 +08:00
|
|
|
|
private getPointLabelCollisionOffsetY(
|
|
|
|
|
|
dynamicScale: number,
|
|
|
|
|
|
labelLineCount: number
|
|
|
|
|
|
): number {
|
|
|
|
|
|
return labelLineCount > 1 ? -36 * dynamicScale : -28 * dynamicScale;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* distance 字段表示点位密度分档,不是物理距离。
|
|
|
|
|
|
* 这里仅用它决定点位从哪个缩放级别开始参与显示候选。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getFeatureDensityValue(feature: Feature): number | null {
|
|
|
|
|
|
const rawDistance = feature.get('distance');
|
|
|
|
|
|
if (
|
|
|
|
|
|
rawDistance === undefined ||
|
|
|
|
|
|
rawDistance === null ||
|
|
|
|
|
|
rawDistance === ''
|
|
|
|
|
|
) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const densityValue = Number(rawDistance);
|
|
|
|
|
|
return Number.isFinite(densityValue) ? densityValue : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private getFeatureDensityPriority(feature: Feature): number {
|
|
|
|
|
|
const densityValue = this.getFeatureDensityValue(feature);
|
|
|
|
|
|
const densityDisplayRules = getNearbyPointDensityDisplayRules();
|
|
|
|
|
|
if (densityValue === null) {
|
|
|
|
|
|
return densityDisplayRules.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const matchedRuleIndex = densityDisplayRules.findIndex(rule => {
|
|
|
|
|
|
return densityValue >= rule.minDensityValue;
|
|
|
|
|
|
});
|
|
|
|
|
|
return matchedRuleIndex >= 0
|
|
|
|
|
|
? matchedRuleIndex
|
|
|
|
|
|
: densityDisplayRules.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private getFeatureDensityMinZoom(feature: Feature): number {
|
|
|
|
|
|
const densityValue = this.getFeatureDensityValue(feature);
|
|
|
|
|
|
if (densityValue === null) {
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const densityDisplayRules = getNearbyPointDensityDisplayRules();
|
|
|
|
|
|
const matchedRule = densityDisplayRules.find(rule => {
|
|
|
|
|
|
return densityValue >= rule.minDensityValue;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (matchedRule) {
|
|
|
|
|
|
return matchedRule.minZoom;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return densityDisplayRules.at(-1)?.minZoom ?? 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 如果点位在当前视图中没有近邻点,则允许跳过静态密度门槛直接参与显示。
|
|
|
|
|
|
* 这样像“戈兰滩”这类 distance 档位偏密、但当前周围实际很空的点也能出现。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private shouldBypassDensityGateForIsolatedFeature(feature: Feature): boolean {
|
|
|
|
|
|
if (!this.map) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const layerKey = String(feature.get('_layerKey') || '');
|
|
|
|
|
|
const geometry = feature.getGeometry();
|
|
|
|
|
|
if (!geometry || geometry.getType() !== 'Point') {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const featurePixel = this.map.getPixelFromCoordinate(
|
|
|
|
|
|
(geometry as Point).getCoordinates()
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!featurePixel) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const nearbyPixelThreshold = 56;
|
|
|
|
|
|
const viewportFeatures = this.pointLayerManager.getFeaturesInViewport();
|
|
|
|
|
|
|
|
|
|
|
|
return !viewportFeatures.some(otherFeature => {
|
|
|
|
|
|
if (otherFeature === feature) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (otherFeature.get('_legendVisible') === false) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (otherFeature.get('_regionVisible') === false) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (String(otherFeature.get('_layerKey') || '') !== layerKey) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const otherGeometry = otherFeature.getGeometry();
|
|
|
|
|
|
if (!otherGeometry || otherGeometry.getType() !== 'Point') {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const otherPixel = this.map?.getPixelFromCoordinate(
|
|
|
|
|
|
(otherGeometry as Point).getCoordinates()
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!otherPixel) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
Math.hypot(
|
|
|
|
|
|
otherPixel[0] - featurePixel[0],
|
|
|
|
|
|
otherPixel[1] - featurePixel[1]
|
|
|
|
|
|
) <= nearbyPixelThreshold
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private shouldRenderFeatureByDensity(
|
|
|
|
|
|
feature: Feature,
|
|
|
|
|
|
currentZoom: number
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
const minZoom = this.getFeatureDensityMinZoom(feature);
|
|
|
|
|
|
const bypassDensityGate =
|
|
|
|
|
|
currentZoom < minZoom &&
|
|
|
|
|
|
this.shouldBypassDensityGateForIsolatedFeature(feature);
|
|
|
|
|
|
return currentZoom >= minZoom || bypassDensityGate;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 统一根据近邻点分组和缩放阶段判断当前要素是否应该参与渲染。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private shouldRenderNearbyFeature(
|
2026-06-03 15:03:31 +08:00
|
|
|
|
feature: Feature,
|
|
|
|
|
|
currentZoom: number
|
|
|
|
|
|
): boolean {
|
2026-07-01 08:48:39 +08:00
|
|
|
|
const nearbyGroupId = feature.get('_nearbyGroupId');
|
|
|
|
|
|
const groupSize = Number(feature.get('_nearbyGroupSize'));
|
|
|
|
|
|
const priority = Number(feature.get('_nearbyPriority'));
|
|
|
|
|
|
const replaceAtZoom = Number(feature.get('_nearbyReplaceAtZoom'));
|
|
|
|
|
|
const expandAtZoom = Number(feature.get('_nearbyExpandAtZoom'));
|
|
|
|
|
|
const showZoomMin = feature.get('_nearbyShowZoomMin');
|
|
|
|
|
|
const showZoomMax = feature.get('_nearbyShowZoomMax');
|
|
|
|
|
|
|
|
|
|
|
|
if (!nearbyGroupId || !Number.isFinite(groupSize) || groupSize < 2) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!Number.isFinite(priority) || priority <= 0) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
if (
|
|
|
|
|
|
showZoomMin !== null &&
|
|
|
|
|
|
showZoomMin !== undefined &&
|
|
|
|
|
|
currentZoom < Number(showZoomMin)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return false;
|
2026-06-03 15:03:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:48:39 +08:00
|
|
|
|
if (
|
|
|
|
|
|
showZoomMax !== null &&
|
|
|
|
|
|
showZoomMax !== undefined &&
|
|
|
|
|
|
currentZoom > Number(showZoomMax)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Number.isFinite(expandAtZoom) && currentZoom >= expandAtZoom) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Number.isFinite(replaceAtZoom) && currentZoom >= replaceAtZoom) {
|
|
|
|
|
|
return priority <= Math.min(2, groupSize);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return priority === 1;
|
2026-06-03 15:03:31 +08:00
|
|
|
|
}
|
2026-05-25 08:54:17 +08:00
|
|
|
|
private createPointStyle1(feature: Feature): Style {
|
|
|
|
|
|
const color = (feature.get('iconCode') as string) || '#3399CC';
|
|
|
|
|
|
const labelText = feature.get('titleName') as string;
|
2026-06-03 15:03:31 +08:00
|
|
|
|
const buildType = (feature.get('BuildType') as number) ?? 1;
|
2026-05-25 08:54:17 +08:00
|
|
|
|
|
|
|
|
|
|
const currentZoom = this.view ? this.view.getZoom() : 4.5;
|
|
|
|
|
|
let dynamicScale = 0.7 + (currentZoom - 4.5) * 0.08;
|
|
|
|
|
|
dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale));
|
|
|
|
|
|
const fontSize = Math.max(16, Math.min(24, 12 * dynamicScale));
|
|
|
|
|
|
const radius = Math.max(6, Math.min(18, 6 * dynamicScale));
|
|
|
|
|
|
|
|
|
|
|
|
let imageStyle;
|
|
|
|
|
|
if (buildType === 0) {
|
|
|
|
|
|
const canvas = this.createSemiCircleCanvas(radius, color);
|
|
|
|
|
|
imageStyle = new Icon({
|
|
|
|
|
|
img: canvas,
|
|
|
|
|
|
imgSize: [canvas.width, canvas.height],
|
2026-06-03 15:03:31 +08:00
|
|
|
|
anchor: [0.5, 0.5]
|
2026-05-25 08:54:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
imageStyle = new Circle({
|
|
|
|
|
|
radius: radius,
|
|
|
|
|
|
fill: new Fill({ color: color }),
|
|
|
|
|
|
stroke: new Stroke({ color: 'rgba(0, 0, 0, 0.5)', width: 1 })
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const styleOptions: any = {
|
|
|
|
|
|
image: imageStyle
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (labelText) {
|
|
|
|
|
|
let offsetY = -20 * dynamicScale;
|
|
|
|
|
|
const specialLabels = [
|
|
|
|
|
|
'枕头坝二级',
|
|
|
|
|
|
'老鹰岩二级',
|
|
|
|
|
|
'长洲',
|
|
|
|
|
|
'丹巴',
|
|
|
|
|
|
'玛尔挡',
|
|
|
|
|
|
'班多',
|
|
|
|
|
|
'彭水',
|
|
|
|
|
|
'李家峡',
|
|
|
|
|
|
'ML',
|
|
|
|
|
|
'BDa',
|
|
|
|
|
|
'乌东德',
|
|
|
|
|
|
'里底',
|
|
|
|
|
|
'大华桥',
|
|
|
|
|
|
|
|
|
|
|
|
'巴塘'
|
|
|
|
|
|
];
|
|
|
|
|
|
if (specialLabels.includes(labelText)) {
|
|
|
|
|
|
offsetY = 20 * dynamicScale;
|
|
|
|
|
|
}
|
|
|
|
|
|
styleOptions.text = new Text({
|
|
|
|
|
|
text: labelText,
|
|
|
|
|
|
offsetY: offsetY,
|
|
|
|
|
|
font: `${fontSize}px sans-serif`,
|
|
|
|
|
|
fill: new Fill({ color: '#fff' }),
|
|
|
|
|
|
stroke: new Stroke({ color: 'rgba(0, 0, 0, .9)', width: 2 }),
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
declutterMode: 'declutter'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return new Style(styleOptions);
|
|
|
|
|
|
}
|
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-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;
|
|
|
|
|
|
} else if (layer.type == 'raster-dem') {
|
|
|
|
|
|
const tileLayer = new TileLayer({
|
|
|
|
|
|
source: new XYZ({
|
|
|
|
|
|
url: layer.url,
|
|
|
|
|
|
wrapX: true,
|
|
|
|
|
|
crossOrigin: 'anonymous'
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 生成带完整圆边框的上半圆 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-01 08:48:39 +08:00
|
|
|
|
if (this.labelVisibilityRefreshFrameId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
|
|
|
|
|
|
this.labelVisibilityRefreshFrameId = null;
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
this.removeQueryLayer();
|
2026-06-03 15:03:31 +08:00
|
|
|
|
this.regionMaskManager.destroy();
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|