WholeProcessPlatform/frontend-sjgl/src/components/gis/ol/popup-manager.ts

531 lines
16 KiB
TypeScript
Raw Normal View History

2026-07-20 16:02:51 +08:00
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;
}
}