WholeProcessPlatform/frontend-sjgl/src/components/gis/map.cesium.ts
2026-07-31 11:13:49 +08:00

2810 lines
90 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 * as Cesium from 'cesium';
import type { MDOptions } from './map.class';
import type { MapInterface, layer } from './map.d';
import { getIconPath } from '@/utils/index';
import {
generatePopupHtml,
shouldPreferEng2Popup,
getNormalizedPopupSttpMap
} from '@/utils/popupHtmlGenerator';
import { useModelStore } from '@/store/modules/model';
import { getNearbyPointDensityDisplayRules } from '@/modules/map/domain/nearby-point-rules';
import {
LoadOSGB,
removeQxsy,
osgbChangeClick,
osgbLocation,
type OSGBItem
} from './osgbUtils';
export class MapCesium implements MapInterface {
private viewer: Cesium.Viewer | null = null;
private _ready = false;
private _pendingOSGBItems: OSGBItem[] = [];
private clickEventHandler: Cesium.ScreenSpaceEventHandler | null = null;
private popupElement: HTMLElement | null = null;
private hoveredEntityId: string | null = null;
private hoverRafId: number | null = null; // requestAnimationFrame 节流
private removePostRenderListener: (() => void) | null = null;
private removeCameraChangedListener: (() => void) | null = null;
private isBatchPopupMode = false;
private batchPopupContainer: HTMLDivElement | null = null;
private batchPopupItems: Array<{
entity: Cesium.Entity;
element: HTMLDivElement;
popupWidth: number;
popupHeight: number;
}> = [];
private batchPostRenderRemover: (() => void) | null = null;
private containerId = '';
private containerElement: HTMLElement | null = null;
private loadingOverlayElement: HTMLDivElement | null = null;
private loadingSpinnerAnimation: Animation | null = null;
private flightFallbackTimer: number | null = null;
private imageryBaseLayer: Cesium.ImageryLayer | null = null;
private baseLayerRegistry = new Map<string, Cesium.ImageryLayer>();
private baseLayerAliasMap = new Map<string, string>();
private pointLayerRegistry = new Map<string, Cesium.Entity[]>();
private hydropBaseConfig: any = null;
private BASEID = '';
private currentClipGeoJson: any = null;
private clipRequestController: AbortController | null = null;
private maskPolygonEntities: Cesium.Entity[] = [];
private originalBgColor: Cesium.Color | null = null;
private skyBoxShown = true;
private skyAtmosphereShown = true;
private readonly IGNORED_BASE_LAYER_KEYS = new Set([
'powerBaseStation',
's_hydropBase_wfs'
]);
private readonly PRIMARY_BASE_LAYER_KEYS = new Set([
'customBaseLayer',
's_province_boundaries',
'BASEMAP-white',
'BASEMAP-img'
]);
private readonly CHINA_CENTER = { lng: 104.5, lat: 36.5 };
private readonly CHINA_BOOT_HEIGHT = 20000000;
private readonly CHINA_DEFAULT_HEIGHT = 9000000;
private readonly CHINA_VIEW_ORIENTATION = {
heading: Cesium.Math.toRadians(-6),
pitch: Cesium.Math.toRadians(-90),
roll: 0
};
private readonly CESIUM_ZOOM_FORMULA_A = 40487.57;
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 = 14.9; // zoom >= 14.9 进入批量固定模式float 容差避免 15 刚好踩边界)
// ==================== Step 6: 碰撞检测 ====================
private collisionRefreshFrameId: number | null = null;
private labelVisibilityDebounceTimerId: number | null = null;
private readonly COLLISION_GRID_CELL_SIZE = 96; // 与 2D 一致
private readonly DENSITY_ISOLATION_THRESHOLD = 56; // 孤立点 pixel 阈值
private readonly COLLISION_LABEL_PADDING = 4; // 标签碰撞 padding
// 图标缩放可调参数(只影响 3D Cesium不参与 2D 计算)
// scale = base + (zoom - 4.5) * rate钳位到 [min, max]
// zoom≈4.5 是中国全国视角zoom≈15 是最大放大级别
private readonly ICON_MIN_SCALE = 0.4; // 最小缩放
private readonly ICON_MAX_SCALE = 1.2; // 最大缩放(限制放大后图标不超过 1.2x 基础值)
private readonly ICON_BASE_SCALE = 0.7; // zoom≈4.5 基准(中国视角图标 ≈ 原始尺寸)
private readonly ICON_SCALE_RATE = 0.035; // 每级 zoom 增长(放大 10 级才增加 ~0.35
// 标签文字可调参数
// fontSize = clamp(base * scale, min, max)scale = 0.7 + (zoom-4.5)*0.08
private readonly LABEL_FONT_MIN = 10; // 最小字号(px),改大 → 小缩放时文字更清晰
private readonly LABEL_FONT_MAX = 20; // 最大字号(px)
private readonly LABEL_FONT_BASE = 16; // 基准乘数,改大 → 所有缩放级别文字都变大
private readonly LABEL_OFFSET_SINGLE = 14; // 单行偏移系数,改大 → 文字离图标更远
private readonly LABEL_OFFSET_MULTI = 30; // 多行偏移系数
// ==================== Step 1: 初始化 + 飞中国 ====================
async init(container: HTMLElement, _rectangle?: any): Promise<any> {
try {
this.containerId = container.id;
this.containerElement = container;
this.showLoadingOverlay(container);
const token = 'bearer b734a443-2c8f-4f4a-8698-44828cc5f709';
this.viewer = new Cesium.Viewer(container, {
animation: false,
timeline: false,
baseLayerPicker: false,
fullscreenButton: false,
vrButton: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
infoBox: false,
selectionIndicator: false,
shouldAnimate: true,
requestRenderMode: false,
targetFrameRate: 30,
terrainProvider: new Cesium.EllipsoidTerrainProvider() // 默认不使用地形
});
this.viewer.cesiumWidget.creditContainer.style.display = 'none';
const layers = this.viewer.imageryLayers;
layers.removeAll();
const provider = new Cesium.UrlTemplateImageryProvider({
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
maximumLevel: 19,
credit:
'Esri, DigitalGlobe, GeoEye, i-cubed, USDA FSA, USGS, AEX, Getmapping, Aerogrid, IGN, IGP, swisstopo, and the GIS User Community'
});
const customTerrain = await Cesium.CesiumTerrainProvider.fromUrl(
import.meta.env.VITE_APP_MAP_URL + '/Terrain?token=' + token, // 指向包含 layer.json 的目录
{
requestVertexNormals: true
}
);
// 应用地形到Viewer
this.viewer.terrainProvider = customTerrain;
this.imageryBaseLayer = layers.addImageryProvider(provider);
this.imageryBaseLayer.show = true;
this.viewer.scene.globe.depthTestAgainstTerrain = true;
this.viewer.scene.globe.show = true;
this.viewer.scene.fog.enabled = false;
this.viewer.scene.fxaa = false;
this.viewer.useBrowserRecommendedResolution = true;
await this.flyToChina();
this.setupClickHandler();
this.setupCameraPopupReset();
this._ready = true;
console.log('[Cesium] init complete, viewer ready');
// 初始化完成后等 5 秒再加载倾斜摄影,确保渲染管线稳定
setTimeout(() => {
this.flushPendingOSGB();
}, 5000);
return this.viewer;
} catch (error) {
this.hideLoadingOverlay();
console.error('Cesium Init Critical Error:', error);
throw error;
}
}
private flyToChina(): Promise<void> {
return new Promise(resolve => {
if (!this.viewer) {
this.hideLoadingOverlay();
resolve();
return;
}
// Step 1: 瞬跳至高空俯视中国
this.viewer.camera.cancelFlight();
this.viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(
this.CHINA_CENTER.lng,
this.CHINA_CENTER.lat,
this.CHINA_BOOT_HEIGHT
),
orientation: this.CHINA_VIEW_ORIENTATION
});
this.viewer.scene.requestRender();
// Step 2: 等一帧让 setView 生效后,飞入到默认视角
requestAnimationFrame(() => {
const finish = () => {
this.clearFlightFallbackTimer();
this.hideLoadingOverlay();
resolve();
};
this.clearFlightFallbackTimer();
this.flightFallbackTimer = window.setTimeout(() => {
finish();
}, 2600);
this.viewer!.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
this.CHINA_CENTER.lng,
this.CHINA_CENTER.lat,
this.CHINA_DEFAULT_HEIGHT
),
orientation: this.CHINA_VIEW_ORIENTATION,
duration: 1.8,
complete: finish,
cancel: finish
});
});
});
}
private clearFlightFallbackTimer() {
if (this.flightFallbackTimer !== null) {
window.clearTimeout(this.flightFallbackTimer);
this.flightFallbackTimer = null;
}
}
private showLoadingOverlay(container: HTMLElement) {
this.hideLoadingOverlay();
const overlay = document.createElement('div');
overlay.setAttribute('data-map-cesium-loading', 'true');
overlay.style.position = 'absolute';
overlay.style.inset = '0';
overlay.style.display = 'flex';
overlay.style.flexDirection = 'column';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.gap = '12px';
overlay.style.background =
'linear-gradient(180deg, rgba(7, 20, 36, 0.72) 0%, rgba(7, 20, 36, 0.46) 100%)';
overlay.style.zIndex = '30';
overlay.style.backdropFilter = 'blur(2px)';
overlay.style.pointerEvents = 'auto';
const spinner = document.createElement('div');
spinner.style.width = '36px';
spinner.style.height = '36px';
spinner.style.borderRadius = '50%';
spinner.style.border = '3px solid rgba(255, 255, 255, 0.25)';
spinner.style.borderTopColor = '#ffffff';
this.loadingSpinnerAnimation = spinner.animate(
[{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
{ duration: 900, iterations: Infinity, easing: 'linear' }
);
const label = document.createElement('div');
label.textContent = '3D场景加载中...';
label.style.color = '#ffffff';
label.style.fontSize = '14px';
label.style.lineHeight = '20px';
label.style.letterSpacing = '0.5px';
label.style.textShadow = '0 1px 2px rgba(0, 0, 0, 0.35)';
overlay.appendChild(spinner);
overlay.appendChild(label);
container.appendChild(overlay);
this.loadingOverlayElement = overlay;
}
private hideLoadingOverlay() {
if (this.loadingSpinnerAnimation) {
this.loadingSpinnerAnimation.cancel();
this.loadingSpinnerAnimation = null;
}
if (this.loadingOverlayElement?.parentNode) {
this.loadingOverlayElement.parentNode.removeChild(
this.loadingOverlayElement
);
}
this.loadingOverlayElement = null;
}
// ==================== Step 7: 点击锚点打开详情弹框 ====================
private setupClickHandler() {
if (!this.viewer) return;
if (this.clickEventHandler) {
this.clickEventHandler.destroy();
}
const modelStore = useModelStore();
const canvas = this.viewer.scene.canvas;
this.clickEventHandler = new Cesium.ScreenSpaceEventHandler(canvas);
// 默认箭头光标,与 2D 一致(不显示拖拽小手)
canvas.style.cursor = 'default';
// 鼠标移动:光标样式 + hover popuprAF 节流,批量模式下只改光标)
let pendingMoveEvent: { endPosition: Cesium.Cartesian2 } | null = null;
this.clickEventHandler.setInputAction(
(move: { endPosition: Cesium.Cartesian2 }) => {
pendingMoveEvent = move;
if (this.hoverRafId !== null) return; // 已有待处理的帧,跳过
this.hoverRafId = requestAnimationFrame(() => {
this.hoverRafId = null;
const evt = pendingMoveEvent;
pendingMoveEvent = null;
if (!evt) return;
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(evt.endPosition);
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
);
// 点击:打开详情弹框
this.clickEventHandler.setInputAction(
(click: { position: Cesium.Cartesian2 }) => {
const scene = this.viewer?.scene;
if (!scene) return;
const picked = scene.pick(click.position);
if (!Cesium.defined(picked) || !picked.id) return;
const entity = picked.id as Cesium.Entity;
if (!this.isEntityInteractive(entity)) return;
const rawData: Record<string, any> = (entity as any)._rawData || {};
if (rawData.sttpMap === 'ylfb') {
modelStore.ylfbModalVisible = true;
modelStore.params = rawData;
} else {
modelStore.modalVisible = true;
modelStore.params = rawData;
modelStore.title = rawData.titleName || rawData.stnm;
}
},
Cesium.ScreenSpaceEventType.LEFT_CLICK
);
}
// ==================== Step 5: hover popup + 批量 popup ====================
/** 监听相机移动结束:检测缩放阈值切换批量模式 */
private setupCameraPopupReset() {
if (!this.viewer) return;
if (this.removePostRenderListener) {
this.removePostRenderListener();
this.removePostRenderListener = null;
}
if (this.removeCameraChangedListener) {
this.removeCameraChangedListener();
this.removeCameraChangedListener = null;
}
// camera.changed相机运动中实时刷新碰撞检测debounce 150ms
this.removeCameraChangedListener =
this.viewer.camera.changed.addEventListener(() => {
this.requestRefreshPointLabelVisibility(false, 150);
});
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();
} else {
// 已处于批量模式:重建 popup与 2D moveend 行为对齐)
// 拖拽/平移后视口内可能出现新的可见锚点,仅 postRender 重定位无法创建新 popup
// 同时修复 zoom 进出循环后 popup 消失的问题
this.rebuildBatchPopupsIfNeeded();
}
} else {
if (this.isBatchPopupMode) {
this.disableBatchPopupMode();
}
// hover popup 重定位而非隐藏,避免缩放结束后闪烁
if (this.hoveredEntityId) {
this.repositionHoverPopup();
}
}
// Step 6: 碰撞检测 — 相机停止移动后重新计算图标/标签可见性
this.requestRefreshPointLabelVisibility();
}
);
}
/** 根据 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;
}
/** 判断实体是否属于 ENG 类型(用于 popup 碰撞优先保留,与 isEntityEng 对齐) */
private isEngEntity(entity: Cesium.Entity): boolean {
const e = entity as any;
// 优先从实体 properties._sttpMap 直接检查(与 Entity 创建时设置的 _sttpMap 一致)
const sttpFromProps = String(e._sttpMap || '').toUpperCase();
if (sttpFromProps.startsWith('ENG')) return true;
// 其次从 _rawData 检查
const rawData = e._rawData as Record<string, any> | undefined;
if (!rawData) return false;
const sttpMap = getNormalizedPopupSttpMap(rawData).toUpperCase();
return sttpMap.startsWith('ENG');
}
/** 判断实体当前是否可交互(图层可见 + 图例可见 + 区域可见) */
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 = '';
}
/** 判断 3D 位置是否被地形遮挡(基于射线与 terrain 求交) */
private isPositionBehindTerrain(position: Cesium.Cartesian3): boolean {
if (!this.viewer) return true;
const scene = this.viewer.scene;
const windowCoord = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position
);
if (!windowCoord) return true; // 在地球背面
const ray = scene.camera.getPickRay(windowCoord);
if (!ray) return false;
const terrainPos = scene.globe.pick(ray, scene);
if (!terrainPos) return false; // 该方向无地形
const cameraPos = scene.camera.positionWC;
const terrainDist = Cesium.Cartesian3.distance(cameraPos, terrainPos);
const entityDist = Cesium.Cartesian3.distance(cameraPos, position);
// 地形交点明显更近 → 实体被遮挡
return terrainDist + 10 < entityDist;
}
/** 根据实体世界坐标把 popup 定位到屏幕坐标 */
private updatePopupPosition(position: Cesium.Cartesian3): void {
if (!this.viewer || !this.popupElement) return;
// 被地形遮挡时隐藏 popup
if (this.isPositionBehindTerrain(position)) {
this.hidePopup();
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() {
console.log('[Cesium] enableBatchPopupMode');
this.isBatchPopupMode = true;
this.clearBatchPopups();
this.hidePopup(); // 清除可能残留的 hover popup
// 先同步执行碰撞检测,确保 _iconCollisionVisible 已正确计算,
// 避免重叠锚点的 popup 在 showBatchPopups 中被短暂创建
this.refreshPointLabelVisibility();
// 等所有 chunk 处理完再注册 postRender避免用不完整列表做碰撞
this.showBatchPopups().then(() => {
if (this.isBatchPopupMode) {
this.setupBatchPopupPostRenderSync();
}
});
}
/** 退出批量 popup */
private disableBatchPopupMode() {
console.log('[Cesium] disableBatchPopupMode');
this.isBatchPopupMode = false;
this.clearBatchPopupPostRenderSync();
this.clearBatchPopups();
this.hidePopup();
}
/** 收集视口内可交互实体并按 X 坐标排序ENG 优先) */
private collectBatchPopupCandidates(): Array<{
entity: Cesium.Entity;
x: number;
y: number;
popupHtml: string;
}> {
if (!this.viewer) return [];
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;
// 图标被碰撞/密度隐藏的锚点不展示 popup没有可见图标popup 无意义且干扰碰撞)
if ((entity as any)._iconCollisionVisible === false) 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;
// 被地形遮挡的不展示 popup
if (this.isPositionBehindTerrain(position)) return;
const rawData: Record<string, any> = {
...((entity as any)._rawData || {})
};
if (!rawData.sttpMap) {
rawData.sttpMap = rawData._sttpMap || rawData.popupSttpMap || '';
}
let popupHtml = shouldPreferEng2Popup(rawData)
? generatePopupHtml(rawData, { forceEng2: true }) ||
rawData.popupHtml ||
generatePopupHtml(rawData)
: this.getPopupHtml(rawData);
if (!popupHtml) {
const title = rawData.titleName || rawData.stnm || rawData.ennm || '';
if (!title) return;
popupHtml = `<div class="map-popup-item"><div class="iconDivTitle">${title}</div></div>`;
}
candidates.push({ entity, x: cp.x, y: cp.y, popupHtml });
});
});
// 按 X 坐标排序X 接近时5px 容差3D 投影浮点误差ENG 优先
candidates.sort((a, b) => {
const dx = a.x - b.x;
if (Math.abs(dx) > 5) return dx;
const aEng = this.isEngEntity(a.entity) ? 0 : 1;
const bEng = this.isEngEntity(b.entity) ? 0 : 1;
if (aEng !== bEng) return aEng - bEng;
return dx;
});
return candidates;
}
/** 在指定容器中分帧构建 popup DOM避免卡顿完成后 resolve */
private buildBatchPopupsInContainer(
container: HTMLDivElement,
candidates: Array<{
entity: Cesium.Entity;
x: number;
y: number;
popupHtml: string;
}>
): Promise<void> {
return new Promise(resolve => {
let index = 0;
const CHUNK_SIZE = 80;
const placedPopups: Array<{
left: number;
right: number;
top: number;
bottom: number;
entity: Cesium.Entity;
element: HTMLDivElement;
}> = [];
const processChunk = () => {
if (!this.isBatchPopupMode) 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 =
this.popupElement?.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`;
container.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
};
const isCurrentEng = this.isEngEntity(entity);
let collidedIdx = -1;
for (let k = 0; k < placedPopups.length; k++) {
if (this.checkPopupCollision(popupRect, placedPopups[k])) {
collidedIdx = k;
break;
}
}
if (collidedIdx === -1) {
// 无碰撞:正常放置
popupEl.style.visibility = 'visible';
popupEl.style.left = `${x}px`;
popupEl.style.top = `${y}px`;
this.batchPopupItems.push({
entity,
element: popupEl,
popupWidth: pw,
popupHeight: ph
});
placedPopups.push({ ...popupRect, entity, element: popupEl });
} else {
const collided = placedPopups[collidedIdx];
const isCollidedEng = this.isEngEntity(collided.entity);
if (isCurrentEng && !isCollidedEng) {
// ENG 碰撞普通 popup移除普通展示 ENG
collided.element.remove();
this.batchPopupItems = this.batchPopupItems.filter(
item => item.element !== collided.element
);
popupEl.style.visibility = 'visible';
popupEl.style.left = `${x}px`;
popupEl.style.top = `${y}px`;
this.batchPopupItems.push({
entity,
element: popupEl,
popupWidth: pw,
popupHeight: ph
});
placedPopups[collidedIdx] = {
...popupRect,
entity,
element: popupEl
};
} else if (!isCurrentEng && isCollidedEng) {
// 普通碰撞 ENG隐藏当前普通 popup保留 ENG
container.removeChild(popupEl);
} else {
// 同类型碰撞:后来的让路
container.removeChild(popupEl);
}
}
}
index = end;
if (index < candidates.length && this.isBatchPopupMode) {
requestAnimationFrame(processChunk);
} else {
resolve();
}
};
requestAnimationFrame(processChunk);
});
}
/** 创建批量 popup 容器并构建所有可见 popup */
private showBatchPopups(): Promise<void> {
if (!this.viewer || !this.containerElement) return Promise.resolve();
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 candidates = this.collectBatchPopupCandidates();
return this.buildBatchPopupsInContainer(batchContainer, candidates);
}
/** 每一帧都在更新,不需要持久化,但清理时一并重置 */
private popupCollisionFrames = new WeakMap<HTMLDivElement, number>();
/** 每帧更新批量 popup 屏幕位置 + 碰撞检测(拖拽/缩放时流畅跟随) */
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;
// 第一遍:更新位置、收集可见项的屏幕矩形(用缓存宽高,不依赖 DOM 测量)
const visibleRects: {
item: (typeof this.batchPopupItems)[number];
x: number;
y: number;
left: number;
right: number;
top: number;
bottom: number;
}[] = [];
for (let i = 0; i < this.batchPopupItems.length; i++) {
const item = this.batchPopupItems[i];
const { entity, element, popupWidth, popupHeight } = item;
// 先恢复为 block碰撞检测再做最终决定
element.style.display = 'block';
if (!this.isEntityInteractive(entity)) {
element.style.display = 'none';
continue;
}
// 图标被碰撞隐藏的锚点不展示 popup
if ((entity as any)._iconCollisionVisible === false) {
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;
}
const x = cp.x;
const y = cp.y;
element.style.left = `${x}px`;
element.style.top = `${y}px`;
visibleRects.push({
item,
x,
y,
left: x - popupWidth / 2,
right: x + popupWidth / 2,
top: y - 10 - popupHeight,
bottom: y - 10
});
}
// 第二遍:碰撞检测 — 按 X 排序后右让左X 接近时5px 容差ENG 优先
visibleRects.sort((a, b) => {
const dx = a.x - b.x;
if (Math.abs(dx) > 5) return dx;
const aEng = this.isEngEntity(a.item.entity) ? 0 : 1;
const bEng = this.isEngEntity(b.item.entity) ? 0 : 1;
if (aEng !== bEng) return aEng - bEng;
return dx;
});
const collisionFlags = new Array(visibleRects.length).fill(false);
const instantFlags = new Array(visibleRects.length).fill(false); // ENG 碰撞立即隐藏
for (let i = 1; i < visibleRects.length; i++) {
const a = visibleRects[i];
for (let j = 0; j < i; j++) {
if (collisionFlags[j]) continue;
const b = visibleRects[j];
if (!this.checkPopupCollision(a, b)) continue;
const aEng = this.isEngEntity(a.item.entity);
const bEng = this.isEngEntity(b.item.entity);
if (aEng && !bEng) {
// ENG (a) 碰撞普通 (b)立即隐藏普通ENG 继续检查是否碰撞其他已放置项
collisionFlags[j] = true;
instantFlags[j] = true;
} else if (!aEng && bEng) {
// 普通 (a) 碰撞 ENG (b):立即隐藏普通
collisionFlags[i] = true;
instantFlags[i] = true;
break;
} else {
// 同类型碰撞:后来的让路(经 2 帧滞后防闪烁)
collisionFlags[i] = true;
break;
}
}
}
// 第三遍应用显示状态ENG 碰撞立即隐藏,同类型 2 帧滞后防闪烁
for (let i = 0; i < visibleRects.length; i++) {
const { element } = visibleRects[i].item;
const hidden = collisionFlags[i];
if (hidden && instantFlags[i]) {
// ENG 碰撞:立即隐藏,不经过滞后
element.style.display = 'none';
this.popupCollisionFrames.delete(element);
continue;
}
const prevFrames = this.popupCollisionFrames.get(element) || 0;
if (hidden) {
const count = prevFrames > 0 ? prevFrames + 1 : 1;
this.popupCollisionFrames.set(element, count);
if (count >= 2) {
element.style.display = 'none';
}
} else {
const count = prevFrames < 0 ? prevFrames - 1 : -1;
this.popupCollisionFrames.set(element, count);
if (count <= -2) {
element.style.display = 'block';
}
}
}
}
/** 批量模式下双缓冲重建 popup图层/图例变化、拖拽/缩放 moveEnd 时调用)。
* 在新隐藏容器中构建 popup构建完成后替换旧容器避免 clear→rebuild 空白帧闪烁。 */
private _rebuildGeneration = 0;
private rebuildBatchPopupsIfNeeded() {
if (!this.isBatchPopupMode || !this.containerElement) return;
console.log(
'[Cesium] rebuildBatchPopups gen=%d',
this._rebuildGeneration + 1
);
// 停止旧容器的 postRender 同步(旧 popup 在新容器就绪前保持显示)
this.clearBatchPopupPostRenderSync();
const oldContainer = this.batchPopupContainer;
const generation = ++this._rebuildGeneration;
// 在隐藏容器中构建新 popup尚未插入 DOM
const newContainer = document.createElement('div');
newContainer.className = 'batch-popup-container';
newContainer.style.cssText =
'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:1000;';
newContainer.id = 'batch-popup-container';
this.batchPopupContainer = newContainer;
this.batchPopupItems = [];
this.popupCollisionFrames = new WeakMap();
const candidates = this.collectBatchPopupCandidates();
this.buildBatchPopupsInContainer(newContainer, candidates).then(() => {
// 如果期间触发了新的重建,丢弃本次结果
if (generation !== this._rebuildGeneration) {
newContainer.remove();
return;
}
if (!this.isBatchPopupMode) {
newContainer.remove();
return;
}
// 双缓冲替换:移除旧容器 → 插入新容器 → 注册 postRender
if (oldContainer) oldContainer.remove();
this.containerElement!.appendChild(newContainer);
this.setupBatchPopupPostRenderSync();
});
}
/** 清除所有批量 popup */
private clearBatchPopups() {
this.batchPopupItems = [];
this.popupCollisionFrames = new WeakMap<HTMLDivElement, number>();
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._ready = false;
this._pendingOSGBItems = [];
// 批量 popup 和 hover popup
this.isBatchPopupMode = false;
this.clearBatchPopupPostRenderSync();
this.clearBatchPopups();
this.hidePopup();
// hover 节流 requestAnimationFrame
if (this.hoverRafId !== null) {
cancelAnimationFrame(this.hoverRafId);
this.hoverRafId = null;
}
// 相机事件监听
if (this.removePostRenderListener) {
this.removePostRenderListener();
this.removePostRenderListener = null;
}
if (this.removeCameraChangedListener) {
this.removeCameraChangedListener();
this.removeCameraChangedListener = null;
}
// 碰撞检测 rAF / 定时器
if (this.collisionRefreshFrameId !== null) {
cancelAnimationFrame(this.collisionRefreshFrameId);
this.collisionRefreshFrameId = null;
}
if (this.labelVisibilityDebounceTimerId !== null) {
window.clearTimeout(this.labelVisibilityDebounceTimerId);
this.labelVisibilityDebounceTimerId = null;
}
// 中止进行中的基地裁切请求
if (this.clipRequestController) {
this.clipRequestController.abort();
this.clipRequestController = null;
}
// 清理遮罩多边形 Entity需在 viewer.destroy 前清除)
if (this.viewer && !this.viewer.isDestroyed()) {
this.maskPolygonEntities.forEach(entity => {
this.viewer!.entities.remove(entity);
});
}
this.maskPolygonEntities = [];
// 清理锚点图层和底图注册表
this.destroyAllPointLayers();
this.pointLayerRegistry.clear();
this.baseLayerRegistry.clear();
this.baseLayerAliasMap.clear();
this.imageryBaseLayer = null;
// 清理 Cesium 事件处理器
if (this.clickEventHandler) {
this.clickEventHandler.destroy();
this.clickEventHandler = null;
}
this.popupElement = null;
// 销毁 Cesium Viewer
if (this.viewer) {
this._rebuildGeneration++; // 中止进行中的双缓冲重建
this.viewer.camera.cancelFlight();
this.viewer.destroy();
this.viewer = null;
}
this.containerElement = null;
// 重置所有状态变量(确保下次 init 时是干净状态)
this.popupCollisionFrames = new WeakMap();
this.currentClipGeoJson = null;
this.originalBgColor = null;
this.skyBoxShown = true;
this.skyAtmosphereShown = true;
this.hydropBaseConfig = null;
this.BASEID = '';
this.hoveredEntityId = null;
console.log('[Cesium] destroyed');
}
private requestRender() {
this.viewer?.scene.requestRender();
}
/**
* 标签文本格式化:与 2D formatPointLabelText 完全对齐
* - 去除括号 ()
* - 每行最多 12 个字符
* - 13~24 字符 → 拆成两行
* - 超过 24 字符 → 第二行末尾用 "..." 截断
*/
private formatPointLabelText(text: string): string {
const normalizedText = String(text || '')
.replace(/[()]/g, '')
.trim();
if (!normalizedText) {
return '';
}
const maxLineLength = 12;
if (normalizedText.length <= maxLineLength) {
return normalizedText;
}
const firstLine = normalizedText.slice(0, maxLineLength);
if (normalizedText.length <= maxLineLength * 2) {
return `${firstLine}\n${normalizedText.slice(maxLineLength)}`;
}
const secondLine = `${normalizedText.slice(
maxLineLength,
maxLineLength + 9
)}...`;
return `${firstLine}\n${secondLine}`;
}
// ==================== Step 2: 基础底图 ====================
private getRegistryKeys(layerConfig: any): string[] {
const keys = [layerConfig?.key, layerConfig?.id].filter(
(key): key is string => typeof key === 'string' && key.length > 0
);
return Array.from(new Set(keys));
}
private resolvePrimaryBaseLayerKey(layerKey: string): string | undefined {
if (!layerKey) return undefined;
if (this.baseLayerRegistry.has(layerKey)) return layerKey;
return this.baseLayerAliasMap.get(layerKey);
}
private unregisterBaseLayer(layerConfig: any) {
if (!this.viewer) return;
const registryKeys = this.getRegistryKeys(layerConfig);
const uniquePrimaryKeys = new Set<string>();
registryKeys.forEach(key => {
const primaryKey = this.resolvePrimaryBaseLayerKey(key) || key;
uniquePrimaryKeys.add(primaryKey);
this.baseLayerAliasMap.delete(key);
});
uniquePrimaryKeys.forEach(primaryKey => {
const existingLayer = this.baseLayerRegistry.get(primaryKey);
if (existingLayer) {
this.viewer?.imageryLayers.remove(existingLayer, true);
this.baseLayerRegistry.delete(primaryKey);
}
});
}
private registerBaseLayer(
layerConfig: any,
imageryLayer: Cesium.ImageryLayer
) {
const registryKeys = this.getRegistryKeys(layerConfig);
const primaryKey = layerConfig?.key || layerConfig?.id;
if (!primaryKey) return;
this.baseLayerRegistry.set(primaryKey, imageryLayer);
registryKeys.forEach(key => {
this.baseLayerAliasMap.set(key, primaryKey);
});
}
private getLayerRequestUrl(layerConfig: any): string {
if (layerConfig?.key === 'customBaseLayer') {
return layerConfig?.url_3d;
}
return layerConfig?.url || layerConfig?.url_3d || '';
}
private createImageryLayer(layerConfig: any, visible: boolean) {
if (layerConfig?.key === 'hydropBase') return null;
if (!this.viewer) return null;
const url = this.getLayerRequestUrl(layerConfig);
if (!url) return null;
let imageryLayer: Cesium.ImageryLayer | null = null;
// WMTS与 2D addBaseDataLayer 对齐,从 URL 参数中提取 LAYER / TILEMATRIXSET
// 必须提供 tileMatrixLabels否则 Cesium 默认用纯数字 tileMatrix 值,
// 而服务器期望的是 "TileMatrixSet:zoom" 格式(如 EPSG:3857_hbb_zrbhq_l13:4
if (layerConfig?.type === 'wmts') {
const urlParts = url.split('?');
const baseUrl = urlParts[0];
const urlParams = new URLSearchParams(urlParts[1] || '');
const wmtsLayer = urlParams.get('LAYER') || urlParams.get('layer') || '';
const tileMatrixSetID =
urlParams.get('TILEMATRIXSET') || urlParams.get('TileMatrixSet') || '';
const maxLevel = 19;
const tileMatrixLabels = Array.from(
{ length: maxLevel + 1 },
(_, i) => `${tileMatrixSetID}:${i}`
);
imageryLayer = this.viewer.imageryLayers.addImageryProvider(
new Cesium.WebMapTileServiceImageryProvider({
url: baseUrl,
layer: wmtsLayer,
style: 'raster',
format: 'image/png',
tileMatrixSetID: tileMatrixSetID,
tileMatrixLabels: tileMatrixLabels,
maximumLevel: maxLevel
})
);
} else if (
layerConfig?.type === 'raster-dem' ||
/\{x\}|\{y\}|\{z\}/.test(url)
) {
imageryLayer = this.viewer.imageryLayers.addImageryProvider(
new Cesium.UrlTemplateImageryProvider({ url })
);
} else if (url.includes('MapServer')) {
imageryLayer = this.viewer.imageryLayers.add(
Cesium.ImageryLayer.fromProviderAsync(
Cesium.ArcGisMapServerImageryProvider.fromUrl(url, {
enablePickFeatures: false,
maximumLevel: 19
})
)
);
} else {
const cleanUrl = url.split('?')[0];
const wmsLayers = layerConfig?.layers || '';
imageryLayer = this.viewer.imageryLayers.addImageryProvider(
new Cesium.WebMapServiceImageryProvider({
url: cleanUrl,
layers: wmsLayers,
parameters: {
service: 'WMS',
transparent: 'true',
format: 'image/png'
}
})
);
}
if (!imageryLayer) return null;
imageryLayer.show = visible;
if (typeof layerConfig?.rasteropacity === 'number') {
imageryLayer.alpha = layerConfig.rasteropacity;
}
return imageryLayer;
}
addBaseDataLayer(layerConfig: any, isShow = true): void {
if (!this.viewer || !layerConfig?.key) return;
// 基地裁切配置存储(与 2D 对齐layer.type === 'vector' && layer.key === 'hydropBase'
if (layerConfig.type === 'vector' && layerConfig.key === 'hydropBase') {
this.hydropBaseConfig = layerConfig;
return;
}
if (
this.IGNORED_BASE_LAYER_KEYS.has(layerConfig.key) ||
this.IGNORED_BASE_LAYER_KEYS.has(layerConfig.id)
) {
return;
}
if (layerConfig.key === 'BASEMAP-img') {
if (this.imageryBaseLayer) {
this.imageryBaseLayer.show = !!isShow;
}
this.requestRender();
return;
}
const imageryLayer = this.createImageryLayer(layerConfig, !!isShow);
if (!imageryLayer) return;
this.unregisterBaseLayer(layerConfig);
this.registerBaseLayer(layerConfig, imageryLayer);
this.requestRender();
}
controlBaseLayerTreeShowAndHidden(
layerType: string,
key: string,
checked: boolean
): void {
const targetKey = key || layerType;
if (targetKey === 'BASEMAP-img') {
if (this.imageryBaseLayer) {
this.imageryBaseLayer.show = checked;
}
return;
}
const primaryKey = this.resolvePrimaryBaseLayerKey(targetKey);
if (!primaryKey) return;
const imageryLayer = this.baseLayerRegistry.get(primaryKey);
if (!imageryLayer) return;
imageryLayer.show = checked;
this.requestRender();
}
hasBaseLayer(layerKey: string): boolean {
if (layerKey === 'BASEMAP-img') return !!this.imageryBaseLayer;
return !!this.resolvePrimaryBaseLayerKey(layerKey);
}
// ==================== 其他公共方法 ====================
flyTopanto(position: number[], zoom: number): void {
if (!this.viewer || !position) return;
const [lng, lat] = position;
const height = 20000000 / Math.pow(2, zoom);
this.viewer.camera.cancelFlight();
this.viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
lng,
lat,
height > 100 ? height : 1000
),
duration: 1.5
});
}
getCurrentZoom(): number | undefined {
return this.getCurrentCesiumZoom();
}
private getCurrentCesiumZoom() {
if (!this.viewer) return undefined;
const height = this.viewer.camera.positionCartographic.height;
if (!height || !isFinite(height) || height <= 0) return undefined;
const zoom =
this.CESIUM_ZOOM_FORMULA_D +
(this.CESIUM_ZOOM_FORMULA_A - this.CESIUM_ZOOM_FORMULA_D) /
(1 +
Math.pow(
Number(height) / this.CESIUM_ZOOM_FORMULA_C,
this.CESIUM_ZOOM_FORMULA_B
)) +
1;
return zoom > -1 ? zoom : 0;
}
zoomToggle(type: 'out' | 'in'): void {
if (!this.viewer) return;
const factor = 1.5;
if (type === 'in') this.viewer.camera.zoomIn(factor);
else this.viewer.camera.zoomOut(factor);
}
mapOutPut(fileName = '3D_截图.png'): void {
if (!this.viewer) {
console.warn('Viewer is not initialized.');
return;
}
try {
const canvas = this.viewer.canvas;
this.viewer.render();
const dataURL = canvas.toDataURL('image/png');
if (dataURL.length < 100) {
console.warn(
'Canvas data is too small, likely black or empty. Check CORS settings.'
);
}
const link = document.createElement('a');
link.href = dataURL;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('Failed to export map image:', error);
}
}
initPopupOverlay(popupContainer: HTMLDivElement): void {
this.popupElement = popupContainer;
if (this.popupElement) {
// 清除可能残留的旧样式
this.popupElement.style.removeProperty('position');
this.popupElement.style.removeProperty('transform');
this.popupElement.style.removeProperty('left');
this.popupElement.style.removeProperty('top');
this.popupElement.style.display = 'none';
this.popupElement.style.position = 'absolute';
this.popupElement.style.transform = 'translate(-50%, calc(-100% - 10px))';
this.popupElement.style.setProperty(
'pointer-events',
'none',
'important'
);
}
}
// ==================== Step 3: 锚点展示 ====================
addInitDataLayer(
pointData: any,
layerType: any,
_mdoptions?: MDOptions
): void {
if (!this.viewer) return;
let dataArray: any[] = [];
let targetLayerKey = layerType;
// 与 2D PointLayerManager.addDataLayer 对齐的输入归一化
if (Array.isArray(pointData)) {
dataArray = pointData;
} else {
dataArray = pointData?.data || [];
targetLayerKey = pointData?.key || layerType;
}
if (!targetLayerKey) {
console.warn('[Cesium] 缺少图层 Key无法加载锚点');
return;
}
// 先移除旧图层数据
this.removePointLayer(targetLayerKey);
const entities: Cesium.Entity[] = [];
dataArray.forEach((item: any) => {
const { lgtd, lttd, stnm, iconCode, titleName, ennm } = item;
if (lgtd == null || lttd == null) return;
const lon = Number(lgtd);
const lat = Number(lttd);
if (isNaN(lon) || isNaN(lat) || !isFinite(lon) || !isFinite(lat)) return;
if (Math.abs(lon) > 180 || Math.abs(lat) > 90) return;
// 图标:复用现有图标资源路径,与 2D 一致
const iconUrl = iconCode
? getIconPath(iconCode)
: getIconPath('default') || '';
// 标签文本:格式化(换行截断)+ ylfb 用 ftp其他用 titleName > stnm > ennm
const rawLabelText =
item.sttpMap === 'ylfb'
? item.ftp || ''
: titleName || stnm || ennm || '';
const labelText = this.formatPointLabelText(rawLabelText);
// 行数决定纵向偏移量,与 2D getPointLabelRenderOffsetY 对齐
const labelLineCount = labelText.includes('\n') ? 2 : labelText ? 1 : 0;
// Entity ID与 2D 对齐,使用 _id 优先_id = sttpMap_stcd确保同 stcd 不同 sttpMap 的点不互相覆盖
const rawId =
item._id || item.stcd || `${lon.toFixed(6)},${lat.toFixed(6)}`;
const entityId = `${targetLayerKey}:${rawId}`;
// 图标缩放:使用 CallbackProperty 动态响应相机缩放变化
// 调参见顶部 ICON_ 常量
const dynamicIconScale = new Cesium.CallbackProperty(() => {
const zoom = this.getCurrentCesiumZoom() || 4.5;
return Math.max(
this.ICON_MIN_SCALE,
Math.min(
this.ICON_MAX_SCALE,
this.ICON_BASE_SCALE + (zoom - 4.5) * this.ICON_SCALE_RATE
)
);
}, false);
// 字体大小:与 2D getPointFontSize 对齐 → fontSize = clamp(base * scale, min, max)
// 调参见顶部 LABEL_FONT_ 常量
const dynamicLabelFont = new Cesium.CallbackProperty(() => {
const zoom = this.getCurrentCesiumZoom() || 4.5;
const scale = Math.max(0.5, Math.min(3.0, 0.7 + (zoom - 4.5) * 0.08));
const fontSize = Math.round(
Math.max(
this.LABEL_FONT_MIN,
Math.min(this.LABEL_FONT_MAX, this.LABEL_FONT_BASE * scale)
)
);
return `${fontSize}px sans-serif`;
}, false);
// 标签纵向偏移:与 2D getPointLabelRenderOffsetY 对齐
// 调参见顶部 LABEL_OFFSET_ 常量
const dynamicLabelOffset = new Cesium.CallbackProperty(() => {
const zoom = this.getCurrentCesiumZoom() || 4.5;
const scale = Math.max(0.5, Math.min(3.0, 0.7 + (zoom - 4.5) * 0.08));
const offsetY =
labelLineCount > 1
? -this.LABEL_OFFSET_MULTI * scale
: -this.LABEL_OFFSET_SINGLE * scale;
return new Cesium.Cartesian2(0, offsetY);
}, false);
const entity = new Cesium.Entity({
id: entityId,
position: Cesium.Cartesian3.fromDegrees(lon, lat, 0),
billboard: {
image: iconUrl || undefined,
scale: dynamicIconScale as any,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(1.0e3, 1.5, 2.0e7, 0.8),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
} as any,
label: {
text: labelText || undefined,
font: dynamicLabelFont as any,
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
pixelOffset: dynamicLabelOffset as any,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(1.0e3, 1.2, 2.0e7, 0.8),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
} as any,
properties: {
...item,
_layerKey: targetLayerKey,
_iconUrl: iconUrl,
_labelText: labelText,
_legendVisible: true,
_regionVisible: true,
_sttpMap: item.sttpMap
} as any
});
this.viewer!.entities.add(entity);
// 存储原始业务数据引用,避免点击时遍历 Cesium Property
(entity as any)._rawData = item;
(entity as any)._labelText = labelText;
// 可见性控制标志(组合计算 entity.show
(entity as any)._layerVisible = true;
(entity as any)._legendVisible = true;
(entity as any)._regionVisible = true;
// 碰撞检测可见性标志(初始均为可见,碰撞检测会按需更新)
(entity as any)._iconCollisionVisible = true;
(entity as any)._labelCollisionVisible = true;
// 初始应用可见性(确保 entity.show / entity.label.show 被显式设置)
this.applyEntityVisibility(entity);
entities.push(entity);
});
this.pointLayerRegistry.set(targetLayerKey, entities);
// 如果当前有活跃的基地裁切,新图层也需要过滤
if (this.currentClipGeoJson) {
this.filterPointsByRegionForLayer(
targetLayerKey,
this.extractPolygonCoords(this.currentClipGeoJson)
);
}
// 数据加载完成后触发首次碰撞检测(同步执行,避免异步 rAF 导致的首帧闪烁)
this.requestRefreshPointLabelVisibility(true);
}
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {
const entities = this.pointLayerRegistry.get(layerType);
if (!entities) return;
const visible = checked !== false;
entities.forEach(entity => {
(entity as any)._layerVisible = visible;
this.applyEntityVisibility(entity);
});
// 图层切换后立即重算碰撞(同步执行,避免闪烁)
this.requestRefreshPointLabelVisibility(true);
// 批量模式下同步重建 popup
this.rebuildBatchPopupsIfNeeded();
}
removePointLayer(layerKey: string): void {
if (!layerKey) return;
const entities = this.pointLayerRegistry.get(layerKey);
if (!entities) return;
entities.forEach(entity => {
if (this.viewer && !this.viewer.isDestroyed()) {
this.viewer.entities.remove(entity);
}
});
this.pointLayerRegistry.delete(layerKey);
}
hasLayer(layerKey: string): boolean {
return this.pointLayerRegistry.has(layerKey);
}
private destroyAllPointLayers(): void {
this.pointLayerRegistry.forEach((entities, layerKey) => {
entities.forEach(entity => {
if (this.viewer && !this.viewer.isDestroyed()) {
this.viewer.entities.remove(entity);
}
});
});
this.pointLayerRegistry.clear();
}
// ==================== Stubs后续步骤补齐 ====================
baseLayerSwitcher(_key: string): void {}
switchView(_type: '2D' | '3D'): void {}
fitBounds(_bounds: any): void {}
lengthCalculate(): void {}
areCalculate(): void {}
removeQueryLayer(): void {}
addTertiarybasinLayer(
_layer: layer,
_fillcolor: any,
_outlineColor: any,
_datas: any
): void {}
hideTertiarybasinLayer(_layer: layer): void {}
async jdPanelControlShowAndHidden(
_regionId: string,
isAll: boolean
): Promise<void> {
if (!this.viewer || !this.hydropBaseConfig) {
console.warn('地图未初始化或 hydropBaseConfig 未配置');
return;
}
// 取消正在进行的裁切请求
if (this.clipRequestController) {
this.clipRequestController.abort();
this.clipRequestController = null;
}
this.BASEID = _regionId;
if (!isAll) {
// 取消裁切:清除遮罩多边形,恢复全部锚点可见,平滑飞回全国视图
this.currentClipGeoJson = null;
this.clearMaskPolygon();
this.setAllPointsRegionVisible(true);
this.viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
this.CHINA_CENTER.lng,
this.CHINA_CENTER.lat,
this.CHINA_DEFAULT_HEIGHT
),
orientation: this.CHINA_VIEW_ORIENTATION,
duration: 1.0
});
return;
}
// 用户从"全部"进入,锚点本来就全可见,无需 API 前先隐藏(避免全量遍历卡顿)
this.clipRequestController = new AbortController();
const signal = this.clipRequestController.signal;
try {
const url =
this.hydropBaseConfig.geojson_url +
`&cql_filter=BASEID='${this.BASEID}'`;
const geoJsonData = await this.fetchGeoJson(url, signal);
if (signal.aborted) return;
if (this.BASEID !== _regionId) return;
this.currentClipGeoJson = geoJsonData;
const regionCoords = this.extractPolygonCoords(geoJsonData);
this.filterPointsByRegion(regionCoords);
this.fitViewToGeoJson(regionCoords);
this.createMaskPolygon(regionCoords);
} catch (error: any) {
if (error.name === 'AbortError') return;
this.currentClipGeoJson = null;
console.error('加载裁切数据失败:', error);
this.clearMaskPolygon();
this.setAllPointsRegionVisible(true);
} finally {
if (this.clipRequestController?.signal === signal) {
this.clipRequestController = null;
}
}
}
mdLayerShowOrHidden(
_layerType: string,
_key?: string,
_baseid?: string,
_checked?: boolean,
_isAll?: boolean
): void {}
setLegendPointVisible(
layerKey: string,
anchoPointState: string,
checked: boolean
): void {
const entities = this.pointLayerRegistry.get(layerKey);
if (!entities) return;
if (!anchoPointState) {
// 第一步:全部隐藏(配合 restoreLayerLegendVisibility 两步走:先全隐藏再按图例逐项恢复)
entities.forEach(entity => {
(entity as any)._legendVisible = false;
this.applyEntityVisibility(entity);
});
this.requestRefreshPointLabelVisibility(true);
this.rebuildBatchPopupsIfNeeded();
return;
}
// 按 anchoPointState 字段匹配并设置可见性
entities.forEach(entity => {
const rawData = (entity as any)._rawData;
if (rawData && rawData.anchoPointState === anchoPointState) {
(entity as any)._legendVisible = checked;
this.applyEntityVisibility(entity);
}
});
// 图例切换后立即重算碰撞(同步执行,避免闪烁)
this.requestRefreshPointLabelVisibility(true);
this.rebuildBatchPopupsIfNeeded();
}
// ==================== 基地裁切辅助方法 ====================
/** 获取 GeoJSON 数据 */
private async fetchGeoJson(url: string, signal?: AbortSignal): Promise<any> {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
/** 批量设置全部锚点的 _regionVisible 标志 */
private setAllPointsRegionVisible(visible: boolean): void {
this.pointLayerRegistry.forEach(entities => {
entities.forEach(entity => {
(entity as any)._regionVisible = visible;
this.applyEntityVisibility(entity);
});
});
}
/** 根据 GeoJSON 边界批量更新锚点的区域显隐状态(射线法) */
private filterPointsByRegion(regionCoords: number[][][]): void {
if (!regionCoords.length) {
console.warn('无法解析区域边界,显示所有锚点');
this.setAllPointsRegionVisible(true);
return;
}
this.pointLayerRegistry.forEach((_entities, layerKey) => {
this.filterPointsByRegionForLayer(layerKey, regionCoords);
});
}
/** 对单个图层应用基地裁切过滤 */
private filterPointsByRegionForLayer(
layerKey: string,
regionCoords: number[][][]
): void {
const entities = this.pointLayerRegistry.get(layerKey);
if (!entities || !regionCoords.length) return;
entities.forEach(entity => {
const rawData = (entity as any)._rawData;
if (!rawData) {
(entity as any)._regionVisible = false;
this.applyEntityVisibility(entity);
return;
}
const lon = Number(rawData.lgtd);
const lat = Number(rawData.lttd);
if (!isFinite(lon) || !isFinite(lat)) {
(entity as any)._regionVisible = false;
this.applyEntityVisibility(entity);
return;
}
(entity as any)._regionVisible = this.isPointInPolygon(
lon,
lat,
regionCoords
);
this.applyEntityVisibility(entity);
});
}
/** 根据多边形坐标飞行相机到基地边界 */
private fitViewToGeoJson(regionCoords: number[][][]): void {
if (!this.viewer) return;
try {
if (!regionCoords.length) return;
// 计算所有坐标的经纬度范围
let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
regionCoords.forEach(ring => {
ring.forEach(([lon, lat]) => {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
});
});
if (!isFinite(minLon)) return;
const rectangle = Cesium.Rectangle.fromDegrees(
minLon,
minLat,
maxLon,
maxLat
);
this.viewer.camera.flyTo({
destination: rectangle,
duration: 1.0,
complete: () => this.requestRender()
});
} catch (error) {
console.error('调整3D视野失败:', error);
}
}
/** 应用基地裁切globe.clippingPolygons 直接裁切地球渲染 */
private createMaskPolygon(regionCoords: number[][][]): void {
if (!this.viewer) return;
// 仅清理旧边框(不重置 clipping/背景色/skyBox避免 GPU 状态反复重建)
this.maskPolygonEntities.forEach(entity => {
if (!this.viewer!.isDestroyed()) {
this.viewer!.entities.remove(entity);
}
});
this.maskPolygonEntities = [];
if (!regionCoords.length) return;
const cartesianRings = regionCoords.map(ring =>
Cesium.Cartesian3.fromDegreesArray(
ring.flatMap(([lon, lat]) => [lon, lat])
)
);
// 首次进入裁切时保存原始状态
if (!this.originalBgColor) {
this.originalBgColor = this.viewer.scene.backgroundColor.clone();
this.skyBoxShown = this.viewer.scene.skyBox?.show ?? true;
this.skyAtmosphereShown = this.viewer.scene.skyAtmosphere?.show ?? true;
}
// 边界外设为白色
if (this.viewer.scene.skyBox) this.viewer.scene.skyBox.show = false;
if (this.viewer.scene.skyAtmosphere)
this.viewer.scene.skyAtmosphere.show = false;
this.viewer.scene.backgroundColor = Cesium.Color.WHITE;
// 直接设新裁切(不经过空集合,避免 GPU shader 重复编译)
this.viewer.scene.globe.clippingPolygons =
new Cesium.ClippingPolygonCollection({
polygons: cartesianRings.map(
positions => new Cesium.ClippingPolygon({ positions })
),
inverse: true,
enabled: true
});
// 绘制基地边界线(双线视觉效果:紫色 + 浅紫色)
const borderStyles = [
{ width: 3, color: '#6D64DF' },
{ width: 1.5, color: '#CCC9F4' }
];
borderStyles.forEach(({ width, color }) => {
cartesianRings.forEach(positions => {
this.maskPolygonEntities.push(
this.viewer!.entities.add({
polyline: {
positions,
width,
material: Cesium.Color.fromCssColorString(color),
clampToGround: true
}
})
);
});
});
this.requestRender();
this.viewer.scene.screenSpaceCameraController.maximumZoomDistance = 6_400_000;
}
/** 清除遮罩多边形 */
private clearMaskPolygon(): void {
if (!this.viewer) return;
// 解除 globe 裁切
this.viewer.scene.globe.clippingPolygons =
new Cesium.ClippingPolygonCollection();
// 恢复背景色、星空、大气层
if (this.originalBgColor) {
this.viewer.scene.backgroundColor = this.originalBgColor;
this.originalBgColor = null;
}
if (this.viewer.scene.skyBox) {
this.viewer.scene.skyBox.show = this.skyBoxShown;
}
if (this.viewer.scene.skyAtmosphere) {
this.viewer.scene.skyAtmosphere.show = this.skyAtmosphereShown;
}
this.viewer.scene.screenSpaceCameraController.maximumZoomDistance =
Infinity;
this.maskPolygonEntities.forEach(entity => {
if (!this.viewer!.isDestroyed()) {
this.viewer!.entities.remove(entity);
}
});
this.maskPolygonEntities = [];
}
/** 从 GeoJSON 中提取多边形环坐标 */
private extractPolygonCoords(geoJson: any): number[][][] {
if (!geoJson?.features?.length) return [];
const coords: number[][][] = [];
geoJson.features.forEach((feature: any) => {
const geometry = feature.geometry;
if (!geometry) return;
if (geometry.type === 'Polygon') {
coords.push(...geometry.coordinates);
} else if (geometry.type === 'MultiPolygon') {
geometry.coordinates.forEach((polygon: number[][][]) => {
coords.push(...polygon);
});
}
});
return coords;
}
/** 射线法判断点是否在任意一个多边形环内 */
private isPointInPolygon(
lon: number,
lat: number,
polygons: number[][][]
): boolean {
for (const polygon of polygons) {
if (this.isPointInRing(lon, lat, polygon)) {
return true;
}
}
return false;
}
/** 射线法判断点是否在单个闭合环内 */
private isPointInRing(lon: number, lat: number, ring: number[][]): boolean {
let inside = false;
const n = ring.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
const intersect =
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
/**
* 组合计算可见性3D 解耦模型):
* - entity.show = 基础可见layer && legend && region保持 entity 始终"在作用域内"
* - entity.billboard.show = 基础可见 && 图标碰撞通过
* - entity.label.show = 基础可见 && 标签碰撞通过(图标碰撞不影响标签)
*/
private applyEntityVisibility(entity: Cesium.Entity): void {
const e = entity as any;
const baseVisible = e._layerVisible && e._legendVisible && e._regionVisible;
// entity 整体始终基于 layer/legend/region不受碰撞影响保证 popup/click 等交互不受碰撞干扰)
entity.show = baseVisible;
// 图标单独受碰撞控制
if (entity.billboard) {
(entity.billboard as any).show =
baseVisible && e._iconCollisionVisible !== false;
}
// 标签单独受碰撞控制(不与图标碰撞联动)
if (entity.label) {
(entity.label as any).show =
baseVisible && e._labelCollisionVisible !== false;
}
}
// ==================== Step 6: 碰撞检测 ====================
/**
* 触发碰撞检测刷新(带 rAF 节流,与 2D requestRefreshPointLabelVisibility 对齐)
*/
private requestRefreshPointLabelVisibility(
immediate = false,
debounceMs = 0
) {
if (this.labelVisibilityDebounceTimerId !== null) {
window.clearTimeout(this.labelVisibilityDebounceTimerId);
this.labelVisibilityDebounceTimerId = null;
}
if (immediate) {
if (this.collisionRefreshFrameId !== null) {
cancelAnimationFrame(this.collisionRefreshFrameId);
this.collisionRefreshFrameId = null;
}
this.refreshPointLabelVisibility();
return;
}
if (debounceMs > 0) {
this.labelVisibilityDebounceTimerId = window.setTimeout(() => {
this.labelVisibilityDebounceTimerId = null;
this.requestRefreshPointLabelVisibility();
}, debounceMs);
return;
}
if (this.collisionRefreshFrameId !== null) return;
this.collisionRefreshFrameId = window.requestAnimationFrame(() => {
this.collisionRefreshFrameId = null;
this.refreshPointLabelVisibility();
});
}
/**
* 主碰撞检测:密度门控 → 图标碰撞 → 标签碰撞
* 与 2D refreshPointLabelVisibility 核心算法完全对齐
*/
private refreshPointLabelVisibility(): void {
if (!this.viewer) return;
const currentZoom = this.getCurrentCesiumZoom();
if (currentZoom === undefined || currentZoom === null) return;
console.log(
`[Cesium Collision] zoom=${currentZoom?.toFixed(2)} layers=${
this.pointLayerRegistry.size
}`
);
// ===== 阶段0收集视口内所有基础可见实体 =====
type CollisionCandidate = {
entity: Cesium.Entity;
iconLeft: number;
iconRight: number;
iconTop: number;
iconBottom: number;
hasLabel: boolean;
labelLeft: number;
labelRight: number;
labelTop: number;
labelBottom: number;
engPriority: number;
nearbyPriority: number;
densityPriority: number;
pixelX: number;
pixelY: number;
id: string;
};
const candidates: CollisionCandidate[] = [];
const canvas = this.viewer.scene.canvas;
const viewPadding = 120; // 视口外的宽容范围
// 孤立点密度网格:用于 shouldBypassDensityGate
const densityPixelGrid = new Map<
string,
Array<{
entity: Cesium.Entity;
layerKey: string;
pixelX: number;
pixelY: number;
}>
>();
const dynamicScale = Math.max(
0.5,
Math.min(3.0, 0.7 + (currentZoom - 4.5) * 0.08)
);
const fontSize = Math.max(10, Math.min(24, 12 * dynamicScale));
// 第一遍:密度门控 + 构建候选
this.pointLayerRegistry.forEach((entities, layerKey) => {
entities.forEach(entity => {
const e = entity as any;
// 基础可见性检查layer/legend/region
if (
e._layerVisible === false ||
e._legendVisible === false ||
e._regionVisible === false
) {
e._iconCollisionVisible = false;
e._labelCollisionVisible = false;
this.applyEntityVisibility(entity);
return;
}
const position = entity.position?.getValue(
this.viewer!.clock.currentTime
);
if (!position) {
e._iconCollisionVisible = false;
e._labelCollisionVisible = false;
this.applyEntityVisibility(entity);
return;
}
const screenPos = Cesium.SceneTransforms.worldToWindowCoordinates(
this.viewer!.scene,
position
);
if (!screenPos) {
e._iconCollisionVisible = false;
e._labelCollisionVisible = false;
this.applyEntityVisibility(entity);
return;
}
const pixelX = screenPos.x;
const pixelY = screenPos.y;
// 视口裁剪:太远的实体不参与碰撞,但保留其当前碰撞状态(不重置)
if (
pixelX < -viewPadding ||
pixelY < -viewPadding ||
pixelX > canvas.clientWidth + viewPadding ||
pixelY > canvas.clientHeight + viewPadding
) {
return;
}
// 密度 gating与 2D getFeatureDensityMinZoom / shouldRenderFeatureByDensity 对齐)
const densityMinZoom = this.getEntityDensityMinZoom(entity);
const bypassDensity =
currentZoom < densityMinZoom &&
this.shouldBypassDensityGateForIsolatedEntity(
entity,
pixelX,
pixelY,
layerKey,
densityPixelGrid
);
const densityVisible = currentZoom >= densityMinZoom || bypassDensity;
if (!densityVisible) {
e._iconCollisionVisible = false;
e._labelCollisionVisible = false;
this.applyEntityVisibility(entity);
return;
}
// 构建图标碰撞盒(与 2D 一致Math.max(14, 24 * dynamicScale)
const iconCollisionSize = Math.max(14, 24 * dynamicScale);
const iconLeft = pixelX - iconCollisionSize / 2;
const iconRight = pixelX + iconCollisionSize / 2;
const iconTop = pixelY - iconCollisionSize / 2;
const iconBottom = pixelY + iconCollisionSize / 2;
// 构建标签碰撞盒
const labelText: string = e._labelText || '';
const hasLabel = !!labelText;
let labelLeft = 0;
let labelRight = 0;
let labelTop = 0;
let labelBottom = 0;
if (hasLabel) {
const lines = labelText.split('\n');
const maxLineLength = Math.max(
...lines.map((l: string) => l.length),
1
);
const labelLineCount = lines.length;
// 标签碰撞偏移与 2D getPointLabelCollisionOffsetY 对齐
const labelOffsetY =
labelLineCount > 1 ? -36 * dynamicScale : -28 * dynamicScale;
const estimatedWidth = maxLineLength * fontSize * 0.6 + 16;
const estimatedHeight = labelLineCount * (fontSize + 4) + 8;
const centerY = pixelY + labelOffsetY;
labelLeft = pixelX - estimatedWidth / 2;
labelRight = pixelX + estimatedWidth / 2;
labelTop = centerY - estimatedHeight / 2;
labelBottom = centerY + estimatedHeight / 2;
}
const entityId = String(entity.id || '');
const rawData: Record<string, any> = e._rawData || {};
candidates.push({
entity,
iconLeft,
iconRight,
iconTop,
iconBottom,
hasLabel,
labelLeft,
labelRight,
labelTop,
labelBottom,
engPriority: this.getEntityEngRenderPriority(entity),
nearbyPriority: Number(rawData._nearbyPriority || 9999),
densityPriority: this.getEntityDensityPriority(entity),
pixelX,
pixelY,
id: entityId
});
});
});
if (candidates.length === 0) return;
// ===== 阶段1排序eng优先级 > 近邻点优先级 > 密度优先级 > Y轴 > ID =====
candidates.sort((left, right) => {
if (left.engPriority !== right.engPriority) {
return right.engPriority - left.engPriority;
}
if (left.nearbyPriority !== right.nearbyPriority) {
return left.nearbyPriority - right.nearbyPriority;
}
if (left.densityPriority !== right.densityPriority) {
return left.densityPriority - right.densityPriority;
}
if (left.pixelY !== right.pixelY) {
return left.pixelY - right.pixelY;
}
return left.id.localeCompare(right.id);
});
// ===== 阶段2图标碰撞AABB + 空间网格加速) =====
const iconPlacedGrid = new Map<
string,
Array<{
left: number;
right: number;
top: number;
bottom: number;
}>
>();
candidates.forEach(candidate => {
const iconRect = {
left: candidate.iconLeft,
right: candidate.iconRight,
top: candidate.iconTop,
bottom: candidate.iconBottom
};
const hasCollision = this.findCollidingRectInGrid(
iconRect,
iconPlacedGrid,
this.COLLISION_GRID_CELL_SIZE,
placed => this.checkIconsAabbCollision(placed, iconRect)
);
const iconVisible = !hasCollision;
(candidate.entity as any)._iconCollisionVisible = iconVisible;
if (iconVisible) {
this.addRectToCollisionGrid(
iconRect,
iconPlacedGrid,
this.COLLISION_GRID_CELL_SIZE
);
}
});
// ===== 阶段3标签碰撞先避让图标再避让标签 =====
const labelCandidates = [...candidates].sort((left, right) => {
if (left.engPriority !== right.engPriority)
return right.engPriority - left.engPriority;
if (left.nearbyPriority !== right.nearbyPriority)
return left.nearbyPriority - right.nearbyPriority;
if (left.pixelY !== right.pixelY) return left.pixelY - right.pixelY;
if (left.pixelX !== right.pixelX) return left.pixelX - right.pixelX;
return left.id.localeCompare(right.id);
});
const labelPlacedGrid = new Map<
string,
Array<{
left: number;
right: number;
top: number;
bottom: number;
}>
>();
labelCandidates.forEach(candidate => {
const e = candidate.entity as any;
if (!candidate.hasLabel) {
e._labelCollisionVisible = false;
return;
}
// 3D 解耦:图标被碰撞隐藏不影响标签显示
// 标签仍会避让已放置的图标(见下方 iconPlacedGrid 检查)
const labelRect = {
left: candidate.labelLeft,
right: candidate.labelRight,
top: candidate.labelTop,
bottom: candidate.labelBottom
};
// 先避让已放置的图标缩小保护区4px允许边缘擦过
const hasIconCollision = this.findCollidingRectInGrid(
labelRect,
iconPlacedGrid,
this.COLLISION_GRID_CELL_SIZE,
iconRect => {
const inset = 4;
const narrowed = {
left: iconRect.left + inset,
right: iconRect.right - inset,
top: iconRect.top + inset,
bottom: iconRect.bottom - inset
};
return !(
narrowed.right <= labelRect.left ||
narrowed.left >= labelRect.right ||
narrowed.bottom <= labelRect.top ||
narrowed.top >= labelRect.bottom
);
}
);
if (hasIconCollision) {
e._labelCollisionVisible = false;
return;
}
// 再避让已放置的标签
const hasLabelCollision = this.findCollidingRectInGrid(
labelRect,
labelPlacedGrid,
this.COLLISION_GRID_CELL_SIZE,
placed => this.checkLabelAabbCollision(placed, labelRect)
);
const labelVisible = !hasLabelCollision;
e._labelCollisionVisible = labelVisible;
if (labelVisible) {
this.addRectToCollisionGrid(
labelRect,
labelPlacedGrid,
this.COLLISION_GRID_CELL_SIZE
);
}
});
// ===== 阶段4统一应用可见性变化 =====
let iconVisibleCount = 0;
let labelVisibleCount = 0;
candidates.forEach(candidate => {
const e = candidate.entity as any;
if (e._iconCollisionVisible) iconVisibleCount++;
if (e._labelCollisionVisible) labelVisibleCount++;
this.applyEntityVisibility(candidate.entity);
});
console.log(
`[Cesium Collision] 候选=${candidates.length} 图标可见=${iconVisibleCount} 标签可见=${labelVisibleCount}`
);
}
// ==================== 碰撞检测辅助:密度门控 ====================
/** 从实体原始数据中提取 density 字段(密度分档值,非物理距离) */
private getEntityDensityValue(entity: Cesium.Entity): number | null {
const rawData = (entity as any)._rawData;
if (!rawData) return null;
const rawDistance = rawData.distance;
if (
rawDistance === undefined ||
rawDistance === null ||
rawDistance === ''
) {
return null;
}
const densityValue = Number(rawDistance);
return Number.isFinite(densityValue) ? densityValue : null;
}
/** 密度优先级(档位越低越优先,与 2D getFeatureDensityPriority 对齐) */
private getEntityDensityPriority(entity: Cesium.Entity): number {
const densityValue = this.getEntityDensityValue(entity);
const densityDisplayRules = getNearbyPointDensityDisplayRules();
if (densityValue === null) {
return densityDisplayRules.length;
}
const matchedRuleIndex = densityDisplayRules.findIndex(rule => {
return densityValue >= rule.minDensityValue;
});
return matchedRuleIndex >= 0
? matchedRuleIndex
: densityDisplayRules.length;
}
/** 根据密度值获取最低显示缩放级别(与 2D getFeatureDensityMinZoom 对齐) */
private getEntityDensityMinZoom(entity: Cesium.Entity): number {
const densityValue = this.getEntityDensityValue(entity);
if (densityValue === null) return 0;
const densityDisplayRules = getNearbyPointDensityDisplayRules();
const matchedRule = densityDisplayRules.find(rule => {
return densityValue >= rule.minDensityValue;
});
if (matchedRule) return matchedRule.minZoom;
return densityDisplayRules.at(-1)?.minZoom ?? 0;
}
/**
* 孤立点密度豁免:如果当前视口内 56px 范围内没有其他同图层可见点,
* 则跳过密度门控直接显示(与 2D shouldBypassDensityGateForIsolatedFeature 对齐)
*/
private shouldBypassDensityGateForIsolatedEntity(
entity: Cesium.Entity,
pixelX: number,
pixelY: number,
layerKey: string,
densityPixelGrid: Map<
string,
Array<{
entity: Cesium.Entity;
layerKey: string;
pixelX: number;
pixelY: number;
}>
>
): boolean {
// 先登记当前实体到密度网格
const gridKey = `${layerKey}:${Math.floor(
pixelX / this.DENSITY_ISOLATION_THRESHOLD
)}:${Math.floor(pixelY / this.DENSITY_ISOLATION_THRESHOLD)}`;
const bucket = densityPixelGrid.get(gridKey) || [];
bucket.push({ entity, layerKey, pixelX, pixelY });
densityPixelGrid.set(gridKey, bucket);
// 检查周围 3x3 网格单元
const checkedKeys = new Set<string>();
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const neighborKey = `${layerKey}:${
Math.floor(pixelX / this.DENSITY_ISOLATION_THRESHOLD) + dx
}:${Math.floor(pixelY / this.DENSITY_ISOLATION_THRESHOLD) + dy}`;
if (checkedKeys.has(neighborKey)) continue;
checkedKeys.add(neighborKey);
const neighborBucket = densityPixelGrid.get(neighborKey);
if (!neighborBucket?.length) continue;
for (const neighbor of neighborBucket) {
if (neighbor.entity === entity) continue;
const dist = Math.hypot(
neighbor.pixelX - pixelX,
neighbor.pixelY - pixelY
);
if (dist <= this.DENSITY_ISOLATION_THRESHOLD) {
return false; // 有近邻点,不豁免
}
}
}
}
return true; // 孤立点,豁免密度门控
}
// ==================== 碰撞检测辅助eng 优先级 ====================
/** 判断是否为 eng 点(与 2D isFeatureEng 对齐) */
private isEntityEng(entity: Cesium.Entity): boolean {
const e = entity as any;
const rawData: Record<string, any> = e._rawData || {};
const layerKey = String(e._layerKey || '').toLowerCase();
const sttpMap = String(
rawData.sttpMap || rawData._sttpMap || ''
).toUpperCase();
const sttpCode = String(
rawData.sttpCode || rawData.sttp || ''
).toUpperCase();
return (
layerKey.includes('eng_point') ||
sttpMap === 'ENG' ||
sttpMap === 'ENG2' ||
sttpCode === 'ENG'
);
}
/** 判断是否为 eng 告警点(与 2D isFeatureEngAlarm 对齐) */
private isEntityEngAlarm(entity: Cesium.Entity): boolean {
const e = entity as any;
const rawData: Record<string, any> = e._rawData || {};
const layerKey = String(e._layerKey || '').toLowerCase();
const legendState = String(rawData.anchoPointState || '')
.trim()
.toLowerCase();
return (
layerKey.includes('eng_alarm_point') ||
layerKey.includes('alarm_range') ||
legendState.startsWith('alarm_range_') ||
legendState.startsWith('large_eng_built_alarm_range_') ||
legendState.startsWith('mid_eng_built_alarm_range_')
);
}
/** 获取 eng 渲染优先级:告警=300, eng=200, 普通=100与 2D 完全一致) */
private getEntityEngRenderPriority(entity: Cesium.Entity): number {
if (this.isEntityEngAlarm(entity)) return 300;
return this.isEntityEng(entity) ? 200 : 100;
}
// ==================== 碰撞检测辅助:空间网格与 AABB ====================
/** 计算矩形覆盖的碰撞网格 cell keys */
private getCollisionGridKeys(
rect: {
left: number;
right: number;
top: number;
bottom: number;
},
cellSize: number
): string[] {
const startX = Math.floor(rect.left / cellSize);
const endX = Math.floor(rect.right / cellSize);
const startY = Math.floor(rect.top / cellSize);
const endY = Math.floor(rect.bottom / cellSize);
const keys: string[] = [];
for (let x = startX; x <= endX; x += 1) {
for (let y = startY; y <= endY; y += 1) {
keys.push(`${x}:${y}`);
}
}
return keys;
}
/** 将矩形注册到碰撞网格 */
private addRectToCollisionGrid<
T extends {
left: number;
right: number;
top: number;
bottom: number;
}
>(rect: T, grid: Map<string, T[]>, cellSize: number) {
this.getCollisionGridKeys(rect, cellSize).forEach(key => {
const bucket = grid.get(key) || [];
bucket.push(rect);
grid.set(key, bucket);
});
}
/** 在碰撞网格中查找与给定矩形碰撞的矩形 */
private findCollidingRectInGrid<
T extends {
left: number;
right: number;
top: number;
bottom: number;
}
>(
rect: {
left: number;
right: number;
top: number;
bottom: number;
},
grid: Map<string, T[]>,
cellSize: number,
isCollision: (candidate: T) => boolean
): T | undefined {
for (const key of this.getCollisionGridKeys(rect, cellSize)) {
const bucket = grid.get(key);
if (!bucket?.length) continue;
for (const candidate of bucket) {
if (isCollision(candidate)) {
return candidate;
}
}
}
return undefined;
}
/** 图标碰撞检测AABB 重叠判定padding=0与 2D 图标碰撞一致) */
private checkIconsAabbCollision(
left: { left: number; right: number; top: number; bottom: number },
right: { left: number; right: number; top: number; bottom: number }
): boolean {
return !(
left.right < right.left ||
left.left > right.right ||
left.bottom < right.top ||
left.top > right.bottom
);
}
/** 标签碰撞检测AABB 重叠 + padding=4与 2D checkLabelCollision 对齐) */
private checkLabelAabbCollision(
left: { left: number; right: number; top: number; bottom: number },
right: { left: number; right: number; top: number; bottom: number }
): boolean {
const padding = this.COLLISION_LABEL_PADDING;
return !(
left.right + padding < right.left ||
left.left - padding > right.right ||
left.bottom + padding < right.top ||
left.top - padding > right.bottom
);
}
// ==================== 倾斜摄影(OSGB) ====================
/**
* 加载倾斜摄影 3D Tileset 模型
* 如果 viewer 尚未就绪,暂存到队列中,等 5 秒后批量加载
*/
addQxsyLayer(item: OSGBItem): void {
if (!this.viewer || !this._ready) {
// 未就绪:放入等待队列
if (!this._pendingOSGBItems.includes(item)) {
this._pendingOSGBItems.push(item);
}
return;
}
LoadOSGB(this.viewer, item);
}
/**
* 批量加载所有等待中的倾斜摄影
*/
private flushPendingOSGB(): void {
if (!this.viewer || this.viewer.isDestroyed()) return;
const items = this._pendingOSGBItems.splice(0);
console.log(`[Cesium] 开始加载 ${items.length} 个待加载的倾斜摄影模型`);
items.forEach(item => LoadOSGB(this.viewer!, item));
}
/**
* 移除倾斜摄影模型
*/
removeQxsyLayer(item: OSGBItem): void {
if (!this.viewer) return;
// 从待加载队列中移除,防止已被移除的模型在 flushPendingOSGB 时又被加载
this._pendingOSGBItems = this._pendingOSGBItems.filter(i => i !== item);
removeQxsy(this.viewer, item);
}
/**
* 倾斜摄影显隐切换
*/
qxsyChangeClick(item: OSGBItem, checked: boolean): void {
if (!this.viewer) return;
osgbChangeClick(this.viewer, item, checked);
}
/**
* 飞行定位到倾斜摄影模型
*/
qxsyToPosition(item: OSGBItem): void {
if (!this.viewer) return;
osgbLocation(this.viewer, item);
}
}