2810 lines
90 KiB
TypeScript
2810 lines
90 KiB
TypeScript
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 popup(rAF 节流,批量模式下只改光标)
|
||
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);
|
||
}
|
||
}
|