import Feature from 'ol/Feature'; import Point from 'ol/geom/Point'; import OlMap from 'ol/Map'; import Overlay from 'ol/Overlay'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import { generatePopupHtml, shouldPreferEng2Popup } from '@/utils/popupHtmlGenerator'; type PopupManagerOptions = { map: OlMap | null; getPointLayers: () => VectorLayer[]; }; type PopupHitResult = { detectedFeature: Feature | undefined; isHitIcon: boolean; coordinate?: number[]; }; type PointerMoveChangePayload = { hoveredId: string | number | null; detectedFeature: Feature | undefined; coordinate?: number[]; }; export class PopupManager { private map: OlMap | null; private popupOverlay: Overlay | null = null; private popupElement: HTMLElement | null = null; private batchPopupContainer: HTMLDivElement | null = null; private batchPopupItems: Array<{ feature: Feature; element: HTMLDivElement; }> = []; private lastHoveredId: string | number | null = null; private animationFrameId: number | null = null; private getPointLayers: () => VectorLayer[]; private popupMouseEnterHandler: (() => void) | null = null; private popupMouseMoveHandler: (() => void) | null = null; private hoverChangeHandler: | ((payload: PointerMoveChangePayload) => void) | undefined; constructor(options: PopupManagerOptions) { this.map = options.map; this.getPointLayers = options.getPointLayers; } // 备注:同步地图实例,供地图初始化和销毁后更新 Popup 管理器上下文。 setMap(map: OlMap | null) { this.map = map; } // 备注:初始化 Popup Overlay 并挂载到地图实例。 initPopupOverlay(container: HTMLElement) { this.popupElement = container; this.popupElement.style.display = 'none'; this.popupElement.style.removeProperty('position'); this.popupElement.style.removeProperty('transform'); this.popupElement.style.removeProperty('left'); this.popupElement.style.removeProperty('top'); this.popupElement.style.setProperty('pointer-events', 'none', 'important'); this.popupMouseEnterHandler = () => { this.forceHidePopup(); }; this.popupMouseMoveHandler = () => { this.forceHidePopup(); }; this.popupElement.addEventListener( 'mouseenter', this.popupMouseEnterHandler ); this.popupElement.addEventListener('mousemove', this.popupMouseMoveHandler); this.popupOverlay = new Overlay({ element: this.popupElement, positioning: 'bottom-center', offset: [0, -10], stopEvent: false, autoPan: false }); if (this.map) { this.map.addOverlay(this.popupOverlay); } } // 备注:统一处理地图点击命中检测,命中图标时通过回调把要素抛给外层。 handleMapClick( pixel: number[], onHit?: (feature: Feature, coordinate?: number[]) => void ) { const { detectedFeature, isHitIcon, coordinate } = this.detectFeatureAtPixel(pixel); if (detectedFeature && isHitIcon) { onHit?.(detectedFeature, coordinate); } } // 备注:统一处理鼠标悬停命中检测与节流,只有 hover 目标变化时才通知外层刷新。 handlePointerMove( pixel: number[], onHoverChange?: (payload: PointerMoveChangePayload) => void ) { if (!this.map) return; this.hoverChangeHandler = onHoverChange; const zoom = this.map.getView().getZoom(); if (zoom !== undefined && zoom < 4.7) { return; } if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); } this.animationFrameId = requestAnimationFrame(() => { const { detectedFeature, isHitIcon, coordinate } = this.detectFeatureAtPixel(pixel); const hoveredId = detectedFeature && isHitIcon ? (detectedFeature.getId() as any) : null; const nextFeature = hoveredId ? detectedFeature : undefined; const nextCoordinate = hoveredId ? coordinate : undefined; if (hoveredId !== this.lastHoveredId) { this.emitHoverChange({ hoveredId, detectedFeature: nextFeature, coordinate: nextCoordinate }); } }); } // 备注:在指定像素位置检测点图层要素,并判断是否命中图标区域。 detectFeatureAtPixel(pixel: number[]): PopupHitResult { let detectedFeature: Feature | undefined = undefined; let isHitIcon = false; let coordinate: number[] | undefined; const pointLayers = this.getPointLayers(); if (this.isPixelInsidePopup(pixel)) { return { detectedFeature: undefined, isHitIcon: false, coordinate: undefined }; } this.map?.forEachFeatureAtPixel( pixel, (feature, layer) => { if (pointLayers.includes(layer as VectorLayer)) { detectedFeature = feature as Feature; const geom = feature.getGeometry(); if (geom && geom.getType() === 'Point') { coordinate = (geom as Point).getCoordinates(); const iconPixel = this.map?.getPixelFromCoordinate(coordinate); if (iconPixel) { const dx = pixel[0] - iconPixel[0]; const dy = pixel[1] - iconPixel[1]; if (this.isPixelInsideIconArea(dx, dy)) { isHitIcon = true; } } } return true; } return false; }, { hitTolerance: 0 } ); return { detectedFeature, isHitIcon, coordinate }; } // 备注:仅把图标本体区域作为 hover 命中范围,避免上方文字标签被误判为图标命中。 private isPixelInsideIconArea(dx: number, dy: number): boolean { if (!this.map) return false; const zoom = this.map.getView().getZoom() ?? 4.5; let dynamicScale = 0.7 + (zoom - 4.5) * 0.08; dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale)); const halfWidth = 12 * dynamicScale; const topReach = 8 * dynamicScale; const bottomReach = 12 * dynamicScale; return ( dx >= -halfWidth && dx <= halfWidth && dy >= -topReach && dy <= bottomReach ); } // 备注:根据当前悬停要素统一显示或隐藏 Popup 内容。 showPopup(feature: Feature | undefined, coordinate: number[] | undefined) { if (!this.popupOverlay || !this.popupElement) return; if (feature && coordinate) { const props = feature.getProperties(); const popupHtml = this.getPopupHtml(props); if (popupHtml) { this.popupElement.innerHTML = popupHtml; this.popupOverlay.setPosition(coordinate); this.popupElement.style.display = 'block'; return; } } this.popupOverlay.setPosition(undefined); this.popupElement.style.display = 'none'; } private getPopupHtml(props: Record) { return props.popupHtml || generatePopupHtml(props); } // 备注:批量显示多个要素的 Popup,使用绝对定位的 div 而非 Overlay。 showPopupsForFeatures( features: Feature[], styleChecker?: (feature: Feature) => boolean, options?: { rebuild?: boolean; } ) { if (!this.map || !this.popupElement) return; const shouldRebuild = options?.rebuild !== false; // 过滤:只显示图例可见的要素 const visibleFeatures = features.filter( f => f.get('_legendVisible') !== false ); if (visibleFeatures.length === 0) { this.clearBatchPopups(); return; } if (!shouldRebuild && this.batchPopupItems.length > 0) { this.updateBatchPopupPositions(styleChecker); return; } this.clearBatchPopups(); const mapElement = this.map.getTargetElement(); const batchContainer = document.createElement('div'); batchContainer.className = 'batch-popup-container'; batchContainer.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1000; `; batchContainer.id = 'batch-popup-container'; this.batchPopupContainer = batchContainer; // 先添加容器到 DOM,以便后续元素能正确测量尺寸 mapElement.appendChild(batchContainer); // 用于碰撞检测的已放置 popup 列表 const placedPopups: Array<{ left: number; right: number; top: number; bottom: number; }> = []; visibleFeatures.forEach(feature => { const geom = feature.getGeometry(); if (!geom || geom.getType() !== 'Point') return; const coords = (geom as Point).getCoordinates(); const pixel = this.map?.getPixelFromCoordinate(coords); if (!pixel) return; // 检查样式是否隐藏(declutter、距离过滤等) if (styleChecker && !styleChecker(feature)) { return; } const props = feature.getProperties(); const popupHtml = shouldPreferEng2Popup(props) ? generatePopupHtml(props, { forceEng2: true }) || props.popupHtml || generatePopupHtml(props) : this.getPopupHtml(props); if (!popupHtml) return; // 创建与原始 popupElement 完全相同的样式 const popupEl = document.createElement('div'); popupEl.className = this.popupElement.className; popupEl.innerHTML = popupHtml; // 不设置自定义 cssText,只设置定位(对应原始 popup 的 positioning: 'bottom-center', offset: [0, -10]) popupEl.style.position = 'absolute'; popupEl.style.display = 'block'; popupEl.style.transform = 'translate(-50%, -100%)'; // bottom-center 对齐 popupEl.style.pointerEvents = 'none'; // 临时设置 visibility: hidden 来测量尺寸 popupEl.style.visibility = 'hidden'; popupEl.style.left = `${pixel[0]}px`; popupEl.style.top = `${pixel[1] - 10}px`; batchContainer.appendChild(popupEl); // 获取 popup 尺寸 const rect = popupEl.getBoundingClientRect(); const popupWidth = rect.width; const popupHeight = rect.height; // 计算 popup 的位置(对应原始 positioning: 'bottom-center', offset: [0, -10]) const popupX = pixel[0]; const popupY = pixel[1] - 10; // bottom-center 对齐 + 10px 偏移 const popupRect = { left: popupX - popupWidth / 2, right: popupX + popupWidth / 2, top: popupY - popupHeight, bottom: popupY }; // 碰撞检测 let hasCollision = false; for (const placed of placedPopups) { if (this.checkCollision(popupRect, placed)) { hasCollision = true; break; } } if (!hasCollision) { // 无碰撞,显示 popup popupEl.style.visibility = 'visible'; popupEl.style.left = `${popupX}px`; popupEl.style.top = `${popupY}px`; this.batchPopupItems.push({ feature, element: popupEl }); placedPopups.push({ left: popupRect.left, right: popupRect.right, top: popupRect.top, bottom: popupRect.bottom }); } else { // 有碰撞,移除该 popup batchContainer.removeChild(popupEl); } }); } updateBatchPopupPositions(styleChecker?: (feature: Feature) => boolean) { if ( !this.map || !this.batchPopupContainer || this.batchPopupItems.length === 0 ) { return; } const mapSize = this.map.getSize(); const viewportWidth = mapSize?.[0] ?? 0; const viewportHeight = mapSize?.[1] ?? 0; const viewportPadding = 48; this.batchPopupItems.forEach(({ feature, element }) => { if (feature.get('_legendVisible') === false) { element.style.display = 'none'; return; } if (styleChecker && !styleChecker(feature)) { element.style.display = 'none'; return; } const geom = feature.getGeometry(); if (!geom || geom.getType() !== 'Point') { element.style.display = 'none'; return; } const coords = (geom as Point).getCoordinates(); const pixel = this.map?.getPixelFromCoordinate(coords); if (!pixel) { element.style.display = 'none'; return; } const popupX = pixel[0]; const popupY = pixel[1] - 10; if ( popupX < -viewportPadding || popupY < -viewportPadding || popupX > viewportWidth + viewportPadding || popupY > viewportHeight + viewportPadding ) { element.style.display = 'none'; return; } element.style.left = `${popupX}px`; element.style.top = `${popupY}px`; element.style.display = 'block'; }); } // 备注:清除批量显示的 popups。 clearBatchPopups() { this.batchPopupItems = []; if (this.batchPopupContainer) { this.batchPopupContainer.remove(); this.batchPopupContainer = null; return; } const existing = document.getElementById('batch-popup-container'); if (existing) { existing.remove(); } } // 备注:检测两个 popup 矩形是否碰撞。 private checkCollision( rect1: { left: number; right: number; top: number; bottom: number }, rect2: { left: number; right: number; top: number; bottom: number } ): boolean { const padding = 4; return !( rect1.right + padding < rect2.left || rect1.left - padding > rect2.right || rect1.bottom + padding < rect2.top || rect1.top - padding > rect2.bottom ); } // 备注:统一重置 hover 和 Popup 状态,供地图销毁和切换时复用。 reset() { if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } this.emitHoverChange({ hoveredId: null, detectedFeature: undefined, coordinate: undefined }); } // 备注:在弹窗层意外接管鼠标时,通过统一 hover 回调链路立即清空当前悬停状态。 private forceHidePopup() { this.emitHoverChange({ hoveredId: null, detectedFeature: undefined, coordinate: undefined }); } // 备注:统一派发 hover 变化,确保地图侧的 cursor、Popup 和图层刷新走同一条处理链路。 private emitHoverChange(payload: PointerMoveChangePayload) { this.lastHoveredId = payload.hoveredId; this.hoverChangeHandler?.(payload); if (!this.hoverChangeHandler) { this.showPopup(payload.detectedFeature, payload.coordinate); } } // 备注:当鼠标进入当前 Popup 可见区域时,禁止再把该区域重新识别为图标 hover。 private isPixelInsidePopup(pixel: number[]): boolean { if (!this.map || !this.popupElement) { return false; } if (this.popupElement.style.display === 'none') { return false; } const popupRect = this.popupElement.getBoundingClientRect(); if (popupRect.width <= 0 || popupRect.height <= 0) { return false; } const mapRect = this.map.getTargetElement().getBoundingClientRect(); const clientX = mapRect.left + pixel[0]; const clientY = mapRect.top + pixel[1]; return ( clientX >= popupRect.left && clientX <= popupRect.right && clientY >= popupRect.top && clientY <= popupRect.bottom ); } // 备注:销毁 Popup 管理器内部状态和 Overlay 引用。 destroy() { this.clearBatchPopups(); this.reset(); if (this.popupElement && this.popupMouseEnterHandler) { this.popupElement.removeEventListener( 'mouseenter', this.popupMouseEnterHandler ); } if (this.popupElement && this.popupMouseMoveHandler) { this.popupElement.removeEventListener( 'mousemove', this.popupMouseMoveHandler ); } if (this.map && this.popupOverlay) { this.map.removeOverlay(this.popupOverlay); } this.popupMouseEnterHandler = null; this.popupMouseMoveHandler = null; this.hoverChangeHandler = undefined; this.popupOverlay = null; this.popupElement = null; this.map = null; } }