diff --git a/frontend/src/components/gis/map.cesium.ts b/frontend/src/components/gis/map.cesium.ts index 61cc9271..2b7079ca 100644 --- a/frontend/src/components/gis/map.cesium.ts +++ b/frontend/src/components/gis/map.cesium.ts @@ -2,12 +2,25 @@ import type { MDOptions } from './map.class'; import type { MapInterface, layer } from './map.d'; import { getIconPath } from '@/utils/index'; +import { + generatePopupHtml, + shouldPreferEng2Popup +} from '@/utils/popupHtmlGenerator'; import { useModelStore } from '@/store/modules/model'; export class MapCesium implements MapInterface { private viewer: Cesium.Viewer | null = null; private clickEventHandler: Cesium.ScreenSpaceEventHandler | 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 containerElement: HTMLElement | 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_C = 91610.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 计算) // scale = base + (zoom - 4.5) * rate,钳位到 [min, max] @@ -115,6 +130,7 @@ export class MapCesium implements MapInterface { await this.flyToChina(); this.setupClickHandler(); + this.setupCameraPopupReset(); return this.viewer; } catch (error) { @@ -250,23 +266,58 @@ export class MapCesium implements MapInterface { // 默认箭头光标,与 2D 一致(不显示拖拽小手) canvas.style.cursor = 'default'; - // 悬停锚点时显示 pointer + // 鼠标移动:光标样式 + hover popup(批量模式下只改光标) this.clickEventHandler.setInputAction( (move: { endPosition: Cesium.Cartesian2 }) => { const scene = this.viewer?.scene; 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); - canvas.style.cursor = - Cesium.defined(picked) && - picked.id && - (picked.id as any).properties?._layerKey - ? 'pointer' - : 'default'; + const entity = + Cesium.defined(picked) && picked.id instanceof Cesium.Entity + ? (picked.id as Cesium.Entity) + : null; + + // 检测是否命中可交互的锚点 + 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 ); - // 点击:先强制渲染一帧确保 scene.pick 不阻塞,然后打开详情弹框 + // 点击:打开详情弹框 this.clickEventHandler.setInputAction( (click: { position: Cesium.Cartesian2 }) => { const scene = this.viewer?.scene; @@ -276,16 +327,10 @@ export class MapCesium implements MapInterface { if (!Cesium.defined(picked) || !picked.id) return; const entity = picked.id as Cesium.Entity; - const bag = (entity as any).properties; - if (!bag?._layerKey) return; + if (!this.isEntityInteractive(entity)) return; - // 直接读取原始数据引用,避免 Cesium Property getValue 遍历开销 const rawData: Record = (entity as any)._rawData || {}; - if (this.popupElement) { - this.popupElement.style.display = 'none'; - } - if (rawData.sttpMap === 'ylfb') { modelStore.ylfbModalVisible = true; 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 { + 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 = { + ...((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 = { + ...((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 { this.clearFlightFallbackTimer(); this.hideLoadingOverlay(); + this.isBatchPopupMode = false; + this.clearBatchPopupPostRenderSync(); + this.clearBatchPopups(); + this.hidePopup(); + if (this.removePostRenderListener) { + this.removePostRenderListener(); + this.removePostRenderListener = null; + } this.destroyAllPointLayers(); this.pointLayerRegistry.clear(); this.baseLayerRegistry.clear(); @@ -620,6 +1076,12 @@ export class MapCesium implements MapInterface { initPopupOverlay(popupContainer: HTMLDivElement): void { 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: 锚点展示 ====================