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 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|