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'; 7; // 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 = 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 = 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 { try { const response = await fetch(url); const data = await response.json(); return data; } catch (error) { console.error('配置加载失败:', error); } } //地图初始化 init(container: HTMLElement): Promise { 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: 0.5, 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) // 使用动态样式函数 }); 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) => { const { 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 = `
${ props.stnm || props.titleName || '未知' }
`; 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); } /** * 初始化加载基础图层 * @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 { 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 } ); } /** * 移除地图对象,释放资源 */ 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('地图实例已销毁'); } // ... 其他 MapInterface 方法待实现 }