1752 lines
56 KiB
TypeScript
1752 lines
56 KiB
TypeScript
import { MapInterface } from './map';
|
||
import OlMap from 'ol/Map';
|
||
import View from 'ol/View';
|
||
import TileLayer from 'ol/layer/Tile';
|
||
import VectorLayer from 'ol/layer/Vector';
|
||
import VectorSource from 'ol/source/Vector';
|
||
import GeoJSON from 'ol/format/GeoJSON';
|
||
import Style from 'ol/style/Style';
|
||
import Fill from 'ol/style/Fill';
|
||
import Stroke from 'ol/style/Stroke';
|
||
// ✅ 新增导入
|
||
import Icon from 'ol/style/Icon';
|
||
import Text from 'ol/style/Text';
|
||
import Circle from 'ol/style/Circle';
|
||
import RegularShape from 'ol/style/RegularShape';
|
||
|
||
// import LayerGroup from 'ol/layer/Group';
|
||
// import OSM from 'ol/source/OSM';
|
||
import WMTS from 'ol/source/WMTS';
|
||
import WMTSTileGrid from 'ol/tilegrid/WMTS';
|
||
import { get as getProjection, fromLonLat } from 'ol/proj';
|
||
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 Point from 'ol/geom/Point'; // ✅ 新增导入
|
||
import LineString from 'ol/geom/LineString';
|
||
// import Polygon from 'ol/geom/Polygon';
|
||
import Overlay from 'ol/Overlay';
|
||
import {
|
||
getLength as getSphericalLength,
|
||
getArea as getSphericalArea
|
||
} from 'ol/sphere';
|
||
import { unByKey } from 'ol/Observable';
|
||
import { MDOptions } from './map.class';
|
||
import { getIconPath } from '@/utils/index'; // ✅ 确保引入获取图标路径的工具函数
|
||
|
||
// 定义与 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;
|
||
// ✅ 新增:存储 key -> layer 实例,用于控制显示隐藏和切换
|
||
private layerRegistry: Map<string, any> = new Map();
|
||
// ✅ 新增:记录当前正在显示的“切换 Key”,用于判断是否需要重新加载或只是切换显隐
|
||
// 基础图层配置
|
||
private baseLayerConfig: TileLayer | null = null;
|
||
// 基地配置
|
||
private hydropBaseConfig: any | null = null;
|
||
private REGISTRY_KEY = 'customBaseLayer';
|
||
|
||
// ✅ 新增:量算相关属性
|
||
private drawInteraction: any = null;
|
||
private measureLayer: VectorLayer | null = null;
|
||
private measureSource: VectorSource | null = null;
|
||
// ✅ 新增:存储点图层的 Registry,key 为 layerType,value 为 VectorLayer
|
||
private pointLayerRegistry: Map<string, VectorLayer> = new Map();
|
||
geoJsonData: any = null;
|
||
geoJsonData1: any = null;
|
||
// ✅ 新增:声明用于存储 mask 事件监听器的引用,以便后续移除
|
||
private _maskPrerender: any = null;
|
||
private _maskPostrender: any = null;
|
||
private BASEID: string = '';
|
||
// ✅ 新增:记录当前鼠标悬停的 Feature ID
|
||
private hoveredFeatureId: string | number | null = null;
|
||
// ✅ 新增:用于显示自定义内容的 Overlay
|
||
private popupOverlay: Overlay | null = null;
|
||
private popupElement: HTMLElement | null = null;
|
||
constructor() {}
|
||
private async loadGeoJsonData(url): Promise<any> {
|
||
try {
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
console.error('配置加载失败:', error);
|
||
}
|
||
}
|
||
//地图初始化
|
||
init(container: HTMLElement): Promise<any> {
|
||
try {
|
||
console.log('OL init初始化container', container);
|
||
|
||
// 1. 将经纬度边界转换为 Web Mercator (EPSG:3857) 坐标,用于限制视图范围
|
||
const minCoordinate = fromLonLat(BOUNDS_SW);
|
||
const maxCoordinate = fromLonLat(BOUNDS_NE);
|
||
const extent = [
|
||
minCoordinate[0],
|
||
minCoordinate[1],
|
||
maxCoordinate[0],
|
||
maxCoordinate[1]
|
||
];
|
||
|
||
// 2. 创建视图 (View)
|
||
this.view = new View({
|
||
center: fromLonLat(CENTER_positionCN), // 设置中心点
|
||
zoom: INITIAL_ZOOM,
|
||
minZoom: MIN_ZOOM,
|
||
maxZoom: MAX_ZOOM,
|
||
constrainOnlyCenter: false, // 允许部分视图超出边界,但中心点受限(类似 Leaflet 默认行为)
|
||
extent: extent, // 限制视图范围
|
||
smoothExtentConstraint: false // 平滑边界约束
|
||
});
|
||
|
||
// ✅ 3. 自定义鼠标滚轮交互
|
||
// Leaflet 的 wheelPxPerZoomLevel: 180 意味着缩放比较“慢/平滑”。
|
||
// OpenLayers 默认每次滚动 delta 为 1/3 级。
|
||
// 我们可以减小 delta 值来让缩放更平滑(需要更多滚动次数才能变一级),
|
||
// 或者使用 constrainResolution: false 允许非整数缩放,获得更丝滑的效果。
|
||
const mouseWheelInteraction = new MouseWheelZoom({
|
||
duration: 100, // 缩放动画持续时间 (ms),Leaflet 默认也有动画
|
||
maxDelta: 2,
|
||
constrainResolution: false // ✅ 关键:false 允许缩放到非整数级别 (如 4.5, 4.6),实现平滑缩放
|
||
});
|
||
|
||
// 4. 创建地图实例
|
||
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])
|
||
});
|
||
|
||
// 5. 监听缩放结束事件 (对应 Leaflet 的 'zoomend')
|
||
this.view.on('change:resolution', () => {
|
||
// OpenLayers 没有直接的 'zoomend' 事件,通常监听 resolution 变化
|
||
// 如果需要防抖处理,可以添加 debounce
|
||
console.log('当前缩放级别', this.view?.getZoom());
|
||
});
|
||
|
||
// ✅ 新增:监听鼠标移动,动态改变光标样式
|
||
let lastHoveredId: string | number | null = null;
|
||
let animationFrameId: number | null = null;
|
||
// pointermove
|
||
this.map.on('click', evt => {
|
||
const pixel = evt.pixel;
|
||
|
||
// 取消之前的动画帧,确保只处理最新的一次移动
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId);
|
||
}
|
||
|
||
animationFrameId = requestAnimationFrame(() => {
|
||
let detectedFeature: Feature | undefined = undefined;
|
||
let isHitIcon: boolean = false; // 标记是否真正命中了图标区域
|
||
|
||
// 1. 获取当前视口下的所有点图层
|
||
const pointLayers = Array.from(this.pointLayerRegistry.values());
|
||
|
||
// 2. 遍历检测,但我们需要更精细的判断
|
||
this.map?.forEachFeatureAtPixel(
|
||
pixel,
|
||
(feature, layer) => {
|
||
// 只关心点图层
|
||
if (pointLayers.includes(layer)) {
|
||
detectedFeature = feature as Feature;
|
||
|
||
// ✅ 关键优化:判断是否命中图标区域
|
||
// OpenLayers 的 Icon 样式通常有 anchor [0.5, 0.5]
|
||
// 我们可以获取图标的尺寸,判断鼠标是否在图标矩形范围内
|
||
const style = (layer as VectorLayer).getStyle();
|
||
let iconSize = { width: 0, height: 0 };
|
||
|
||
// 尝试从样式中获取图标尺寸(如果样式是函数,可能需要调用它,这里简化处理,假设固定大小或从 feature 获取)
|
||
// 更通用的做法是:计算鼠标点到 Feature 坐标投影后的像素距离
|
||
const geom = feature.getGeometry();
|
||
if (geom && geom.getType() === 'Point') {
|
||
const coord = (geom as Point).getCoordinates();
|
||
const iconPixel = this.map?.getPixelFromCoordinate(coord);
|
||
|
||
if (iconPixel) {
|
||
// 计算鼠标像素与图标中心像素的距离
|
||
const dx = pixel[0] - iconPixel[0];
|
||
const dy = pixel[1] - iconPixel[1];
|
||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
// 假设图标大致大小为 30x30 或 40x40,半径设为 15-20
|
||
// 你可以根据实际 icon 大小调整这个阈值,例如 15
|
||
const hitRadius = 15;
|
||
if (distance <= hitRadius) {
|
||
isHitIcon = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return true; // 找到第一个点图层要素即停止
|
||
}
|
||
return false;
|
||
},
|
||
{ hitTolerance: 0 } // 设置为 0,完全依赖上面的距离计算
|
||
);
|
||
|
||
// 3. 根据是否命中图标来决定行为
|
||
const newHoveredId =
|
||
detectedFeature && isHitIcon ? detectedFeature.getId() : null;
|
||
const detectedCoordinate =
|
||
detectedFeature && isHitIcon
|
||
? detectedFeature.getGeometry()?.getCoordinates()
|
||
: undefined;
|
||
|
||
// ✅ 关键优化:立即更新状态,不再使用 setTimeout 防抖,以解决移出延时问题
|
||
// 如果希望防止极速移动时的闪烁,可以保留极短的 debounce (如 10ms),但通常 requestAnimationFrame 已足够平滑
|
||
if (newHoveredId !== lastHoveredId) {
|
||
lastHoveredId = newHoveredId;
|
||
this.hoveredFeatureId = newHoveredId;
|
||
|
||
// 更新光标
|
||
const targetElement = this.map?.getTargetElement() as HTMLElement;
|
||
if (targetElement) {
|
||
targetElement.style.cursor = newHoveredId ? 'pointer' : '';
|
||
}
|
||
|
||
// 显示/隐藏 Popup
|
||
this.showPopup(detectedFeature, detectedCoordinate);
|
||
|
||
// 重绘图层以更新图标样式(如高亮)
|
||
this.pointLayerRegistry.forEach(layer => {
|
||
layer.changed();
|
||
});
|
||
|
||
// 触发地图渲染
|
||
this.map?.render();
|
||
}
|
||
});
|
||
});
|
||
return Promise.resolve(this.map);
|
||
} catch (e) {
|
||
console.log('OL Init Error', e);
|
||
return Promise.reject(e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化加载描点数据
|
||
* @param pointData 图层配置对象或数据数组
|
||
* @param layerType 图层类型/Key
|
||
* @param mdoptions 描点选项配置
|
||
*/
|
||
addInitDataLayer(
|
||
pointData: any,
|
||
layerType: any,
|
||
mdoptions?: MDOptions
|
||
): void {
|
||
if (!this.map) return;
|
||
|
||
let dataArray: any[] = [];
|
||
let targetLayerKey: string = layerType;
|
||
|
||
// 1. 参数解析
|
||
if (Array.isArray(pointData)) {
|
||
dataArray = pointData;
|
||
} else {
|
||
dataArray = pointData.data || [];
|
||
targetLayerKey = pointData.key || layerType;
|
||
}
|
||
|
||
if (!targetLayerKey) {
|
||
console.warn('缺少图层 Key,无法加载描点');
|
||
return;
|
||
}
|
||
|
||
// 2. 获取或创建 VectorLayer
|
||
let vectorLayer = this.pointLayerRegistry.get(targetLayerKey);
|
||
let vectorSource: VectorSource;
|
||
|
||
const shouldClear = mdoptions?.isRemove !== false;
|
||
|
||
if (!vectorLayer) {
|
||
vectorSource = new VectorSource();
|
||
vectorLayer = new VectorLayer({
|
||
source: vectorSource,
|
||
zIndex: 100, // 确保点在底图之上
|
||
// declutter: true,
|
||
// style: (feature: any) => this.createPointStyle(feature) // 使用动态样式函数
|
||
style: (feature: any) => this.createPointStyle1(feature) // 使用动态样式函数
|
||
});
|
||
this.pointLayerRegistry.set(targetLayerKey, vectorLayer);
|
||
this.map.addLayer(vectorLayer);
|
||
} else {
|
||
vectorSource = vectorLayer.getSource() as VectorSource;
|
||
if (shouldClear) {
|
||
vectorSource.clear();
|
||
}
|
||
}
|
||
|
||
if (dataArray.length === 0) {
|
||
return;
|
||
}
|
||
|
||
console.log(
|
||
`开始加载 OL 图层 [${targetLayerKey}] 的描点,数量: ${dataArray.length}`
|
||
);
|
||
|
||
// 3. 遍历数据生成 Feature
|
||
const features: Feature[] = [];
|
||
|
||
dataArray.forEach((item: any) => {
|
||
let { lgtd, lttd, stnm, iconCode, titleName, ennm } = item;
|
||
|
||
if (lgtd == null || lttd == null) {
|
||
return;
|
||
}
|
||
|
||
// 4. 确定图标和文字
|
||
let iconUrl = '';
|
||
|
||
// 获取图标 URL (参考 Leaflet 逻辑)
|
||
if (iconCode) {
|
||
iconUrl = getIconPath(iconCode);
|
||
}
|
||
// 如果没有 iconCode,可以根据 anchoPointState 或其他逻辑获取,这里暂略
|
||
|
||
if (!iconUrl) {
|
||
// 默认图标,防止报错
|
||
iconUrl = getIconPath('default') || '';
|
||
}
|
||
|
||
// 显示的文字优先使用 titleName,其次 ennm,最后 stnm
|
||
const labelText = titleName || ennm || stnm || '';
|
||
|
||
// 5. 创建 Feature
|
||
const feature = new Feature({
|
||
geometry: new Point(fromLonLat([lgtd, lttd])), // ✅ 关键:坐标转换 EPSG:4326 -> EPSG:3857
|
||
...item, // 保留原始数据以便点击事件使用
|
||
_layerKey: targetLayerKey
|
||
});
|
||
const uniqueId = item.stcd || item._id;
|
||
|
||
feature.setId(uniqueId);
|
||
|
||
// 将自定义属性绑定到 feature,以便样式函数访问
|
||
feature.set('_iconUrl', iconUrl);
|
||
feature.set('_labelText', labelText);
|
||
if (item.popupHtml) {
|
||
feature.set('popupHtml', item.popupHtml);
|
||
}
|
||
|
||
features.push(feature);
|
||
});
|
||
|
||
// 6. 批量添加要素
|
||
if (features.length > 0) {
|
||
vectorSource.addFeatures(features);
|
||
}
|
||
|
||
console.log(`图层 [${targetLayerKey}] 描点加载完成`);
|
||
}
|
||
/**
|
||
* 初始化 Popup Overlay
|
||
* @param container 父组件传入的 DOM 容器
|
||
*/
|
||
initPopupOverlay(container: HTMLElement) {
|
||
this.popupElement = container;
|
||
|
||
this.popupOverlay = new Overlay({
|
||
element: this.popupElement,
|
||
positioning: 'bottom-center', // 气泡在点位上方
|
||
offset: [0, -10], // 向上偏移一点
|
||
stopEvent: false, // 允许点击事件穿透到地图(如果需要点击地图关闭)
|
||
autoPan: false
|
||
// autoPan: {
|
||
// animation: {
|
||
// duration: 250
|
||
// }
|
||
// }
|
||
});
|
||
|
||
if (this.map) {
|
||
this.map.addOverlay(this.popupOverlay);
|
||
}
|
||
}
|
||
/**
|
||
* 在 pointermove 中调用此方法显示/隐藏 Popup
|
||
* @param feature 当前悬停的要素
|
||
* @param coordinate 地理坐标
|
||
*/
|
||
showPopup(feature: Feature | undefined, coordinate: number[] | undefined) {
|
||
if (!this.popupOverlay || !this.popupElement) return;
|
||
|
||
if (feature && coordinate) {
|
||
// 1. 获取数据
|
||
const props = feature.getProperties();
|
||
|
||
// 2. 生成内容 (这里假设父组件已经在 feature 上绑定了 popupHtml 或者我们在这里构建)
|
||
// 方案 A: 如果父组件传入了 HTML 字符串
|
||
const popupHtml = props.popupHtml || props.popup || '';
|
||
|
||
// 方案 B: 如果父组件希望动态更新 DOM (推荐,性能更好,支持交互)
|
||
// 我们可以触发一个事件,让 Vue 组件去更新这个 div 的内容
|
||
// 这里为了简单,直接演示如何填充内容
|
||
|
||
if (popupHtml) {
|
||
this.popupElement.innerHTML = popupHtml;
|
||
this.popupOverlay.setPosition(coordinate);
|
||
this.popupElement.style.display = 'block';
|
||
} else {
|
||
// 如果没有预设 HTML,可以显示默认文本
|
||
this.popupElement.innerHTML = `<div style="padding:5px; background:white; border-radius:4px;">${
|
||
props.stnm || props.titleName || '未知'
|
||
}</div>`;
|
||
this.popupOverlay.setPosition(coordinate);
|
||
this.popupElement.style.display = 'block';
|
||
}
|
||
} else {
|
||
// 隐藏
|
||
this.popupOverlay.setPosition(undefined);
|
||
this.popupElement.style.display = 'none';
|
||
}
|
||
}
|
||
/**
|
||
* 创建点样式 (模拟 Leaflet 的 DivIcon 效果,支持随缩放动态调整大小)
|
||
*/
|
||
private createPointStyle(feature: Feature): Style {
|
||
const iconUrl = feature.get('_iconUrl') as string;
|
||
const labelText = feature.get('_labelText') as string;
|
||
|
||
// 如果没图标,返回空样式
|
||
if (!iconUrl) {
|
||
return new Style();
|
||
}
|
||
|
||
// ✅ 1. 获取当前地图分辨率
|
||
// 注意:如果样式函数在地图未完全初始化时调用,view 可能为 null
|
||
// const currentResolution = this.view ? this.view.getResolution() : 1000;
|
||
|
||
// ✅ 2. 定义基准分辨率和基准缩放比例
|
||
// INITIAL_ZOOM (4.5) 对应的分辨率大约是 3000-4000 左右 (取决于投影)
|
||
// 这里我们用一个经验公式:分辨率越小(zoom越大),scale 越大
|
||
// 假设在初始分辨率下 scale 为 0.7
|
||
// const baseResolution = 3000; // 这是一个估算值,你可以根据实际效果微调
|
||
// const baseScale = 0.7;
|
||
|
||
// 计算动态缩放比例:
|
||
// 如果 currentResolution 变小 (放大地图),ratio 变大 -> icon 变大
|
||
// 限制最大和最小缩放,防止过大或过小
|
||
// let dynamicScale = baseScale * (baseResolution / (currentResolution || 1));
|
||
const currentZoom: any = this.view ? this.view.getZoom() : 4.5;
|
||
let dynamicScale = 0.7 + (currentZoom - 4.5) * 0.08;
|
||
|
||
// 限制范围:最小 0.5,最大 3.0 (可根据需求调整)
|
||
dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale));
|
||
|
||
// ✅ 3. 动态字体大小
|
||
// 基础字体 12px,随缩放比例线性变化
|
||
const fontSize = Math.max(10, Math.min(24, 12 * dynamicScale));
|
||
|
||
const currentFeatureId = feature.getId() || (feature as any).ol_uid;
|
||
const isHovered = this.hoveredFeatureId === currentFeatureId;
|
||
const zIndex = isHovered ? 100 : 0;
|
||
const styleOptions: any = {
|
||
image: new Icon({
|
||
src: iconUrl,
|
||
scale: dynamicScale,
|
||
anchor: [0.5, 0.5],
|
||
crossOrigin: 'anonymous'
|
||
})
|
||
|
||
// zIndex: zIndex // ✅ 设置样式的 zIndex
|
||
};
|
||
|
||
// ✅ 4. 只有在悬停时才添加 Text 样式
|
||
// if (isHovered && labelText) {
|
||
styleOptions.text = new Text({
|
||
text: labelText,
|
||
offsetY: -20 * dynamicScale,
|
||
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);
|
||
}
|
||
private createPointStyle1(feature: Feature): Style {
|
||
const color = (feature.get('iconCode') as string) || '#3399CC';
|
||
const labelText = feature.get('titleName') as string;
|
||
const buildType = (feature.get('BuildType') as number) ?? 1; // 默认完整圆
|
||
|
||
// 缩放相关动态计算
|
||
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));
|
||
// const fontSize = Math.max(46, Math.min(24, 12 * dynamicScale));
|
||
// const radius = Math.max(14, Math.min(18, 6 * dynamicScale));
|
||
|
||
const currentFeatureId = feature.getId() || (feature as any).ol_uid;
|
||
const isHovered = this.hoveredFeatureId === currentFeatureId;
|
||
|
||
// 根据 BuildType 选择图形样式
|
||
let imageStyle;
|
||
if (buildType === 0) {
|
||
// 半圆(横向,开口朝右)
|
||
const canvas = this.createSemiCircleCanvas(radius, color);
|
||
imageStyle = new Icon({
|
||
img: canvas,
|
||
imgSize: [canvas.width, canvas.height],
|
||
anchor: [0.5, 0.5] // 锚点居中,使半圆定位准确
|
||
});
|
||
} 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
|
||
};
|
||
|
||
// 悬停时添加文本标签(与原逻辑相同,可根据半圆形状微调 offsetY)
|
||
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);
|
||
}
|
||
/**
|
||
* 初始化加载基础图层
|
||
* @param layer 图层配置对象
|
||
*/
|
||
addBaseDataLayer(layer: any, isCache: boolean = false): void {
|
||
if (!this.map) return;
|
||
|
||
console.log('OL addBaseDataLayer', layer);
|
||
|
||
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;
|
||
}
|
||
// 1. 解析参数
|
||
// 注意:如果 layer.url 已经是完整 URL,可能需要调整解析逻辑。
|
||
// 这里假设 layer.url 包含查询参数,或者我们直接使用硬编码的配置来确保正确性
|
||
const layerName: string = urlParams.get('LAYER') || ''; // 根据你的 Leaflet 代码硬编码或从 layer 对象获取
|
||
const matrixSetName: any = urlParams.get('TILEMATRIXSET'); // TileMatrixSet 名称
|
||
|
||
// 2. 获取标准 EPSG:3857 投影信息
|
||
const projection = getProjection('EPSG:3857');
|
||
if (!projection) {
|
||
console.error('无法获取 EPSG:3857 投影');
|
||
return;
|
||
}
|
||
const projectionExtent = projection.getExtent();
|
||
|
||
// 3. 计算分辨率和矩阵ID
|
||
// GeoServer 的 TileMatrixSet 通常基于标准 Web Mercator,但 ID 格式可能不同
|
||
const size = getWidth(projectionExtent) / 256;
|
||
const maxZoom = 13; // 根据 Leaflet 中的 maxNativeZoom: 12 或 13 设定
|
||
|
||
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);
|
||
|
||
// 【关键修复】:构造符合 GeoServer 要求的 Matrix ID
|
||
// 通常格式为: "TileMatrixSetName:ZoomLevel" 或 "EPSG:3857:ZoomLevel"
|
||
// 根据你的 URL 推测,ID 可能是 "EPSG:3857_qgc_qsj_arcgistiles_l13:0"
|
||
matrixIds[z] = `${matrixSetName}:${z}`;
|
||
}
|
||
|
||
// 4. 创建 WMTS 图层
|
||
const wmtsLayer = new TileLayer({
|
||
source: new WMTS({
|
||
url: layer.url.split('?')[0], // 使用代理路径
|
||
layer: layerName,
|
||
matrixSet: matrixSetName,
|
||
format: 'image/png',
|
||
projection: projection, // ✅ 使用标准的 EPSG:3857 投影对象
|
||
tileGrid: new WMTSTileGrid({
|
||
origin: getTopLeft(projectionExtent), // ✅ 使用标准投影的左上角原点
|
||
resolutions: resolutions,
|
||
matrixIds: matrixIds // ✅ 使用上面生成的带前缀的 IDs
|
||
}),
|
||
style: 'default',
|
||
wrapX: true, // 允许水平方向上的循环
|
||
crossOrigin: 'anonymous' // 允许跨域
|
||
})
|
||
});
|
||
|
||
if (layer.key === this.REGISTRY_KEY) {
|
||
!isCache ? wmtsLayer.setZIndex(-100) : wmtsLayer.setZIndex(-99);
|
||
} else {
|
||
wmtsLayer.setZIndex(-99);
|
||
}
|
||
console.log('基础图层', wmtsLayer);
|
||
// 5. 注册与添加
|
||
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);
|
||
this.layerRegistry.set(this.REGISTRY_KEY, tileLayer);
|
||
this.map.addLayer(tileLayer);
|
||
} else if (layer.type === 'vector') {
|
||
if (layer.key === 'hydropBase') {
|
||
this.hydropBaseConfig = layer;
|
||
}
|
||
console.log(`矢量图层 [${layer.key}] 已加载: ${layer.url}`);
|
||
}
|
||
}
|
||
// 在 addBaseDataLayer 方法的 vector 分支末尾,或者专门提供一个方法来激活遮罩
|
||
enableNortheastMask(): void {
|
||
// 1. 获取全量底图图层
|
||
const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY);
|
||
|
||
// 2. 检查是否已加载东北边界数据
|
||
if (!this.geoJsonData1) {
|
||
console.warn('东北边界数据尚未加载');
|
||
return;
|
||
}
|
||
|
||
if (baseLayer && baseLayer instanceof TileLayer) {
|
||
console.log('正在应用东北区域遮罩...');
|
||
this.applyMapMask(baseLayer, this.geoJsonData1);
|
||
} else {
|
||
console.warn('未找到全量底图图层或图层类型错误');
|
||
}
|
||
}
|
||
/**
|
||
* 控制特定区域图层的显示(互斥显示)
|
||
* @param regionId 区域ID (例如 "hebei", "13", "01" 等,需与图层 Key 或属性对应)
|
||
* @param isAll 是否显示所有 (true: 显示所有图层; false: 仅显示匹配 regionId 的图层,隐藏其他)
|
||
*/
|
||
async jdPanelControlShowAndHidden(
|
||
_regionId: string,
|
||
isAll: boolean
|
||
): Promise<void> {
|
||
if (!this.map || !this.hydropBaseConfig) {
|
||
console.warn('地图未初始化或 hydropBaseConfig 未配置');
|
||
return;
|
||
}
|
||
|
||
// 1. 更新当前选中的区域 ID
|
||
this.BASEID = _regionId;
|
||
console.log(`切换区域至: ${this.BASEID}`);
|
||
|
||
if (!isAll) {
|
||
// ✅ 情况 A: isAll == false -> 清空裁切
|
||
this.clearMapMask();
|
||
return;
|
||
}
|
||
|
||
// 2. 获取底图图层 (用于应用遮罩的目标)
|
||
const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY) as TileLayer;
|
||
if (!baseLayer) {
|
||
console.warn('未找到底图图层,无法应用遮罩');
|
||
return;
|
||
}
|
||
try {
|
||
const url =
|
||
this.hydropBaseConfig.geojson_url +
|
||
`&cql_filter=BASEID='${this.BASEID}'`;
|
||
console.log('正在请求裁切数据:', url);
|
||
|
||
// 等待数据加载
|
||
const geoJsonData = await this.loadGeoJsonData(url);
|
||
console.log('获取到的原始 GeoJSON:', geoJsonData);
|
||
|
||
// ✅ 新增:在应用遮罩前,先让地图视角聚焦到该区域
|
||
this.fitViewToGeoJson(geoJsonData);
|
||
|
||
console.log('基础图层', baseLayer);
|
||
// 应用遮罩
|
||
this.applyMapMask(baseLayer, geoJsonData);
|
||
} catch (error) {
|
||
console.error('加载裁切数据失败:', error);
|
||
// 出错时也清空遮罩,保证用户体验
|
||
this.clearMapMask();
|
||
}
|
||
}
|
||
/**
|
||
* 基础图层显示影隐藏方法
|
||
* @param layerType 图层类型 (备用,优先使用 key)
|
||
* @param key 图层唯一标识 Key
|
||
* @param checked true 为显示,false 为隐藏
|
||
*/
|
||
controlBaseLayerTreeShowAndHidden(
|
||
layerType: String,
|
||
key: String,
|
||
checked: boolean
|
||
): void {
|
||
if (!this.map) return;
|
||
|
||
// 1. 确定查找的 Key:优先使用传入的 key,如果为空则尝试使用 layerType
|
||
const registryKey = key || layerType;
|
||
|
||
if (!registryKey) {
|
||
console.warn(
|
||
'controlBaseLayerTreeShowAndHidden: 缺少有效的 Key 或 LayerType'
|
||
);
|
||
return;
|
||
}
|
||
// 2. 从注册表中获取图层实例
|
||
const layerInstance = this.layerRegistry.get(registryKey as string);
|
||
|
||
if (layerInstance) {
|
||
// 3. 设置可见性
|
||
// OpenLayers 的 Layer.setVisible(boolean) 方法
|
||
layerInstance.setVisible(checked);
|
||
|
||
console.log(
|
||
`图层 [${registryKey}] 已设置为: ${checked ? '显示' : '隐藏'}`
|
||
);
|
||
} else {
|
||
console.warn(
|
||
`未找到标识为 [${registryKey}] 的图层实例,当前注册表 keys:`,
|
||
Array.from(this.layerRegistry.keys())
|
||
);
|
||
}
|
||
}
|
||
// 图层树控制描点数据显示隐藏方法
|
||
mdLayerTreeShowOrHidden(layerType: String, checked?: boolean): void {
|
||
const layerInstance: any = this.pointLayerRegistry.get(layerType as string);
|
||
layerInstance.setVisible(checked);
|
||
// this.service.mdLayerTreeShowOrHidden(layerType, checked)
|
||
}
|
||
/**
|
||
* 缩放地图
|
||
* @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')
|
||
*/
|
||
baseLayerSwitcher(key: string): void {
|
||
if (!this.map || !this.view) return;
|
||
|
||
console.log('OL 切换底图 key:', key);
|
||
|
||
// 1. 如果注册表中已存在 "customBaseLayer",先将其从地图中移除
|
||
const oldLayer = this.layerRegistry.get(this.REGISTRY_KEY);
|
||
if (oldLayer) {
|
||
this.map.removeLayer(oldLayer);
|
||
this.layerRegistry.delete(this.REGISTRY_KEY);
|
||
}
|
||
|
||
// 2. 根据传入的业务 key 创建新的图层实例
|
||
if (key == 's_province_boundaries') {
|
||
this.addBaseDataLayer(this.baseLayerConfig);
|
||
} else {
|
||
this.addBaseDataLayer(this.baseLayerConfig, true);
|
||
this.addBaseDataLayer(servers.BaseLayer[key]);
|
||
}
|
||
}
|
||
/**
|
||
* 添加梯级流域图 (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;
|
||
|
||
// 1. 检查是否已存在该图层,避免重复添加
|
||
const existingLayer = this.layerRegistry.get(layer.key);
|
||
if (existingLayer) {
|
||
this.map.removeLayer(existingLayer);
|
||
this.layerRegistry.delete(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)' })
|
||
});
|
||
|
||
// 3. 创建矢量源 (VectorSource) 并直接加载数据
|
||
const vectorSource = new VectorSource({
|
||
features: new GeoJSON().readFeatures(this.geoJsonData, {
|
||
featureProjection: 'EPSG:3857' // 确保转换到地图使用的投影
|
||
})
|
||
});
|
||
|
||
// 4. 数据过滤逻辑
|
||
const allowedIds = Array.isArray(datas) ? datas : [];
|
||
const features = vectorSource.getFeatures();
|
||
|
||
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);
|
||
});
|
||
|
||
// 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);
|
||
}
|
||
/**
|
||
* 移除梯级流域图
|
||
* @param layer 图层配置对象 (需包含 key 属性)
|
||
*/
|
||
removeTertiarybasinLayer(layer: any): void {
|
||
if (!this.map || !layer || !layer.key) {
|
||
console.warn('removeTertiarybasinLayer: 无效的图层或地图未初始化');
|
||
return;
|
||
}
|
||
// 1. 从注册表中获取图层实例
|
||
const layerInstance = this.layerRegistry.get(layer.key);
|
||
|
||
if (layerInstance) {
|
||
// 2. 从地图中移除图层
|
||
this.map.removeLayer(layerInstance);
|
||
// 3. 从注册表中删除引用
|
||
this.layerRegistry.delete(layer.key);
|
||
|
||
console.log(`梯级流域图层 [${layer.key}] 已移除`);
|
||
} else {
|
||
console.warn(`未找到标识为 [${layer.key}] 的梯级流域图层实例`);
|
||
}
|
||
}
|
||
/**
|
||
* 地图打印/导出
|
||
* 将当前地图视图导出为 PNG 图片,背景强制为白色
|
||
*/
|
||
mapOutPut(): void {
|
||
if (!this.map) {
|
||
console.warn('地图未初始化,无法打印');
|
||
return;
|
||
}
|
||
|
||
console.log('开始生成地图打印图片...');
|
||
|
||
// 1. 获取地图容器中的 canvas 元素
|
||
const mapElement = this.map.getTargetElement();
|
||
const canvas = mapElement.querySelector('canvas');
|
||
|
||
if (!canvas) {
|
||
console.error('未找到地图 Canvas 元素,无法导出');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 2. 创建一个离屏 Canvas 用于合成白色背景
|
||
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 上下文');
|
||
}
|
||
|
||
// 3. 填充白色背景
|
||
ctx.fillStyle = '#FFFFFF';
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
// 4. 将原地图 Canvas 绘制到离屏 Canvas 上
|
||
ctx.drawImage(canvas, 0, 0);
|
||
|
||
// 5. 将合成后的 Canvas 转换为 Data URL
|
||
const dataURL = offscreenCanvas.toDataURL('image/png');
|
||
|
||
// 6. 创建临时链接并触发下载
|
||
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);
|
||
|
||
console.log('地图图片已生成并触发下载(背景已白化)');
|
||
} 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;
|
||
|
||
console.log(`启动${geomType === 'LineString' ? '长度' : '面积'}量算模式`);
|
||
|
||
// 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({
|
||
source: this.measureSource!,
|
||
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);
|
||
|
||
console.log('测量结束');
|
||
});
|
||
}
|
||
/**
|
||
* 长度量算
|
||
*/
|
||
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;
|
||
}
|
||
|
||
console.log('已清除所有量算结果');
|
||
}
|
||
|
||
/**
|
||
* 添加固定的 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',
|
||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||
border: '1px solid #000',
|
||
padding: '4px 8px',
|
||
borderRadius: '4px',
|
||
fontSize: '12px',
|
||
color: '#000',
|
||
cursor: 'default',
|
||
whiteSpace: 'nowrap',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
zIndex: '1001',
|
||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||
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);
|
||
}
|
||
/**
|
||
* 为栅格图层添加基于矢量的裁切效果
|
||
* @param rasterLayer 需要被裁切的底图图层 (TileLayer)
|
||
* @param clipGeoJson GeoJSON 格式的裁切边界数据
|
||
*/
|
||
private applyMapMask(rasterLayer: TileLayer, clipGeoJson: any): void {
|
||
if (!rasterLayer || !clipGeoJson) return;
|
||
|
||
// 1. 解析 GeoJSON 为 OL Features (EPSG:3857)
|
||
const features = new GeoJSON().readFeatures(clipGeoJson, {
|
||
dataProjection: 'EPSG:4326',
|
||
featureProjection: 'EPSG:3857'
|
||
});
|
||
|
||
if (!features || features.length === 0) {
|
||
console.warn('裁切数据为空,无法应用遮罩');
|
||
return;
|
||
}
|
||
|
||
// 2. 移除旧的监听器,防止重复绑定
|
||
if (this._maskPrerender) {
|
||
rasterLayer.un('prerender', this._maskPrerender);
|
||
}
|
||
if (this._maskPostrender) {
|
||
rasterLayer.un('postrender', this._maskPostrender);
|
||
}
|
||
|
||
// 3. 定义 prerender 事件处理函数
|
||
// OpenLayers 在 prerender 时,context 已经包含了当前帧的状态
|
||
const maskPrerender = (event: any) => {
|
||
const context = event.context;
|
||
const frameState = event.frameState;
|
||
|
||
if (!context || !frameState || !this.map) return;
|
||
|
||
// ✅ 关键步骤 1: 保存当前 Canvas 状态
|
||
context.save();
|
||
|
||
// ✅ 关键步骤 2: 重置变换矩阵,确保我们操作的是屏幕像素坐标
|
||
// OpenLayers 默认可能应用了平移/缩放,我们需要手动控制
|
||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||
|
||
context.beginPath();
|
||
|
||
let hasValidPath = false;
|
||
|
||
// 遍历所有特征,绘制路径
|
||
for (const feature of features) {
|
||
const geom = feature.getGeometry();
|
||
if (!geom) continue;
|
||
|
||
const type = geom.getType();
|
||
|
||
// 处理 Polygon
|
||
if (type === 'Polygon') {
|
||
const rings = (geom as any).getCoordinates() as number[][][];
|
||
for (const ring of rings) {
|
||
if (this.drawRingToContext(context, ring, frameState)) {
|
||
hasValidPath = true;
|
||
}
|
||
}
|
||
}
|
||
// 处理 MultiPolygon
|
||
else if (type === 'MultiPolygon') {
|
||
const polygons = (geom as any).getCoordinates() as number[][][][];
|
||
for (const polygonRings of polygons) {
|
||
for (const ring of polygonRings) {
|
||
if (this.drawRingToContext(context, ring, frameState)) {
|
||
hasValidPath = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ✅ 关键步骤 3: 如果绘制了有效路径,则执行裁剪
|
||
if (hasValidPath) {
|
||
// 闭合路径(虽然 drawRing 里做了 closePath,但为了保险)
|
||
|
||
context.save();
|
||
// 先用外描边创建裁剪区域
|
||
context.lineWidth = 6;
|
||
context.strokeStyle = '#6D64DF';
|
||
context.stroke();
|
||
context.clip(); // 裁剪到外描边的内部区域
|
||
context.strokeStyle = '#CCC9F4';
|
||
context.lineWidth = 5;
|
||
context.stroke();
|
||
// 执行裁剪!此后所有绘制操作仅在此路径内可见
|
||
context.clip();
|
||
|
||
// 可选:如果你希望裁剪区域外有半透明黑色遮罩,可以在这里绘制一个全屏矩形
|
||
// 但通常我们只希望“隐藏”外部,所以只需 clip 即可,OL 会自动绘制瓦片
|
||
} else {
|
||
console.warn('未能生成有效的裁切路径');
|
||
}
|
||
};
|
||
|
||
// 4. 定义 postrender 事件处理函数
|
||
// 用于恢复 Canvas 状态,避免影响其他图层或后续帧
|
||
const maskPostrender = (event: any) => {
|
||
if (event.context) {
|
||
event.context.restore();
|
||
}
|
||
};
|
||
|
||
// 5. 绑定新的事件监听器
|
||
this._maskPrerender = maskPrerender;
|
||
this._maskPostrender = maskPostrender;
|
||
|
||
rasterLayer.on('prerender', this._maskPrerender);
|
||
rasterLayer.on('postrender', this._maskPostrender);
|
||
|
||
// 6. 强制触发重绘
|
||
rasterLayer.changed();
|
||
|
||
console.log('地图遮罩已应用');
|
||
}
|
||
|
||
/**
|
||
* 将单个环(Ring)绘制到 Canvas 上下文
|
||
* @returns boolean 是否成功绘制了有效路径
|
||
*/
|
||
private drawRingToContext(
|
||
context: CanvasRenderingContext2D,
|
||
ring: number[][],
|
||
frameState: any
|
||
): boolean {
|
||
if (!ring || ring.length < 3) return false; // 至少需要3个点构成面
|
||
|
||
const pixelRatio = frameState.pixelRatio || window.devicePixelRatio || 1;
|
||
let moved = false;
|
||
|
||
for (let i = 0; i < ring.length; i++) {
|
||
const coord = ring[i];
|
||
|
||
// 坐标有效性检查
|
||
if (!coord || coord.length < 2 || typeof coord[0] !== 'number') continue;
|
||
|
||
// 获取 CSS 像素坐标
|
||
const cssPixel = this.map?.getPixelFromCoordinate(
|
||
coord as [number, number]
|
||
);
|
||
|
||
if (!cssPixel) continue;
|
||
|
||
// 转换为物理像素坐标
|
||
const canvasX = cssPixel[0] * pixelRatio;
|
||
const canvasY = cssPixel[1] * pixelRatio;
|
||
|
||
if (!moved) {
|
||
context.moveTo(canvasX, canvasY);
|
||
moved = true;
|
||
} else {
|
||
context.lineTo(canvasX, canvasY);
|
||
}
|
||
}
|
||
|
||
if (moved) {
|
||
context.closePath();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
/**
|
||
* 根据 GeoJSON 数据调整地图视野,使其居中并适配大小
|
||
* @param geoJson GeoJSON 对象
|
||
*/
|
||
private fitViewToGeoJson(geoJson: any): void {
|
||
if (!this.map || !this.view) return;
|
||
|
||
try {
|
||
// 1. 将 GeoJSON 转换为 OpenLayers 的 Feature 数组
|
||
// 注意:这里假设输入的是 EPSG:4326,需要转换到地图使用的 EPSG:3857
|
||
const features = new GeoJSON().readFeatures(geoJson, {
|
||
dataProjection: 'EPSG:4326',
|
||
featureProjection: 'EPSG:3857'
|
||
});
|
||
|
||
if (!features || features.length === 0) return;
|
||
|
||
// 2. 创建一个临时的矢量源来计算所有要素的包围盒
|
||
const source = new VectorSource({
|
||
features: features
|
||
});
|
||
|
||
// 3. 获取包围盒 [minX, minY, maxX, maxY]
|
||
const extent = source.getExtent();
|
||
|
||
// 检查包围盒是否有效
|
||
if (extent[0] === Infinity || extent[0] === -Infinity) {
|
||
console.warn('无法计算有效的地图包围盒');
|
||
return;
|
||
}
|
||
|
||
// 4. 调整视图
|
||
// padding: 设置四周留白,防止图形贴边
|
||
// duration: 动画持续时间
|
||
// maxZoom: 限制最大缩放级别,防止放大过度导致像素化
|
||
this.view.fit(extent, {
|
||
padding: [100, 200, 50, 50], // 上右下左留白
|
||
duration: 1000, // 1秒动画
|
||
maxZoom: 13 // 根据底图精度调整,避免过度放大
|
||
});
|
||
|
||
console.log('地图视野已调整至裁切区域');
|
||
} catch (e) {
|
||
console.error('调整地图视野失败:', e);
|
||
}
|
||
}
|
||
/**
|
||
* 清除地图遮罩(恢复底图正常显示)
|
||
*/
|
||
private clearMapMask(): void {
|
||
const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY) as TileLayer;
|
||
if (!baseLayer) return;
|
||
|
||
console.log('正在清除地图遮罩...');
|
||
|
||
if (this._maskPrerender) {
|
||
baseLayer.un('prerender', this._maskPrerender);
|
||
this._maskPrerender = null;
|
||
}
|
||
if (this._maskPostrender) {
|
||
baseLayer.un('postrender', this._maskPostrender);
|
||
this._maskPostrender = null;
|
||
}
|
||
|
||
// 强制重绘以移除遮罩效果
|
||
baseLayer.changed();
|
||
if (this.view) {
|
||
this.view.animate({
|
||
center: fromLonLat(CENTER_positionCN),
|
||
zoom: INITIAL_ZOOM,
|
||
duration: 1000 // 动画持续时间,保持与 fitViewToGeoJson 一致
|
||
});
|
||
console.log('地图视野已重置为初始状态');
|
||
}
|
||
}
|
||
/**
|
||
* 格式化长度输出 (米)
|
||
*/
|
||
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²';
|
||
}
|
||
|
||
mdLayerShowOrHidden(): void {}
|
||
|
||
switchView(type): void {
|
||
console.log(type);
|
||
}
|
||
fitBounds(): void {}
|
||
/**
|
||
* 飞行定位到指定经纬度和缩放级别
|
||
* @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
|
||
}
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 生成带完整圆边框的上半圆 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;
|
||
}
|
||
|
||
/**
|
||
* 移除地图对象,释放资源
|
||
*/
|
||
destroy(): void {
|
||
console.log('销毁地图实例...');
|
||
|
||
// 1. 清除量算相关资源
|
||
this.removeQueryLayer();
|
||
|
||
this._maskPrerender = null;
|
||
this._maskPostrender = null;
|
||
|
||
// ✅ 关键:先清除所有图层的源,再移除图层,这能更有效地中断网络请求
|
||
const clearLayer = (layer: any) => {
|
||
if (layer && layer.getSource) {
|
||
const source = layer.getSource();
|
||
if (source && source.clear) {
|
||
source.clear(); // 清除缓存
|
||
}
|
||
// 对于 WMTS/XYZ,OL 内部有 TileQueue,dispose source 有助于清理
|
||
if (source && source.dispose) {
|
||
// 注意:某些 OL 版本 source.dispose 可能不是公开 API,但 clear 是必须的
|
||
}
|
||
}
|
||
if (this.map && this.map.getLayers().getArray().includes(layer)) {
|
||
this.map.removeLayer(layer);
|
||
}
|
||
// 强制解除引用
|
||
if (layer.setMap) layer.setMap(null);
|
||
};
|
||
|
||
// 2. 清除通过 layerRegistry 管理的图层
|
||
if (this.layerRegistry) {
|
||
this.layerRegistry.forEach(clearLayer);
|
||
this.layerRegistry.clear();
|
||
}
|
||
|
||
// 3. 清除点图层 Registry
|
||
if (this.pointLayerRegistry) {
|
||
this.pointLayerRegistry.forEach(clearLayer);
|
||
this.pointLayerRegistry.clear();
|
||
}
|
||
|
||
// 4. 清除地图实例
|
||
if (this.map) {
|
||
// 先清除交互和覆盖物
|
||
this.map.getInteractions().clear();
|
||
this.map.getOverlays().clear();
|
||
|
||
// 设置 target 为 undefined 会从 DOM 移除 canvas
|
||
this.map.setTarget(undefined);
|
||
|
||
// 销毁地图实例
|
||
this.map.dispose();
|
||
this.map = null;
|
||
}
|
||
|
||
// 5. 清除视图引用
|
||
if (this.view) {
|
||
this.view.dispose();
|
||
this.view = null;
|
||
}
|
||
|
||
// 6. 清除其他引用
|
||
this.baseLayerConfig = null;
|
||
this.measureSource = null;
|
||
this.measureLayer = null;
|
||
this.drawInteraction = null;
|
||
this.geoJsonData = null;
|
||
this.geoJsonData1 = null;
|
||
|
||
console.log('地图实例已销毁');
|
||
}
|
||
}
|