WholeProcessPlatform/frontend-sjgl/src/components/gis/ol/popup-manager.ts
2026-07-20 16:02:51 +08:00

531 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<VectorSource>[];
};
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<VectorSource>[];
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<VectorSource>)) {
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<string, any>) {
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;
}
}