添加3dpopup显示

This commit is contained in:
扈兆增 2026-07-14 17:08:52 +08:00
parent 3dd9270911
commit 72d91355bb

View File

@ -2,12 +2,25 @@
import type { MDOptions } from './map.class'; import type { MDOptions } from './map.class';
import type { MapInterface, layer } from './map.d'; import type { MapInterface, layer } from './map.d';
import { getIconPath } from '@/utils/index'; import { getIconPath } from '@/utils/index';
import {
generatePopupHtml,
shouldPreferEng2Popup
} from '@/utils/popupHtmlGenerator';
import { useModelStore } from '@/store/modules/model'; import { useModelStore } from '@/store/modules/model';
export class MapCesium implements MapInterface { export class MapCesium implements MapInterface {
private viewer: Cesium.Viewer | null = null; private viewer: Cesium.Viewer | null = null;
private clickEventHandler: Cesium.ScreenSpaceEventHandler | null = null; private clickEventHandler: Cesium.ScreenSpaceEventHandler | null = null;
private popupElement: HTMLElement | null = null; private popupElement: HTMLElement | null = null;
private hoveredEntityId: string | null = null;
private removePostRenderListener: (() => void) | null = null;
private isBatchPopupMode = false;
private batchPopupContainer: HTMLDivElement | null = null;
private batchPopupItems: Array<{
entity: Cesium.Entity;
element: HTMLDivElement;
}> = [];
private batchPostRenderRemover: (() => void) | null = null;
private containerId = ''; private containerId = '';
private containerElement: HTMLElement | null = null; private containerElement: HTMLElement | null = null;
private loadingOverlayElement: HTMLDivElement | null = null; private loadingOverlayElement: HTMLDivElement | null = null;
@ -48,6 +61,8 @@ export class MapCesium implements MapInterface {
private readonly CESIUM_ZOOM_FORMULA_B = 0.00007096758; private readonly CESIUM_ZOOM_FORMULA_B = 0.00007096758;
private readonly CESIUM_ZOOM_FORMULA_C = 91610.74; private readonly CESIUM_ZOOM_FORMULA_C = 91610.74;
private readonly CESIUM_ZOOM_FORMULA_D = -40467.74; private readonly CESIUM_ZOOM_FORMULA_D = -40467.74;
private readonly HOVER_POPUP_MAX_HEIGHT = 9_000_000; // ~zoom 4.5,中国视角以上不触发 hover
private readonly BATCH_POPUP_MODE_ZOOM = 15; // zoom >= 15 进入批量固定模式
// 图标缩放可调参数(只影响 3D Cesium不参与 2D 计算) // 图标缩放可调参数(只影响 3D Cesium不参与 2D 计算)
// scale = base + (zoom - 4.5) * rate钳位到 [min, max] // scale = base + (zoom - 4.5) * rate钳位到 [min, max]
@ -115,6 +130,7 @@ export class MapCesium implements MapInterface {
await this.flyToChina(); await this.flyToChina();
this.setupClickHandler(); this.setupClickHandler();
this.setupCameraPopupReset();
return this.viewer; return this.viewer;
} catch (error) { } catch (error) {
@ -250,23 +266,58 @@ export class MapCesium implements MapInterface {
// 默认箭头光标,与 2D 一致(不显示拖拽小手) // 默认箭头光标,与 2D 一致(不显示拖拽小手)
canvas.style.cursor = 'default'; canvas.style.cursor = 'default';
// 悬停锚点时显示 pointer // 鼠标移动:光标样式 + hover popup批量模式下只改光标
this.clickEventHandler.setInputAction( this.clickEventHandler.setInputAction(
(move: { endPosition: Cesium.Cartesian2 }) => { (move: { endPosition: Cesium.Cartesian2 }) => {
const scene = this.viewer?.scene; const scene = this.viewer?.scene;
if (!scene) return; if (!scene) return;
const cameraHeight = this.getCurrentCameraHeight();
// 缩放门槛:中国视角以上不触发 hover
if (cameraHeight > this.HOVER_POPUP_MAX_HEIGHT) {
canvas.style.cursor = 'default';
this.hidePopup();
return;
}
const picked = scene.pick(move.endPosition); const picked = scene.pick(move.endPosition);
canvas.style.cursor = const entity =
Cesium.defined(picked) && Cesium.defined(picked) && picked.id instanceof Cesium.Entity
picked.id && ? (picked.id as Cesium.Entity)
(picked.id as any).properties?._layerKey : null;
? 'pointer'
: 'default'; // 检测是否命中可交互的锚点
if (entity && this.isEntityInteractive(entity)) {
canvas.style.cursor = 'pointer';
// 批量模式下单点 hover 不触发 popup
if (this.isBatchPopupMode) return;
// 同一个实体不重复刷新
if (this.hoveredEntityId === entity.id) {
const position = entity.position?.getValue(
this.viewer!.clock.currentTime
);
if (position) {
this.updatePopupPosition(position);
}
return;
}
this.showPopupForEntity(entity);
return;
}
canvas.style.cursor = 'default';
// 批量模式下不隐藏批量 popup
if (!this.isBatchPopupMode) {
this.hidePopup();
}
}, },
Cesium.ScreenSpaceEventType.MOUSE_MOVE Cesium.ScreenSpaceEventType.MOUSE_MOVE
); );
// 点击:先强制渲染一帧确保 scene.pick 不阻塞,然后打开详情弹框 // 点击:打开详情弹框
this.clickEventHandler.setInputAction( this.clickEventHandler.setInputAction(
(click: { position: Cesium.Cartesian2 }) => { (click: { position: Cesium.Cartesian2 }) => {
const scene = this.viewer?.scene; const scene = this.viewer?.scene;
@ -276,16 +327,10 @@ export class MapCesium implements MapInterface {
if (!Cesium.defined(picked) || !picked.id) return; if (!Cesium.defined(picked) || !picked.id) return;
const entity = picked.id as Cesium.Entity; const entity = picked.id as Cesium.Entity;
const bag = (entity as any).properties; if (!this.isEntityInteractive(entity)) return;
if (!bag?._layerKey) return;
// 直接读取原始数据引用,避免 Cesium Property getValue 遍历开销
const rawData: Record<string, any> = (entity as any)._rawData || {}; const rawData: Record<string, any> = (entity as any)._rawData || {};
if (this.popupElement) {
this.popupElement.style.display = 'none';
}
if (rawData.sttpMap === 'ylfb') { if (rawData.sttpMap === 'ylfb') {
modelStore.ylfbModalVisible = true; modelStore.ylfbModalVisible = true;
modelStore.params = rawData; modelStore.params = rawData;
@ -299,9 +344,420 @@ export class MapCesium implements MapInterface {
); );
} }
// ==================== Step 5: hover popup + 批量 popup ====================
/** 监听相机移动结束:检测缩放阈值切换批量模式 */
private setupCameraPopupReset() {
if (!this.viewer) return;
if (this.removePostRenderListener) {
this.removePostRenderListener();
this.removePostRenderListener = null;
}
this.removePostRenderListener = this.viewer.camera.moveEnd.addEventListener(
() => {
const zoom = this.getZoomLevelFromCameraHeight(
this.getCurrentCameraHeight()
);
if (zoom >= this.BATCH_POPUP_MODE_ZOOM) {
if (!this.isBatchPopupMode) {
this.enableBatchPopupMode();
}
// 已处于批量模式时不做重建postRender 已持续更新位置),
// 避免 clear+rebuild 产生空白帧闪烁
} else {
if (this.isBatchPopupMode) {
this.disableBatchPopupMode();
}
// hover popup 重定位而非隐藏,避免缩放结束后闪烁
if (this.hoveredEntityId) {
this.repositionHoverPopup();
}
}
}
);
}
/** 根据 hoveredEntityId 重定位 popup相机移动后调用不重建内容 */
private repositionHoverPopup(): void {
if (!this.viewer || !this.popupElement || !this.hoveredEntityId) return;
const entity = this.viewer.entities.getById(this.hoveredEntityId);
if (!entity || !entity.position) {
this.hidePopup();
return;
}
const position = entity.position.getValue(this.viewer.clock.currentTime);
if (!position) {
this.hidePopup();
return;
}
this.updatePopupPosition(position);
}
/** 批量模式下每帧更新 popup 位置,实现流畅跟随拖拽 */
private setupBatchPopupPostRenderSync() {
if (!this.viewer || this.batchPostRenderRemover) return;
this.batchPostRenderRemover = this.viewer.scene.postRender.addEventListener(
() => {
if (!this.isBatchPopupMode) return;
this.updateBatchPopupPositions();
}
);
}
/** 停止每帧同步 */
private clearBatchPopupPostRenderSync() {
if (this.batchPostRenderRemover) {
this.batchPostRenderRemover();
this.batchPostRenderRemover = null;
}
}
/** 获取当前相机高度(米) */
private getCurrentCameraHeight(): number {
if (!this.viewer) return Number.POSITIVE_INFINITY;
const height = this.viewer.camera.positionCartographic.height;
if (!height || !isFinite(height) || height <= 0) {
return Number.POSITIVE_INFINITY;
}
return height;
}
/** 将相机高度映射为近似 2D zoom 级别(与 getCurrentCesiumZoom 同公式) */
private getZoomLevelFromCameraHeight(height: number): number {
const zoom =
this.CESIUM_ZOOM_FORMULA_D +
(this.CESIUM_ZOOM_FORMULA_A - this.CESIUM_ZOOM_FORMULA_D) /
(1 +
Math.pow(
height / this.CESIUM_ZOOM_FORMULA_C,
this.CESIUM_ZOOM_FORMULA_B
)) +
1;
return zoom > -1 ? zoom : 0;
}
/** 判断实体当前是否可交互(图层可见 + 图例可见 + 区域可见) */
private isEntityInteractive(entity: Cesium.Entity): boolean {
const e = entity as any;
if (e._layerVisible === false) return false;
if (e._legendVisible === false) return false;
if (e._regionVisible === false) return false;
if (entity.show === false) return false;
return true;
}
/** 根据实体属性生成 popup HTML复用 generatePopupHtml */
private getPopupHtml(rawData: Record<string, any>): string {
return rawData.popupHtml || generatePopupHtml(rawData) || '';
}
/** 关闭单个 hover popup */
private hidePopup(): void {
this.hoveredEntityId = null;
if (!this.popupElement) return;
this.popupElement.style.display = 'none';
this.popupElement.innerHTML = '';
}
/** 根据实体世界坐标把 popup 定位到屏幕坐标 */
private updatePopupPosition(position: Cesium.Cartesian3): void {
if (!this.viewer || !this.popupElement) return;
const canvasPosition = Cesium.SceneTransforms.worldToWindowCoordinates(
this.viewer.scene,
position
);
if (!canvasPosition) {
this.hidePopup();
return;
}
this.popupElement.style.left = `${canvasPosition.x}px`;
this.popupElement.style.top = `${canvasPosition.y}px`;
this.popupElement.style.display = 'block';
}
/** 读取实体原始数据并渲染 hover popup */
private showPopupForEntity(entity: Cesium.Entity): void {
if (!this.viewer || !this.popupElement || !entity.position) return;
const rawData: Record<string, any> = {
...((entity as any)._rawData || {})
};
if (!rawData.sttpMap) {
rawData.sttpMap = rawData._sttpMap || rawData.popupSttpMap || '';
}
const popupHtml = this.getPopupHtml(rawData);
const position = entity.position.getValue(this.viewer.clock.currentTime);
if (!popupHtml || !position) {
this.hidePopup();
return;
}
this.popupElement.innerHTML = popupHtml;
this.hoveredEntityId = entity.id;
this.updatePopupPosition(position);
}
// ==================== 批量 popup ====================
/** 启用批量 popup所有可见锚点固定显示 popup */
private enableBatchPopupMode() {
this.isBatchPopupMode = true;
this.clearBatchPopups();
this.showBatchPopups();
this.setupBatchPopupPostRenderSync();
}
/** 退出批量 popup */
private disableBatchPopupMode() {
this.isBatchPopupMode = false;
this.clearBatchPopupPostRenderSync();
this.clearBatchPopups();
this.hidePopup();
}
/** 遍历所有图层中交互可见且视口内的实体,分帧创建批量 popup避免卡顿 */
private showBatchPopups() {
if (!this.viewer || !this.containerElement) return;
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;
this.containerElement.appendChild(batchContainer);
const canvas = this.viewer.scene.canvas;
const viewW = canvas.clientWidth;
const viewH = canvas.clientHeight;
const padding = 60;
// 收集所有需要渲染的实体信息(含视口裁剪)
const candidates: Array<{
entity: Cesium.Entity;
x: number;
y: number;
popupHtml: string;
}> = [];
this.pointLayerRegistry.forEach(entities => {
entities.forEach(entity => {
if (!this.isEntityInteractive(entity)) return;
const position = entity.position?.getValue(
this.viewer!.clock.currentTime
);
if (!position) return;
const cp = Cesium.SceneTransforms.worldToWindowCoordinates(
this.viewer!.scene,
position
);
if (!cp) return;
// 视口裁剪:超出屏幕的直接跳过
if (
cp.x < -padding ||
cp.y < -padding ||
cp.x > viewW + padding ||
cp.y > viewH + padding
)
return;
const rawData: Record<string, any> = {
...((entity as any)._rawData || {})
};
if (!rawData.sttpMap) {
rawData.sttpMap = rawData._sttpMap || rawData.popupSttpMap || '';
}
// 批量模式eng 锚点优先用 eng2详细数据与 2D 对齐
const popupHtml = shouldPreferEng2Popup(rawData)
? generatePopupHtml(rawData, { forceEng2: true }) ||
rawData.popupHtml ||
generatePopupHtml(rawData)
: this.getPopupHtml(rawData);
if (!popupHtml) return;
candidates.push({ entity, x: cp.x, y: cp.y, popupHtml });
});
});
// 分帧渲染,每帧最多 80 个,避免一次性 DOM 操作卡顿
let index = 0;
const CHUNK_SIZE = 80;
const placedPopups: Array<{
left: number;
right: number;
top: number;
bottom: number;
}> = [];
const processChunk = () => {
if (!this.isBatchPopupMode || !this.batchPopupContainer) return;
const end = Math.min(index + CHUNK_SIZE, candidates.length);
for (let i = index; i < end; i++) {
const { entity, x, y, popupHtml } = candidates[i];
const popupEl = document.createElement('div');
popupEl.className = 'map-popup-container';
popupEl.innerHTML = popupHtml;
popupEl.style.position = 'absolute';
popupEl.style.display = 'block';
popupEl.style.transform = 'translate(-50%, calc(-100% - 10px))';
popupEl.style.pointerEvents = 'none';
// 先隐藏测量尺寸
popupEl.style.visibility = 'hidden';
popupEl.style.left = `${x}px`;
popupEl.style.top = `${y}px`;
this.batchPopupContainer!.appendChild(popupEl);
const rect = popupEl.getBoundingClientRect();
const pw = rect.width || 120;
const ph = rect.height || 40;
const popupRect = {
left: x - pw / 2,
right: x + pw / 2,
top: y - 10 - ph,
bottom: y - 10
};
let hasCollision = false;
for (const placed of placedPopups) {
if (this.checkPopupCollision(popupRect, placed)) {
hasCollision = true;
break;
}
}
if (!hasCollision) {
popupEl.style.visibility = 'visible';
popupEl.style.left = `${x}px`;
popupEl.style.top = `${y}px`;
this.batchPopupItems.push({ entity, element: popupEl });
placedPopups.push(popupRect);
} else {
this.batchPopupContainer!.removeChild(popupEl);
}
}
index = end;
if (index < candidates.length && this.isBatchPopupMode) {
requestAnimationFrame(processChunk);
}
};
requestAnimationFrame(processChunk);
}
/** 每帧更新批量 popup 屏幕位置(不重建 DOM拖拽时流畅跟随 */
private updateBatchPopupPositions() {
if (!this.viewer || !this.batchPopupContainer) return;
if (this.batchPopupItems.length === 0) return;
const canvas = this.viewer.scene.canvas;
const viewW = canvas.clientWidth;
const viewH = canvas.clientHeight;
const padding = 48;
for (let i = 0; i < this.batchPopupItems.length; i++) {
const { entity, element } = this.batchPopupItems[i];
if (!this.isEntityInteractive(entity)) {
element.style.display = 'none';
continue;
}
const position = entity.position?.getValue(
this.viewer!.clock.currentTime
);
if (!position) {
element.style.display = 'none';
continue;
}
const cp = Cesium.SceneTransforms.worldToWindowCoordinates(
this.viewer!.scene,
position
);
if (!cp) {
element.style.display = 'none';
continue;
}
if (
cp.x < -padding ||
cp.y < -padding ||
cp.x > viewW + padding ||
cp.y > viewH + padding
) {
element.style.display = 'none';
continue;
}
element.style.left = `${cp.x}px`;
element.style.top = `${cp.y}px`;
element.style.display = 'block';
}
}
/** 清除所有批量 popup */
private clearBatchPopups() {
this.batchPopupItems = [];
if (this.batchPopupContainer) {
this.batchPopupContainer.remove();
this.batchPopupContainer = null;
return;
}
const existing = document.getElementById('batch-popup-container');
if (existing) {
existing.remove();
}
}
/** 简单碰撞检测AABB + padding */
private checkPopupCollision(
a: { left: number; right: number; top: number; bottom: number },
b: { left: number; right: number; top: number; bottom: number }
): boolean {
const padding = 4;
return !(
a.right + padding < b.left ||
a.left - padding > b.right ||
a.bottom + padding < b.top ||
a.top - padding > b.bottom
);
}
// ==================== destroy ====================
destroy(): void { destroy(): void {
this.clearFlightFallbackTimer(); this.clearFlightFallbackTimer();
this.hideLoadingOverlay(); this.hideLoadingOverlay();
this.isBatchPopupMode = false;
this.clearBatchPopupPostRenderSync();
this.clearBatchPopups();
this.hidePopup();
if (this.removePostRenderListener) {
this.removePostRenderListener();
this.removePostRenderListener = null;
}
this.destroyAllPointLayers(); this.destroyAllPointLayers();
this.pointLayerRegistry.clear(); this.pointLayerRegistry.clear();
this.baseLayerRegistry.clear(); this.baseLayerRegistry.clear();
@ -620,6 +1076,12 @@ export class MapCesium implements MapInterface {
initPopupOverlay(popupContainer: HTMLDivElement): void { initPopupOverlay(popupContainer: HTMLDivElement): void {
this.popupElement = popupContainer; this.popupElement = popupContainer;
if (this.popupElement) {
this.popupElement.style.display = 'none';
this.popupElement.style.position = 'absolute';
this.popupElement.style.transform = 'translate(-50%, calc(-100% - 10px))';
this.popupElement.style.pointerEvents = 'none';
}
} }
// ==================== Step 3: 锚点展示 ==================== // ==================== Step 3: 锚点展示 ====================