WholeProcessPlatform/frontend-sjgl/src/components/gis/map.cesium.ts

2810 lines
90 KiB
TypeScript
Raw Normal View History

2026-07-20 16:02:51 +08:00
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);
2026-07-31 11:13:49 +08:00
const token = 'bearer b734a443-2c8f-4f4a-8698-44828cc5f709';
2026-07-20 16:02:51 +08:00
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 clearrebuild */
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);
}
}