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';
|
|
|
|
|
|
import RegularShape from 'ol/style/RegularShape';
|
2026-06-01 08:42:06 +08:00
|
|
|
|
import Cluster from 'ol/source/Cluster';
|
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,
|
|
|
|
|
|
DoubleClickZoom
|
|
|
|
|
|
} 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';
|
|
|
|
|
|
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-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;
|
|
|
|
|
|
|
|
|
|
|
|
// 定义边界 [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-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-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-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-05-15 17:38:03 +08:00
|
|
|
|
private async loadGeoJsonData(url): Promise<any> {
|
2026-05-13 08:44:35 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
return data;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('配置加载失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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,
|
|
|
|
|
|
dragPan: true,
|
|
|
|
|
|
|
|
|
|
|
|
pinchRotate: false // 通常禁用旋转,除非需要
|
|
|
|
|
|
}).extend([mouseWheelInteraction])
|
|
|
|
|
|
});
|
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();
|
|
|
|
|
|
const zoom = this.view.getZoom();
|
|
|
|
|
|
console.log('mouse zoom:', zoom);
|
|
|
|
|
|
});
|
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-10 15:45:16 +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-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-06-16 11:36:17 +08:00
|
|
|
|
// 批量 popup 模式下(>= 14 级)不触发 hover popup
|
|
|
|
|
|
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) {
|
|
|
|
|
|
this.updateBatchPopups();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
this.map.on('moveend', () => {
|
|
|
|
|
|
if (this.isBatchPopupMode) {
|
|
|
|
|
|
this.updateBatchPopups();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
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-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
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 监听缩放层级变化,>= 15 时自动显示可视区域内所有锚点的 popup
|
|
|
|
|
|
*/
|
|
|
|
|
|
private handleZoomChange() {
|
|
|
|
|
|
if (!this.view) return;
|
|
|
|
|
|
|
|
|
|
|
|
const zoom = this.view.getZoom();
|
|
|
|
|
|
if (zoom === undefined) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (zoom >= 15) {
|
|
|
|
|
|
this.enableBatchPopupMode();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.disableBatchPopupMode();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 启用批量 popup 模式:自动显示可视区域内所有锚点的 popup
|
|
|
|
|
|
*/
|
|
|
|
|
|
private enableBatchPopupMode() {
|
|
|
|
|
|
this.isBatchPopupMode = true;
|
|
|
|
|
|
this.popupManager.clearBatchPopups();
|
|
|
|
|
|
this.updateBatchPopups();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 禁用批量 popup 模式:恢复鼠标悬停显示 popup
|
|
|
|
|
|
*/
|
|
|
|
|
|
private disableBatchPopupMode() {
|
|
|
|
|
|
this.isBatchPopupMode = false;
|
|
|
|
|
|
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 效果,支持随缩放动态调整大小)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private createPointStyle(feature: Feature): Style {
|
|
|
|
|
|
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-06-01 08:42:06 +08:00
|
|
|
|
if (legendVisible === false) {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
return null;
|
2026-06-01 08:42:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (regionVisible === false) {
|
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-06-03 15:03:31 +08:00
|
|
|
|
if (!this.shouldRenderFeatureByDistance(feature, currentZoom)) {
|
|
|
|
|
|
return null;
|
2026-06-01 08:42:06 +08:00
|
|
|
|
}
|
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;
|
|
|
|
|
|
const labelOffsetY =
|
|
|
|
|
|
labelLineCount > 1 ? -30 * dynamicScale : -22 * dynamicScale;
|
2026-04-20 16:58:33 +08:00
|
|
|
|
|
2026-05-15 17:38:03 +08:00
|
|
|
|
const styleOptions: any = {
|
2026-04-20 16:58:33 +08:00
|
|
|
|
image: new Icon({
|
|
|
|
|
|
src: iconUrl,
|
2026-05-15 17:38:03 +08:00
|
|
|
|
scale: dynamicScale,
|
|
|
|
|
|
anchor: [0.5, 0.5],
|
2026-06-01 08:42:06 +08:00
|
|
|
|
crossOrigin: 'anonymous',
|
2026-06-03 15:03:31 +08:00
|
|
|
|
declutterMode: 'declutter'
|
2026-04-20 16:58:33 +08:00
|
|
|
|
})
|
2026-05-15 17:38:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
styleOptions.text = new Text({
|
2026-06-15 09:18:00 +08:00
|
|
|
|
text: formattedLabelText,
|
|
|
|
|
|
offsetY: labelOffsetY,
|
2026-05-15 17:38:03 +08:00
|
|
|
|
font: `${fontSize}px sans-serif`,
|
|
|
|
|
|
fill: new Fill({ color: '#fff' }),
|
|
|
|
|
|
stroke: new Stroke({ color: 'rgba(0, 0, 0, .9)', width: 2 }),
|
|
|
|
|
|
textAlign: 'center',
|
2026-06-03 15:03:31 +08:00
|
|
|
|
declutterMode: 'declutter'
|
2026-04-20 16:58:33 +08:00
|
|
|
|
});
|
2026-05-15 17:38:03 +08:00
|
|
|
|
return new Style(styleOptions);
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 统一根据缩放级别和点位距离判断要素是否需要参与渲染,减少样式函数中的业务判断复杂度。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private shouldRenderFeatureByDistance(
|
|
|
|
|
|
feature: Feature,
|
|
|
|
|
|
currentZoom: number
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
const distance = feature.get('distance');
|
|
|
|
|
|
if (distance === undefined || distance === null) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let minDistanceRequired = 0;
|
|
|
|
|
|
if (currentZoom <= 6) {
|
|
|
|
|
|
minDistanceRequired = 1500000;
|
|
|
|
|
|
} else if (currentZoom <= 8) {
|
|
|
|
|
|
minDistanceRequired = 800000;
|
|
|
|
|
|
} else if (currentZoom <= 10.5) {
|
|
|
|
|
|
minDistanceRequired = 400000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return distance > minDistanceRequired;
|
|
|
|
|
|
}
|
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-06-01 08:42:06 +08:00
|
|
|
|
addBaseDataLayer(
|
|
|
|
|
|
layer: any,
|
|
|
|
|
|
isShow: boolean,
|
|
|
|
|
|
isCache: boolean = 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;
|
|
|
|
|
|
let url = !isCache ? layer.url : layer.url_3d;
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-05-13 08:44:35 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const url =
|
|
|
|
|
|
this.hydropBaseConfig.geojson_url +
|
|
|
|
|
|
`&cql_filter=BASEID='${this.BASEID}'`;
|
|
|
|
|
|
|
2026-05-15 17:38:03 +08:00
|
|
|
|
const geoJsonData = await this.loadGeoJsonData(url);
|
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-05-13 08:44:35 +08:00
|
|
|
|
} catch (error) {
|
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-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) {
|
|
|
|
|
|
this.updateBatchPopups();
|
|
|
|
|
|
}
|
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-06-16 11:36:17 +08:00
|
|
|
|
// 图层显隐变化时刷新 batch popup
|
|
|
|
|
|
if (this.isBatchPopupMode) {
|
|
|
|
|
|
this.updateBatchPopups();
|
|
|
|
|
|
}
|
2026-04-20 16:58:33 +08:00
|
|
|
|
}
|
2026-06-01 08:42:06 +08:00
|
|
|
|
|
|
|
|
|
|
hasLayer(layerType: string): void {
|
2026-06-03 15:03:31 +08:00
|
|
|
|
return this.pointLayerManager.hasLayer(layerType as string);
|
2026-06-01 08:42:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
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) {
|
|
|
|
|
|
this.updateBatchPopups();
|
|
|
|
|
|
}
|
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-06-16 11:36:17 +08:00
|
|
|
|
// 图例变化时刷新 batch popup
|
|
|
|
|
|
if (this.isBatchPopupMode) {
|
|
|
|
|
|
this.updateBatchPopups();
|
|
|
|
|
|
}
|
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 {
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|