import { MapInterface } from './map'; import OlMap from 'ol/Map'; import View from 'ol/View'; import Overlay from 'ol/Overlay'; 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 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, DragPan } 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 Point from 'ol/geom/Point'; import { getLength as getSphericalLength, getArea as getSphericalArea } from 'ol/sphere'; import { unByKey } from 'ol/Observable'; import { MDOptions } from './map.class'; import { useModelStore } from '@/store/modules/model'; import { PointLayerManager } from './ol/point-layer-manager'; import { PopupManager } from './ol/popup-manager'; import { RegionMaskManager } from './ol/region-mask-manager'; import { getNearbyPointDensityDisplayRules } from '@/modules/map/domain/nearby-point-rules'; const modelStore = useModelStore(); const VITE_APP_MAP_URL = import.meta.env.VITE_APP_MAP_URL; // 定义与 leaflet 中相同的常量 const CENTER_positionCN = [114.17112499999996, 38]; // OpenLayers 使用 [lon, lat] const MIN_ZOOM = 4.23; const MAX_ZOOM = 22; const INITIAL_ZOOM = 4.5; const BATCH_POPUP_MODE_ZOOM = 15; const FULL_DISPLAY_NO_COLLISION_ZOOM = 15; // 定义边界 [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 = new Map(); private iconLoadState = new Map(); private baseLayerConfig: any | null = null; private hydropBaseConfig: any | null = null; private REGISTRY_KEY = 'customBaseLayer'; private activeBaseLayerKey = 's_province_boundaries'; private readonly rasterBaseLayerKeys = ['BASEMAP-white', 'BASEMAP-img']; private drawInteraction: any = null; private measureLayer: VectorLayer | null = null; private measureSource: VectorSource | null = null; private pointLayerManager: PointLayerManager; geoJsonData: any = null; geoJsonData1: any = null; private BASEID = ''; private currentClipGeoJson: any | null = null; private clipRequestController: AbortController | null = null; private hoveredFeatureId: string | number | null = null; private popupManager: PopupManager; private regionMaskManager: RegionMaskManager; private isBatchPopupMode = false; private batchPopupRefreshFrameId: number | null = null; private labelVisibilityRefreshFrameId: number | null = null; 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 }); } private async loadGeoJsonData(url, signal?: AbortSignal): Promise { try { const response = await fetch(url, { signal }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); return data; } catch (error: any) { if (error.name !== 'AbortError') { console.error('配置加载失败:', error); } } } //地图初始化 init(container: HTMLElement): Promise { 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 默认也有动画 maxDelta: 2, 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: false, pinchRotate: false // 通常禁用旋转,除非需要 }).extend([ new DragPan({ kinetic: undefined }), mouseWheelInteraction ]) }); this.pointLayerManager.setMap(this.map); this.popupManager.setMap(this.map); this.regionMaskManager.setContext(this.map, this.view); this.view.on('change:resolution', () => { this.handleZoomChange(); }); this.map.on('click', evt => { this.popupManager.showPopup(undefined, undefined); this.popupManager.handleMapClick(evt.pixel, detectedFeature => { 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 + ' 详情信息'; } }); }); this.map.on('pointermove', evt => { this.popupManager.handlePointerMove(evt.pixel, payload => { this.hoveredFeatureId = payload.hoveredId; const targetElement = this.map?.getTargetElement() as HTMLElement; if (targetElement) { targetElement.style.cursor = payload.hoveredId ? 'pointer' : ''; } // 批量 popup 模式下不触发 hover popup if (!this.isBatchPopupMode) { this.showPopup(payload.detectedFeature, payload.coordinate); } }); }); this.map.on('pointerdrag', () => { if (this.isBatchPopupMode) { this.requestUpdateBatchPopups(); } }); this.map.on('moveend', () => { this.requestRefreshPointLabelVisibility(true); if (this.isBatchPopupMode) { this.requestUpdateBatchPopups(true); } }); return Promise.resolve(this.map); } catch (e) { console.error('OL Init Error', e); return Promise.reject(e); } } /** * 初始化加载描点数据 * @param pointData 图层配置对象或数据数组 * @param layerType 图层类型/Key * @param mdoptions 描点选项配置 */ addInitDataLayer( pointData: any, layerType: any, _mdoptions?: MDOptions ): void { this.pointLayerManager.addDataLayer(pointData, layerType); if (this.currentClipGeoJson) { this.regionMaskManager.filterPointsByRegion(this.currentClipGeoJson); } const currentZoom = this.view ? this.view.getZoom() : INITIAL_ZOOM; this.pointLayerManager.updateNearbyFeatureLayout(currentZoom); this.requestRefreshPointLabelVisibility(true); } /** * 初始化 Popup Overlay * @param container 父组件传入的 DOM 容器 */ initPopupOverlay(container: HTMLElement) { this.popupManager.initPopupOverlay(container); } /** * 在指定像素位置检测要素(共享方法) * @param pixel 像素坐标 * @returns 检测到的要素和是否命中图标区域 */ private detectFeatureAtPixel(pixel: number[]): { detectedFeature: Feature | undefined; isHitIcon: boolean; coordinate?: number[]; } { return this.popupManager.detectFeatureAtPixel(pixel); } /** * 在 pointermove 中调用此方法显示/隐藏 Popup * @param feature 当前悬停的要素 * @param coordinate 地理坐标 */ showPopup(feature: Feature | undefined, coordinate: number[] | undefined) { this.popupManager.showPopup(feature, coordinate); } /** * 监听缩放层级变化,到达指定层级时自动显示可视区域内所有锚点的 popup */ private handleZoomChange() { if (!this.view) return; const zoom = this.view.getZoom(); if (zoom === undefined) return; this.pointLayerManager.updateNearbyFeatureLayout(zoom); this.requestRefreshPointLabelVisibility(); if (zoom >= BATCH_POPUP_MODE_ZOOM) { this.enableBatchPopupMode(); } else { this.disableBatchPopupMode(); } } /** * 启用批量 popup 模式:自动显示可视区域内所有锚点的 popup */ private enableBatchPopupMode() { this.isBatchPopupMode = true; this.popupManager.clearBatchPopups(); this.requestUpdateBatchPopups(true); } /** * 禁用批量 popup 模式:恢复鼠标悬停显示 popup */ private disableBatchPopupMode() { this.isBatchPopupMode = false; if (this.batchPopupRefreshFrameId !== null) { cancelAnimationFrame(this.batchPopupRefreshFrameId); this.batchPopupRefreshFrameId = null; } 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; }); } /** * 创建点样式 (模拟 Leaflet 的 DivIcon 效果,支持随缩放动态调整大小) */ private createPointStyle(feature: Feature): Style[] | null { const iconUrl = feature.get('_iconUrl') as string; const labelText = feature.get('_labelText') as string; const legendVisible = feature.get('_legendVisible'); const regionVisible = feature.get('_regionVisible'); if (!iconUrl) { return null; } if (!this.ensureIconReady(iconUrl)) { return null; } const currentZoom: any = this.view ? this.view.getZoom() : 4.5; const iconTargetVisible = legendVisible !== false && regionVisible !== false && this.shouldRenderFeatureByDensity(feature, currentZoom) && this.shouldRenderNearbyFeature(feature, currentZoom); const dynamicScale = this.getDynamicPointScale(currentZoom); const fontSize = this.getPointFontSize(dynamicScale); const formattedLabelText = this.formatPointLabelText(labelText); const labelLineCount = formattedLabelText ? formattedLabelText.split('\n').length : 1; const labelOffsetY = this.getPointLabelRenderOffsetY( dynamicScale, labelLineCount ); const iconCollisionVisible = feature.get('_iconCollisionVisible') !== false; const labelCollisionVisible = feature.get('_labelCollisionVisible') === true; const finalIconVisible = iconTargetVisible && iconCollisionVisible; const labelTargetVisible = finalIconVisible && !!formattedLabelText && labelCollisionVisible; if (!finalIconVisible) { return null; } const styles: Style[] = []; styles.push( new Style({ image: new Icon({ src: iconUrl, scale: dynamicScale, anchor: [0.5, 0.5], crossOrigin: 'anonymous', declutterMode: 'none' }) }) ); if (formattedLabelText && labelTargetVisible) { styles.push( new Style({ zIndex: 101, text: new Text({ text: formattedLabelText, offsetY: labelOffsetY, font: `${fontSize}px sans-serif`, fill: new Fill({ color: '#fff' }), stroke: new Stroke({ color: 'rgba(0, 0, 0, 0.9)', width: 2 }), textAlign: 'center', declutterMode: 'none' }) }) ); } return styles; } private refreshPointLabelVisibility() { if (!this.map || !this.view) return; const currentZoom = this.view.getZoom(); if (currentZoom === undefined) return; const candidateMap = new Map< string, { feature: Feature; iconLeft: number; iconRight: number; iconTop: number; iconBottom: number; hasLabel: boolean; left: number; right: number; top: number; bottom: number; priority: number; densityPriority: number; id: string; pixelX: number; pixelY: number; } >(); const candidates: Array<{ feature: Feature; iconLeft: number; iconRight: number; iconTop: number; iconBottom: number; hasLabel: boolean; left: number; right: number; top: number; bottom: number; priority: number; densityPriority: number; id: string; pixelX: number; pixelY: number; }> = []; const viewportFeatures = this.pointLayerManager.getFeaturesInViewport(); if (currentZoom >= FULL_DISPLAY_NO_COLLISION_ZOOM) { viewportFeatures.forEach(feature => { const iconUrl = feature.get('_iconUrl') as string; const shouldRenderIcon = !!iconUrl && this.isIconReady(iconUrl) && feature.get('_legendVisible') !== false && feature.get('_regionVisible') !== false && this.shouldRenderFeatureByDensity(feature, currentZoom) && this.shouldRenderNearbyFeature(feature, currentZoom); const hasLabel = !!this.formatPointLabelText( feature.get('_labelText') as string ); this.syncFeatureIconCollisionVisible(feature, shouldRenderIcon); this.syncFeatureLabelCollisionVisible( feature, shouldRenderIcon && hasLabel ); }); return; } viewportFeatures.forEach(feature => { const iconUrl = feature.get('_iconUrl') as string; const shouldRenderIcon = !!iconUrl && this.isIconReady(iconUrl) && feature.get('_legendVisible') !== false && feature.get('_regionVisible') !== false && this.shouldRenderFeatureByDensity(feature, currentZoom) && this.shouldRenderNearbyFeature(feature, currentZoom); if (!shouldRenderIcon) { this.syncFeatureIconCollisionVisible(feature, false); this.syncFeatureLabelCollisionVisible(feature, false); return; } const geometry = feature.getGeometry(); if (!geometry || geometry.getType() !== 'Point') { this.syncFeatureIconCollisionVisible(feature, false); this.syncFeatureLabelCollisionVisible(feature, false); return; } const coordinates = (geometry as any).getCoordinates?.(); if (!coordinates) { this.syncFeatureIconCollisionVisible(feature, false); this.syncFeatureLabelCollisionVisible(feature, false); return; } const pixel = this.map?.getPixelFromCoordinate(coordinates); if (!pixel) { this.syncFeatureIconCollisionVisible(feature, false); this.syncFeatureLabelCollisionVisible(feature, false); return; } const dynamicScale = this.getDynamicPointScale(currentZoom); // 备注:图标碰撞盒适当小于视觉图标尺寸,允许相邻站点轻微贴近显示, // 避免像“小浪底/三门峡”这类近点被过早裁掉成只剩一个点。 const iconCollisionSize = Math.max(12, 16 * dynamicScale); const iconPadding = 0; const iconLeft = pixel[0] - iconCollisionSize / 2 - iconPadding; const iconRight = pixel[0] + iconCollisionSize / 2 + iconPadding; const iconTop = pixel[1] - iconCollisionSize / 2 - iconPadding; const iconBottom = pixel[1] + iconCollisionSize / 2 + iconPadding; const labelText = this.formatPointLabelText( feature.get('_labelText') as string ); const fontSize = this.getPointFontSize(dynamicScale); const lines = labelText ? labelText.split('\n') : ['']; const maxLineLength = Math.max(...lines.map(line => line.length), 1); const labelLineCount = lines.length; const labelOffsetY = this.getPointLabelCollisionOffsetY( dynamicScale, labelLineCount ); const estimatedWidth = maxLineLength * fontSize * 0.6 + 16; const estimatedHeight = labelLineCount * (fontSize + 4) + 8; const centerX = pixel[0]; const centerY = pixel[1] + labelOffsetY; const candidateId = String( feature.getId?.() || feature.get('stcd') || '' ); const dedupeKey = candidateId || [ feature.get('_layerKey') || '', pixel[0].toFixed(2), pixel[1].toFixed(2), labelText ].join('|'); candidateMap.set(dedupeKey, { feature, iconLeft, iconRight, iconTop, iconBottom, hasLabel: !!labelText, left: centerX - estimatedWidth / 2, right: centerX + estimatedWidth / 2, top: centerY - estimatedHeight / 2, bottom: centerY + estimatedHeight / 2, priority: Number(feature.get('_nearbyPriority') || 9999), densityPriority: this.getFeatureDensityPriority(feature), id: candidateId, pixelX: pixel[0], pixelY: pixel[1] }); }); candidateMap.forEach(candidate => { candidates.push(candidate); }); candidates.sort((left, right) => { if (left.priority !== right.priority) { return left.priority - right.priority; } if (left.densityPriority !== right.densityPriority) { return left.densityPriority - right.densityPriority; } if (left.pixelY !== right.pixelY) { return left.pixelY - right.pixelY; } return left.id.localeCompare(right.id); }); const placedIconRects: Array<{ featureId: string; left: number; right: number; top: number; bottom: number; }> = []; const placedLabelRects: Array<{ featureId: string; left: number; right: number; top: number; bottom: number; }> = []; let hiddenByIconCollisionCount = 0; let hiddenByLabelCollisionCount = 0; let hiddenByIconHiddenCount = 0; candidates.forEach(candidate => { const featureId = String( candidate.feature.getId?.() || candidate.feature.get('stcd') || '' ); const iconRect = { left: candidate.iconLeft, right: candidate.iconRight, top: candidate.iconTop, bottom: candidate.iconBottom }; const iconHasCollision = placedIconRects.some(rect => this.checkLabelCollision(rect, iconRect) ); const iconVisible = !iconHasCollision; this.syncFeatureIconCollisionVisible(candidate.feature, iconVisible); if (iconVisible) { placedIconRects.push({ featureId, left: iconRect.left, right: iconRect.right, top: iconRect.top, bottom: iconRect.bottom }); } }); const labelCandidates = [...candidates].sort((left, right) => { if (left.priority !== right.priority) { return left.priority - right.priority; } if (left.pixelY !== right.pixelY) { return left.pixelY - right.pixelY; } if (left.pixelX !== right.pixelX) { return left.pixelX - right.pixelX; } return left.id.localeCompare(right.id); }); labelCandidates.forEach(candidate => { const featureId = String( candidate.feature.getId?.() || candidate.feature.get('stcd') || '' ); const iconVisible = candidate.feature.get('_iconCollisionVisible') !== false; if (!candidate.hasLabel) { this.syncFeatureLabelCollisionVisible(candidate.feature, false); return; } if (!iconVisible) { hiddenByIconHiddenCount += 1; this.syncFeatureLabelCollisionVisible(candidate.feature, false); return; } const collidingIconRect = placedIconRects.find(rect => { if (rect.featureId === featureId) { return false; } return this.checkLabelCollisionWithIcon(rect, candidate); }); const hasCollisionWithIcon = !!collidingIconRect; const collidingLabelRect = placedLabelRects.find(rect => this.checkLabelCollision(rect, candidate) ); const hasCollisionWithLabel = !!collidingLabelRect; const visible = !hasCollisionWithIcon && !hasCollisionWithLabel; if (!visible) { if (hasCollisionWithIcon) { hiddenByIconCollisionCount += 1; } else { hiddenByLabelCollisionCount += 1; } } this.syncFeatureLabelCollisionVisible(candidate.feature, visible); if (visible) { placedLabelRects.push({ featureId, left: candidate.left, right: candidate.right, top: candidate.top, bottom: candidate.bottom }); } }); } private requestRefreshPointLabelVisibility(immediate = false) { if (immediate) { if (this.labelVisibilityRefreshFrameId !== null) { cancelAnimationFrame(this.labelVisibilityRefreshFrameId); this.labelVisibilityRefreshFrameId = null; } this.refreshPointLabelVisibility(); return; } if (this.labelVisibilityRefreshFrameId !== null) { return; } this.labelVisibilityRefreshFrameId = window.requestAnimationFrame(() => { this.labelVisibilityRefreshFrameId = null; this.refreshPointLabelVisibility(); }); } private syncFeatureIconCollisionVisible(feature: Feature, visible: boolean) { if (feature.get('_iconCollisionVisible') !== visible) { feature.set('_iconCollisionVisible', visible); feature.changed(); } } private syncFeatureLabelCollisionVisible(feature: Feature, visible: boolean) { if (feature.get('_labelCollisionVisible') !== visible) { feature.set('_labelCollisionVisible', visible); feature.changed(); } } private isIconReady(iconUrl: string): boolean { return this.iconLoadState.get(iconUrl) === 'loaded'; } private ensureIconReady(iconUrl: string): boolean { if (!iconUrl) { return false; } const status = this.iconLoadState.get(iconUrl); if (status === 'loaded') { return true; } if (status === 'loading' || status === 'error') { return false; } this.iconLoadState.set(iconUrl, 'loading'); const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => { this.iconLoadState.set(iconUrl, 'loaded'); this.refreshPointLayerStyles(); }; image.onerror = () => { this.iconLoadState.set(iconUrl, 'error'); this.refreshPointLayerStyles(); }; image.src = iconUrl; if (image.complete && image.naturalWidth > 0) { this.iconLoadState.set(iconUrl, 'loaded'); return true; } return false; } private refreshPointLayerStyles() { this.pointLayerManager.forEachLayer(layer => { layer.changed(); }); this.requestRefreshPointLabelVisibility(true); if (this.isBatchPopupMode) { this.requestUpdateBatchPopups(true); } } private requestUpdateBatchPopups(immediate = false) { if (!this.isBatchPopupMode) { return; } if (immediate) { if (this.batchPopupRefreshFrameId !== null) { cancelAnimationFrame(this.batchPopupRefreshFrameId); this.batchPopupRefreshFrameId = null; } this.updateBatchPopups(); return; } if (this.batchPopupRefreshFrameId !== null) { return; } this.batchPopupRefreshFrameId = window.requestAnimationFrame(() => { this.batchPopupRefreshFrameId = null; if (!this.isBatchPopupMode) { return; } this.updateBatchPopups(); }); } private checkLabelCollision( left: { left: number; right: number; top: number; bottom: number }, right: { left: number; right: number; top: number; bottom: number } ) { const padding = 4; return !( left.right + padding < right.left || left.left - padding > right.right || left.bottom + padding < right.top || left.top - padding > right.bottom ); } private checkLabelCollisionWithIcon( iconRect: { left: number; right: number; top: number; bottom: number }, labelRect: { left: number; right: number; top: number; bottom: number } ) { // 标签避让图标时仅保留图标中心更小的保护区,并允许边缘接触不算碰撞, // 避免像“三峡/黄龙滩”这类仅在边界轻微擦到时把文字过度压掉。 const inset = 4; const padding = 0; const narrowedIconRect = { left: iconRect.left + inset, right: iconRect.right - inset, top: iconRect.top + inset, bottom: iconRect.bottom - inset }; return !( narrowedIconRect.right + padding <= labelRect.left || narrowedIconRect.left - padding >= labelRect.right || narrowedIconRect.bottom + padding <= labelRect.top || narrowedIconRect.top - padding >= labelRect.bottom ); } /** * 点位标注最多显示两行,每行 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}`; } /** * 根据缩放级别计算锚点图标缩放比例,统一复用,减少样式函数中的重复判断。 */ 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 getPointLabelRenderOffsetY( dynamicScale: number, labelLineCount: number ): number { return labelLineCount > 1 ? -30 * dynamicScale : -22 * dynamicScale; } /** * 统一计算标签碰撞时的纵向偏移。 * 这里允许比视觉位置略高一点,避免相邻点图标过早压掉主标签。 */ private getPointLabelCollisionOffsetY( dynamicScale: number, labelLineCount: number ): number { return labelLineCount > 1 ? -36 * dynamicScale : -28 * dynamicScale; } /** * distance 字段表示点位密度分档,不是物理距离。 * 这里仅用它决定点位从哪个缩放级别开始参与显示候选。 */ private getFeatureDensityValue(feature: Feature): number | null { const rawDistance = feature.get('distance'); if ( rawDistance === undefined || rawDistance === null || rawDistance === '' ) { return null; } const densityValue = Number(rawDistance); return Number.isFinite(densityValue) ? densityValue : null; } private getFeatureDensityPriority(feature: Feature): number { const densityValue = this.getFeatureDensityValue(feature); const densityDisplayRules = getNearbyPointDensityDisplayRules(); if (densityValue === null) { return densityDisplayRules.length; } const matchedRuleIndex = densityDisplayRules.findIndex(rule => { return densityValue >= rule.minDensityValue; }); return matchedRuleIndex >= 0 ? matchedRuleIndex : densityDisplayRules.length; } private getFeatureDensityMinZoom(feature: Feature): number { const densityValue = this.getFeatureDensityValue(feature); if (densityValue === null) { return 0; } const densityDisplayRules = getNearbyPointDensityDisplayRules(); const matchedRule = densityDisplayRules.find(rule => { return densityValue >= rule.minDensityValue; }); if (matchedRule) { return matchedRule.minZoom; } return densityDisplayRules.at(-1)?.minZoom ?? 0; } /** * 如果点位在当前视图中没有近邻点,则允许跳过静态密度门槛直接参与显示。 * 这样像“戈兰滩”这类 distance 档位偏密、但当前周围实际很空的点也能出现。 */ private shouldBypassDensityGateForIsolatedFeature(feature: Feature): boolean { if (!this.map) { return false; } const layerKey = String(feature.get('_layerKey') || ''); const geometry = feature.getGeometry(); if (!geometry || geometry.getType() !== 'Point') { return false; } const featurePixel = this.map.getPixelFromCoordinate( (geometry as Point).getCoordinates() ); if (!featurePixel) { return false; } const nearbyPixelThreshold = 56; const viewportFeatures = this.pointLayerManager.getFeaturesInViewport(); return !viewportFeatures.some(otherFeature => { if (otherFeature === feature) { return false; } if (otherFeature.get('_legendVisible') === false) { return false; } if (otherFeature.get('_regionVisible') === false) { return false; } if (String(otherFeature.get('_layerKey') || '') !== layerKey) { return false; } const otherGeometry = otherFeature.getGeometry(); if (!otherGeometry || otherGeometry.getType() !== 'Point') { return false; } const otherPixel = this.map?.getPixelFromCoordinate( (otherGeometry as Point).getCoordinates() ); if (!otherPixel) { return false; } return ( Math.hypot( otherPixel[0] - featurePixel[0], otherPixel[1] - featurePixel[1] ) <= nearbyPixelThreshold ); }); } private shouldRenderFeatureByDensity( feature: Feature, currentZoom: number ): boolean { const minZoom = this.getFeatureDensityMinZoom(feature); const bypassDensityGate = currentZoom < minZoom && this.shouldBypassDensityGateForIsolatedFeature(feature); return currentZoom >= minZoom || bypassDensityGate; } /** * 统一根据近邻点分组和缩放阶段判断当前要素是否应该参与渲染。 */ private shouldRenderNearbyFeature( feature: Feature, currentZoom: number ): boolean { const nearbyGroupId = feature.get('_nearbyGroupId'); const groupSize = Number(feature.get('_nearbyGroupSize')); const priority = Number(feature.get('_nearbyPriority')); const replaceAtZoom = Number(feature.get('_nearbyReplaceAtZoom')); const expandAtZoom = Number(feature.get('_nearbyExpandAtZoom')); const showZoomMin = feature.get('_nearbyShowZoomMin'); const showZoomMax = feature.get('_nearbyShowZoomMax'); if (!nearbyGroupId || !Number.isFinite(groupSize) || groupSize < 2) { return true; } if (!Number.isFinite(priority) || priority <= 0) { return true; } if ( showZoomMin !== null && showZoomMin !== undefined && currentZoom < Number(showZoomMin) ) { return false; } if ( showZoomMax !== null && showZoomMax !== undefined && currentZoom > Number(showZoomMax) ) { return false; } if (Number.isFinite(expandAtZoom) && currentZoom >= expandAtZoom) { return true; } if (Number.isFinite(replaceAtZoom) && currentZoom >= replaceAtZoom) { return priority <= Math.min(2, groupSize); } return priority === 1; } 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)); 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 }; 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, isShow: boolean, isCache = false): void { if (!this.map) return; // 备注:同 key 的底图重复添加前先移除旧实例,避免注册表覆盖后旧图层仍残留在地图上。 const existingLayer = layer?.key ? this.layerRegistry.get(layer.key as string) : null; if (existingLayer) { this.map.removeLayer(existingLayer); this.layerRegistry.delete(layer.key); } if (layer.type === 'wmts') { if (!layer.url) return; const url = !isCache ? layer.url : layer.url_3d; const urlParams = new URLSearchParams(url.split('?')[1]); if (layer.key === this.REGISTRY_KEY) { this.baseLayerConfig = layer; } const layerName: string = urlParams.get('LAYER') || ''; const matrixSetName: any = urlParams.get('TILEMATRIXSET'); const projection = getProjection('EPSG:3857'); if (!projection) { console.error('无法获取 EPSG:3857 投影'); return; } const projectionExtent = projection.getExtent(); const size = getWidth(projectionExtent) / 256; const maxZoom = 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); matrixIds[z] = `${matrixSetName}:${z}`; } const wmtsLayer = new TileLayer({ source: new WMTS({ url: layer.url.split('?')[0], layer: layerName, matrixSet: matrixSetName, format: 'image/png', projection: projection, tileGrid: new WMTSTileGrid({ origin: getTopLeft(projectionExtent), resolutions: resolutions, matrixIds: matrixIds }), style: 'default', wrapX: true, crossOrigin: 'anonymous' }) }); if (layer.key === this.REGISTRY_KEY) { !isCache ? wmtsLayer.setZIndex(-100) : wmtsLayer.setZIndex(-99); } else { wmtsLayer.setZIndex(-100); } wmtsLayer.type = 'layer'; wmtsLayer.setVisible(isShow); 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); tileLayer.setVisible(isShow); tileLayer.type = 'layer'; this.layerRegistry.set(layer.key, tileLayer); this.map.addLayer(tileLayer); } else if (layer.type === 'vector') { if (layer.key === 'hydropBase') { this.hydropBaseConfig = layer; } } } /** * 根据 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 ); } enableNortheastMask(): void { const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY); if (!this.geoJsonData1) { console.warn('东北边界数据尚未加载'); return; } if (baseLayer && baseLayer instanceof TileLayer) { this.regionMaskManager.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; } if (this.clipRequestController) { this.clipRequestController.abort(); this.clipRequestController = null; } this.BASEID = _regionId; if (!isAll) { this.currentClipGeoJson = null; this.regionMaskManager.clearMapMask(); this.regionMaskManager.showAllPoints(); return; } const baseLayers = this.getCurrentMaskTargetLayers(); if (baseLayers.length === 0) { console.warn('未找到底图图层,无法应用遮罩'); return; } this.regionMaskManager.hideAllPoints(); this.clipRequestController = new AbortController(); const signal = this.clipRequestController.signal; try { const url = this.hydropBaseConfig.geojson_url + `&cql_filter=BASEID='${this.BASEID}'`; const geoJsonData = await this.loadGeoJsonData(url, signal); if (signal.aborted) { return; } if (this.BASEID !== _regionId) { return; } this.currentClipGeoJson = geoJsonData; this.regionMaskManager.filterPointsByRegion(geoJsonData); this.regionMaskManager.fitViewToGeoJson(geoJsonData); this.regionMaskManager.applyMapMaskToLayers(baseLayers, geoJsonData); } catch (error: any) { if (error.name === 'AbortError') { return; } this.currentClipGeoJson = null; console.error('加载裁切数据失败:', error); this.regionMaskManager.clearMapMask(); this.regionMaskManager.showAllPoints(); } finally { if (this.clipRequestController?.signal === signal) { this.clipRequestController = null; } } } /** * 基础图层显示影隐藏方法 * @param layerType 图层类型 (备用,优先使用 key) * @param key 图层唯一标识 Key * @param checked true 为显示,false 为隐藏 */ controlBaseLayerTreeShowAndHidden( layerType: string, key: string, checked: boolean ): void { if (!this.map) return; const registryKey = key || layerType; if (!registryKey) { console.warn( 'controlBaseLayerTreeShowAndHidden: 缺少有效的 Key 或 LayerType' ); return; } if (registryKey === this.REGISTRY_KEY) { this.syncActiveBaseLayerVisibility(checked); return; } const layerInstance = this.layerRegistry.get(registryKey as string); if (layerInstance) { layerInstance.setVisible(checked); // 图层显隐变化时刷新 batch popup if (this.isBatchPopupMode) { this.requestUpdateBatchPopups(true); } } else { console.warn( `未找到标识为 [${registryKey}] 的图层实例,当前注册表 keys:`, Array.from(this.layerRegistry.keys()) ); } } mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void { this.pointLayerManager.setLayerVisible(layerType, checked); this.requestRefreshPointLabelVisibility(true); // 图层显隐变化时刷新 batch popup if (this.isBatchPopupMode) { this.requestUpdateBatchPopups(true); } } hasLayer(layerType: string): boolean { return this.pointLayerManager.hasLayer(layerType as string); } hasBaseLayer(layerKey: string): boolean { return this.layerRegistry.has(layerKey); } /** * 缩放地图 * @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, checked: boolean): void { if (!this.map || !this.view) return; this.activeBaseLayerKey = key; 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') { this.addBaseDataLayer(this.baseLayerConfig, checked); this.hideBaseMapLayers(this.rasterBaseLayerKeys); } else { this.addBaseDataLayer(this.baseLayerConfig, checked, true); this.rasterBaseLayerKeys.forEach(layerKey => { if (layerKey === key) { this.addBaseDataLayer(servers.BaseLayer[layerKey], checked); } else { this.setBaseLayerVisible(layerKey, false); } }); } this.reapplyCurrentBaseMask(); } /** * 隐藏指定的底图图层 * @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); } }); } /** * 添加梯级流域图 (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); // 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)' }) }); if (existingLayer) { const allowedIds = Array.isArray(datas) ? datas : []; const existingFeatures = existingLayer.getSource?.()?.getFeatures?.() ?? []; existingFeatures.forEach(feature => { const rvcd = feature.get('RVCD'); const isVisible = allowedIds.length > 0 ? rvcd && allowedIds.includes(rvcd) : true; feature.setStyle(isVisible ? visibleStyle : hiddenStyle); }); existingLayer.setVisible(layer.visible !== false); return; } // 3. 创建矢量源 (VectorSource),并通过 layer.geojson_url 加载数据 const vectorSource = new VectorSource(); 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); }); } ); } // 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 属性) */ hideTertiarybasinLayer(layer: any): void { if (!this.map || !layer || !layer.key) { console.warn('hideTertiarybasinLayer: 无效的图层或地图未初始化'); return; } // 1. 从注册表中获取图层实例 const layerInstance = this.layerRegistry.get(layer.key); if (layerInstance) { layerInstance.setVisible(false); } else { console.warn(`未找到标识为 [${layer.key}] 的梯级流域图层实例`); } } /** * 删除描点图层 * @param layerKey 图层 key */ removePointLayer(layerKey: string): void { this.pointLayerManager.removeLayer(layerKey); } /** * 地图打印/导出 * 将当前地图视图导出为 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({ 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); }); } /** * 长度量算 */ 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²'; } /** * 图例和基地面板控制描点数据显示隐藏方法 * @param layerType 图层类型 * @param key 图层标识符(锚点状态 nameEn) * @param baseid 基地ID * @param checked 是否选中(显示/隐藏) * @param isAll 是否全部操作 */ mdLayerShowOrHidden( layerType: string, key?: string, baseid: string, checked?: boolean, isAll?: boolean ): void { void baseid; void isAll; this.pointLayerManager.setLegendVisibleByField( layerType, key || '', !!checked ); // 图例变化时刷新 batch popup if (this.isBatchPopupMode) { this.requestUpdateBatchPopups(true); } } /** * 根据 anchoPointState 控制单个图例的锚点显示隐藏 * @param layerKey 图层 key * @param anchoPointState 锚点状态(对应图例的 nameEn) * @param checked 是否显示 */ setLegendPointVisible( layerKey: string, anchoPointState: string, checked: boolean ): void { this.pointLayerManager.setLegendVisibleByField( layerKey, anchoPointState, checked ); this.requestRefreshPointLabelVisibility(true); // 图例变化时刷新 batch popup if (this.isBatchPopupMode) { this.requestUpdateBatchPopups(true); } } switchView(_type): void {} 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 { if (this.labelVisibilityRefreshFrameId !== null) { cancelAnimationFrame(this.labelVisibilityRefreshFrameId); this.labelVisibilityRefreshFrameId = null; } this.removeQueryLayer(); this.regionMaskManager.destroy(); const clearLayer = (layer: any) => { if (layer && layer.getSource) { const source = layer.getSource(); if (source && source.clear) { source.clear(); } } 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); this.layerRegistry.clear(); } this.popupManager.destroy(); this.pointLayerManager.destroy(); if (this.map) { this.map.getInteractions().clear(); this.map.getOverlays().clear(); this.map.setTarget(undefined); this.map.dispose(); this.map = null; } this.popupManager.setMap(null); this.pointLayerManager.setMap(null); this.regionMaskManager.setContext(null, null); if (this.view) { this.view.dispose(); this.view = null; } this.baseLayerConfig = null; this.measureSource = null; this.measureLayer = null; this.drawInteraction = null; this.geoJsonData = null; this.geoJsonData1 = null; } }