import { ENTRIES } from '../data/entries.js'; // Cesium 懒加载:脚本首次进入“地理”视图时才加载;用 Proxy 惰性访问 window.Cesium, // 使下方 50 余处 Cesium.xxx 引用无需逐个改动。 const Cesium = new Proxy({}, { get(_t, prop) { return window.Cesium[prop]; }, }); const CESIUM_BASE = '/cesium'; function loadCesium() { return new Promise((resolve, reject) => { if (window.Cesium) return resolve(); window.CESIUM_BASE_URL = CESIUM_BASE; const s = document.createElement('script'); s.src = `${CESIUM_BASE}/Cesium.js`; s.onload = () => resolve(); s.onerror = () => reject(new Error('Cesium 脚本加载失败')); document.head.appendChild(s); }); } // 免费、无需 Ion 令牌的影像与地形源 const IMAGERY_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; const TERRAIN_URL = 'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer'; // 右键环视角度限制与灵敏度 const MAX_LOOK_ROT = (40 * Math.PI) / 180; // 左右旋转 ±40° const MAX_LOOK_TILT = (20 * Math.PI) / 180; // 上下倾角 ±20° const LOOK_RATE = Math.PI / 720; // 每像素约 0.25° // 计算多边形集合的球面面积(km²),用于按国土面积分级国家名字号 function sphericalAreaKm2(polys) { let area = 0; for (let p = 0; p < polys.length; p++) { const rings = polys[p]; for (let r = 0; r < rings.length; r++) { const ring = rings[r]; let s = 0; for (let i = 0; i < ring.length; i++) { const [lng1, lat1] = ring[i]; const [lng2, lat2] = ring[(i + 1) % ring.length]; let dlng = ((lng2 - lng1) * Math.PI) / 180; while (dlng > Math.PI) dlng -= 2 * Math.PI; while (dlng < -Math.PI) dlng += 2 * Math.PI; const f1 = (lat1 * Math.PI) / 180; const f2 = (lat2 * Math.PI) / 180; s += dlng * (2 + Math.sin(f1) + Math.sin(f2)); } const ringArea = Math.abs(s) / 2; area += (r === 0 ? 1 : -1) * ringArea; } } const R = 6371; // km return area * R * R; } // 生成便签 canvas:黄底圆角 + 左侧箭头 + 黑色加粗文字(整张图,箭头尖端指向坐标) // 选中态:先描粗边再填充,得到一条干净的 2px 外描边(四周预留 B 的 padding 防止裁切) function makeStickyNote(text, fontSize, selected = false) { const padX = 8, padY = 5, radius = 7, arrowW = 10, arrowH = 14, S = 2, B = 2; // S: 2x 超采样,B: 边框预留 const font = `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`; const m = document.createElement('canvas'); const mctx = m.getContext('2d'); mctx.font = font; const textW = mctx.measureText(text).width; const bodyW = Math.max(1, Math.ceil(textW + padX * 2)); const w = arrowW + bodyW; const h = Math.ceil(fontSize + padY * 2); const cw = w + B * 2; const ch = h + B * 2; const c = document.createElement('canvas'); c.width = cw * S; c.height = ch * S; const ctx = c.getContext('2d'); ctx.scale(S, S); const ox = B, oy = B; // 形状整体内缩 B,给黑边留空间 // 箭头 + 圆角矩形一体化路径(箭头尖端在 (ox, oy + h/2),指向坐标) ctx.beginPath(); ctx.moveTo(ox, oy + h / 2); ctx.lineTo(ox + arrowW, oy + h / 2 - arrowH / 2); ctx.lineTo(ox + arrowW, oy + radius); ctx.arcTo(ox + arrowW, oy, ox + arrowW + radius, oy, radius); ctx.lineTo(ox + w - radius, oy); ctx.arcTo(ox + w, oy, ox + w, oy + radius, radius); ctx.lineTo(ox + w, oy + h - radius); ctx.arcTo(ox + w, oy + h, ox + w - radius, oy + h, radius); ctx.lineTo(ox + arrowW + radius, oy + h); ctx.arcTo(ox + arrowW, oy + h, ox + arrowW, oy + h - radius, radius); ctx.lineTo(ox + arrowW, oy + h / 2 + arrowH / 2); ctx.closePath(); if (selected) { // 先描粗边,再填充盖住内侧一半 → 外露 2px 干净黑边 ctx.strokeStyle = '#14100d'; ctx.lineWidth = B * 2; // 4px,外露 2px ctx.lineJoin = 'round'; ctx.stroke(); } else { // 非选中:柔和的投影 ctx.shadowColor = 'rgba(0, 0, 0, 0.20)'; ctx.shadowBlur = 5; ctx.shadowOffsetY = 2; } ctx.fillStyle = '#e0c46a'; ctx.fill(); // 黑色加粗文字 ctx.fillStyle = '#14100d'; ctx.font = font; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText(text, ox + arrowW + bodyW / 2, oy + h / 2 + 1); return { canvas: c, scale: 1 / S, width: cw, height: ch, tipX: B, tipY: B + h / 2 }; } export class CesiumGlobe { constructor({ onPick } = {}) { this.onPick = onPick; this.viewer = null; this.markers = []; this.selected = -1; this._created = false; this._visible = false; this._pendingData = 2; // 国家边界 + 城市数据 this.ready = new Promise((resolve) => { this._resolveReady = resolve; }); this.viewerReady = new Promise((resolve) => { this._resolveViewerReady = resolve; }); } async ensureCreated() { if (this._created) return; this._created = true; try { await loadCesium(); } catch (err) { this._created = false; // 失败可重试 console.warn('[CesiumGlobe] Cesium 脚本加载失败:', err); return; } // 调整实体渲染顺序:先渲染标签(城市/国家名),再渲染 Billboard(标记便签), // 让词条标记始终显示在城市/国家名的上层。 Cesium.DataSourceDisplay.defaultVisualizersCallback = function ( scene, entityCluster, dataSource, ) { const entities = dataSource.entities; return [ new Cesium.LabelVisualizer(entityCluster, entities), new Cesium.BillboardVisualizer(entityCluster, entities), new Cesium.GeometryVisualizer( scene, entities, dataSource._primitives, dataSource._groundPrimitives, ), new Cesium.ModelVisualizer(scene, entities), new Cesium.Cesium3DTilesetVisualizer(scene, entities), new Cesium.PointVisualizer(entityCluster, entities), new Cesium.PathVisualizer(scene, entities), new Cesium.PolylineVisualizer( scene, entities, dataSource._primitives, dataSource._groundPrimitives, ), ]; }; const container = document.getElementById('cesium-container'); this.viewer = new Cesium.Viewer(container, { animation: false, timeline: false, geocoder: false, homeButton: false, sceneModePicker: false, baseLayerPicker: false, navigationHelpButton: false, fullscreenButton: false, infoBox: false, selectionIndicator: false, baseLayer: false, requestRenderMode: true, }); const viewer = this.viewer; const scene = viewer.scene; // 贴合暗色主题 scene.backgroundColor = Cesium.Color.fromCssColorString('#14100d'); scene.skyAtmosphere.show = false; scene.skyBox.show = false; scene.sun.show = false; scene.moon.show = false; scene.globe.enableLighting = false; scene.globe.showGroundAtmosphere = false; scene.globe.baseColor = Cesium.Color.fromCssColorString('#d9e6f0'); // 冰盖浅色:影像/地形未覆盖的南北极区域显示为冰盖,而非黑洞 // 相机交互:限制滚轮缩放范围,避免过近/过远 const controller = scene.screenSpaceCameraController; controller.enableCollisionDetection = true; // 必须开启:缩放距离限制在关闭时会被忽略 controller.minimumZoomDistance = 10000; // 最近约 10 km(区域尺度) controller.maximumZoomDistance = 8e6; // 最远约 8000 km // 限制鼠标按钮:只保留左键旋转 + 滚轮缩放;禁用中键倾斜、右键缩放 controller.rotateEventTypes = Cesium.CameraEventType.LEFT_DRAG; controller.zoomEventTypes = [ Cesium.CameraEventType.WHEEL, Cesium.CameraEventType.PINCH, ]; controller.tiltEventTypes = [ Cesium.CameraEventType.PINCH, { eventType: Cesium.CameraEventType.LEFT_DRAG, modifier: Cesium.KeyboardEventModifier.CTRL, }, ]; // 阻止右键弹出浏览器菜单 scene.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // 精细化渲染:质量与性能的平衡 scene.globe.maximumScreenSpaceError = 2.0; // 默认细分,最省性能 scene.globe.tileCacheSize = 600; // 更大的瓦片缓存,减少细节刷新 scene.fxaa = true; // 开启抗锯齿 viewer.resolutionScale = 1.0; // 关闭超采样(DPR 已提供足够分辨率),省填充率 scene.maximumRenderTimeChange = Infinity; // 配合 requestRenderMode:静止时停止重绘,大幅省电省帧 // 卫星影像 const imagery = new Cesium.UrlTemplateImageryProvider({ url: IMAGERY_URL, credit: 'Imagery © Esri, Maxar, Earthstar Geographics', maximumLevel: 19, }); const imageryLayer = new Cesium.ImageryLayer(imagery, { // 卫星影像不覆盖极区(约 ±85° 外返回黑块),限制范围让极区用冰盖底色 rectangle: Cesium.Rectangle.fromDegrees(-180, -85, 180, 85), }); viewer.imageryLayers.add(imageryLayer); // 真实地形(Esri World Elevation 3D,免费无 token;失败则保持默认椭球) const terrain = new Cesium.Terrain( Cesium.ArcGISTiledElevationTerrainProvider.fromUrl(TERRAIN_URL), ); terrain.errorEvent.addEventListener((err) => { console.warn('[CesiumGlobe] 地形加载失败,使用椭球:', err); }); scene.setTerrain(terrain); viewer.clock.shouldAnimate = false; this._buildMarkers(); this._setupPick(); this._setupLookAround(); this._loadBoundaries(); this._buildCityLabels(); viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(35, 12, 7.2e6), }); // 创建完成:同步当前选中/可见/相机状态(首次进入时这些调用可能因 viewer 未建而跳过) this.setSelected(this.selected); this.setVisible(this._visible); this.resize(); this.flyToEntry(this.selected); // 地球已就绪(影像/地形/城市数据继续后台加载),通知 App 隐藏加载遮罩 if (this._resolveViewerReady) { this._resolveViewerReady(); this._resolveViewerReady = null; } } _buildMarkers() { ENTRIES.forEach((entry, i) => { const position = Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat); const noteNormal = makeStickyNote(entry.name, 16, false); const noteSelected = makeStickyNote(entry.name, 16, true); this.markers.push({ position, r2: Cesium.Cartesian3.magnitudeSquared(position), noteNormal, noteSelected, index: i, }); }); this._initMarkerLayer(); } // 便签用 DOM 覆盖层渲染:始终在城市/国家名上层,且缩放到最近也不会漂移 _initMarkerLayer() { const container = document.getElementById('cesium-container'); this._markerLayer = document.createElement('div'); this._markerLayer.id = 'marker-layer'; container.appendChild(this._markerLayer); for (const m of this.markers) { const el = document.createElement('div'); el.className = 'marker-note'; el.style.width = `${m.noteNormal.width}px`; el.style.height = `${m.noteNormal.height}px`; el.dataset.index = m.index; m.el = el; m.tipX = m.noteNormal.tipX; // 箭头尖相对元素左上角的偏移 m.tipY = m.noteNormal.tipY; m.normalUrl = m.noteNormal.canvas.toDataURL(); m.selectedUrl = m.noteSelected.canvas.toDataURL(); el.style.backgroundImage = `url("${m.normalUrl}")`; el.addEventListener('click', (e) => { e.stopPropagation(); this.onPick?.(m.index); }); this._markerLayer.appendChild(el); } // 用 postUpdate(每帧都触发,即使 requestRenderMode 下不真正渲染)更新便签位置, // 避免拖拽时 postRender 不触发导致的闪烁/消失。 this.viewer.scene.postUpdate.addEventListener(() => this._updateMarkerLayer()); } // 每帧更新便签屏幕位置(相机移动/缩放/飞行时跟随) _updateMarkerLayer() { const scene = this.viewer.scene; const camera = scene.camera; const camPos = camera.positionWC; // 每帧只取一次相机位置 for (const m of this.markers) { const el = m.el; if (!el) continue; const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, m.position); if (!win) { el.style.display = 'none'; continue; } // 前/后半球:dot(P, C) > |P|²;地平线附近用 frontness 渐变淡出(避免 acos) const frontness = Cesium.Cartesian3.dot(m.position, camPos) - m.r2; if (frontness <= 0) { el.style.display = 'none'; continue; } const opacity = Math.min(1, frontness / (m.r2 * 0.02)); el.style.display = 'block'; el.style.opacity = String(opacity); el.style.left = `${win.x - m.tipX}px`; el.style.top = `${win.y - m.tipY}px`; } } _setupPick() { const viewer = this.viewer; // 移除 Viewer 默认的单击选中与双击“追踪实体”:否则双击后会锁定相机(定住) // 标记便签已改为 DOM 覆盖层,点击由 DOM 元素自行处理 viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK); viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); } // 右键环视:以点击处为球心,左右旋转 ±40°、上下倾角 ±20°,松开自动归位。 // 不用原生 tilt(RIGHT_DRAG):原生 tilt 会绕点击点做轨道移动,heading/pitch // 是相对“不断移动的相机位置”计算的,无法干净地限制成 40°/20°。 _setupLookAround() { const scene = this.viewer.scene; const camera = this.viewer.camera; const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas); this._lookState = null; this._orbitScratch = { offset: new Cesium.Cartesian3(), upAxis: new Cesium.Cartesian3(), eastAxis: new Cesium.Cartesian3(), northAxis: new Cesium.Cartesian3(), qAz: new Cesium.Quaternion(), qEl: new Cesium.Quaternion(), q: new Cesium.Quaternion(), rotMat: new Cesium.Matrix3(), newOffset: new Cesium.Cartesian3(), newPos: new Cesium.Cartesian3(), dir: new Cesium.Cartesian3(), upProj: new Cesium.Cartesian3(), }; handler.setInputAction((movement) => { // 旋转中心 = 相机当前注视的屏幕中心点(与原生 tilt 一致), // 而不是点击点:用点击点会导致一拖动就“瞬移”到点击处。 const canvas = scene.canvas; const center = Cesium.Cartesian2.fromElements( canvas.clientWidth / 2, canvas.clientHeight / 2, ); const pivot = camera.pickEllipsoid(center, scene.globe.ellipsoid); if (!Cesium.defined(pivot)) return; const sc = this._orbitScratch; const offset = Cesium.Cartesian3.subtract(camera.position, pivot, sc.offset); const upAxis = Cesium.Cartesian3.normalize(pivot, sc.upAxis); const eastAxis = Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, pivot, sc.eastAxis); // 绕它转 = 左右拖动(纬度方向倾倒) if (Cesium.Cartesian3.magnitudeSquared(eastAxis) < 1e-12) { eastAxis.x = 1; eastAxis.y = 0; eastAxis.z = 0; // 极点退化兜底 } else { Cesium.Cartesian3.normalize(eastAxis, eastAxis); } const northAxis = Cesium.Cartesian3.normalize( Cesium.Cartesian3.cross(upAxis, eastAxis, sc.northAxis), // 绕它转 = 上下拖动(经度方向倾倒) sc.northAxis, ); this._lookState = { pivot: Cesium.Cartesian3.clone(pivot), startOffset: Cesium.Cartesian3.clone(offset), eastAxis: Cesium.Cartesian3.clone(eastAxis), northAxis: Cesium.Cartesian3.clone(northAxis), position: Cesium.Cartesian3.clone(camera.position), direction: Cesium.Cartesian3.clone(camera.direction), up: Cesium.Cartesian3.clone(camera.up), azimuth: 0, elevation: 0, lastX: movement.position.x, lastY: movement.position.y, }; }, Cesium.ScreenSpaceEventType.RIGHT_DOWN); handler.setInputAction((movement) => { if (!this._lookState) return; const s = this._lookState; const dx = movement.endPosition.x - s.lastX; const dy = movement.endPosition.y - s.lastY; s.lastX = movement.endPosition.x; s.lastY = movement.endPosition.y; s.azimuth = Cesium.Math.clamp(s.azimuth + dx * LOOK_RATE, -MAX_LOOK_ROT, MAX_LOOK_ROT); s.elevation = Cesium.Math.clamp(s.elevation + dy * LOOK_RATE, -MAX_LOOK_TILT, MAX_LOOK_TILT); this._orbitCamera(s); }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); handler.setInputAction(() => { if (!this._lookState) return; const s = this._lookState; this._lookState = null; camera.setView({ destination: s.position, orientation: { direction: s.direction, up: s.up }, }); }, Cesium.ScreenSpaceEventType.RIGHT_UP); this._lookHandler = handler; } // 按累计角度(已限制)把相机绕点击点倾倒:左右 = 纬度方向(绕东西轴), // 上下 = 经度方向(绕南北轴);up 始终强制北朝上。 _orbitCamera(s) { const camera = this.viewer.camera; const sc = this._orbitScratch; Cesium.Quaternion.fromAxisAngle(s.eastAxis, s.azimuth, sc.qAz); Cesium.Quaternion.fromAxisAngle(s.northAxis, s.elevation, sc.qEl); Cesium.Quaternion.multiply(sc.qEl, sc.qAz, sc.q); Cesium.Matrix3.fromQuaternion(sc.q, sc.rotMat); Cesium.Matrix3.multiplyByVector(sc.rotMat, s.startOffset, sc.newOffset); Cesium.Cartesian3.add(s.pivot, sc.newOffset, sc.newPos); const dir = Cesium.Cartesian3.normalize( Cesium.Cartesian3.subtract(s.pivot, sc.newPos, sc.dir), sc.dir, ); // 强制北朝上:up = 世界 Z 投影到垂直于视线的方向 const z = dir.z; sc.upProj.x = -dir.x * z; sc.upProj.y = -dir.y * z; sc.upProj.z = 1 - z * z; if (Cesium.Cartesian3.magnitudeSquared(sc.upProj) < 1e-12) { Cesium.Cartesian3.clone(s.up, sc.upProj); // 垂直俯视时北朝上无定义,回退起始 up } else { Cesium.Cartesian3.normalize(sc.upProj, sc.upProj); } camera.setView({ destination: Cesium.Cartesian3.clone(sc.newPos), orientation: { direction: Cesium.Cartesian3.clone(dir), up: Cesium.Cartesian3.clone(sc.upProj), }, }); } // 相机平滑飞至某个标记上空并居中:回到与初始一致的高度与北朝上方向,左键拖拽手感不变 flyToEntry(index) { const entry = ENTRIES[index]; if (!entry || !this.viewer) return; const now = Date.now(); if (this._lastFly && now - this._lastFly < 400) return; // 双击/连点去抖,避免重复飞 this._lastFly = now; const camera = this.viewer.camera; const height = camera.positionCartographic.height; // 保持当前缩放,平移过去 camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat, height), orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0, }, duration: 1.2, }); } // 加载行政区划(国家边界,自然地球 110m):把多边形轮廓转成贴合地形的金色细线 + 国家名标签 async _loadBoundaries() { try { const res = await fetch('/world-countries.geojson'); const geojson = await res.json(); const textGold = Cesium.Color.fromCssColorString('#e0c46a'); const black = Cesium.Color.fromCssColorString('#14100d'); const lines = []; for (const feature of geojson.features || []) { const props = feature.properties || {}; const geom = feature.geometry; const isPoly = geom && (geom.type === 'Polygon' || geom.type === 'MultiPolygon'); const polys = isPoly ? (geom.type === 'MultiPolygon' ? geom.coordinates : [geom.coordinates]) : []; const areaKm2 = isPoly ? sphericalAreaKm2(polys) : 0; // 国家名(优先中文),落在 NATURAL EARTH 提供的标注点上,字号按国土面积分级 let cname = props.NAME_ZH || props.NAME; if (cname === '中华民国') cname = '中国台湾'; if (cname && props.LABEL_X != null && props.LABEL_Y != null) { const fontSize = areaKm2 > 5e6 ? 30 : areaKm2 > 1.5e6 ? 19 : areaKm2 > 3e5 ? 15 : 12; this.viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(props.LABEL_X, props.LABEL_Y), label: { text: cname, font: `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`, fillColor: black, style: Cesium.LabelStyle.FILL, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, distanceDisplayCondition: new Cesium.DistanceDisplayCondition(1e6, 1e7), scaleByDistance: new Cesium.NearFarScalar(1e4, 20 / 15, 6e6, 1.0), translucencyByDistance: new Cesium.NearFarScalar(9e6, 1.0, 1e7, 0.0), }, }); } if (!isPoly) continue; for (const poly of polys) { for (const ring of poly) { if (ring.length < 2) continue; const flat = []; for (const [lng, lat] of ring) flat.push(lng, lat); const positions = Cesium.Cartesian3.fromDegreesArray(flat); // 行政区划:一根黑线 lines.push(new Cesium.GeometryInstance({ geometry: new Cesium.GroundPolylineGeometry({ positions, width: 2.6 }) })); } } } if (lines.length) { this.viewer.scene.groundPrimitives.add( new Cesium.GroundPolylinePrimitive({ geometryInstances: lines, appearance: new Cesium.PolylineMaterialAppearance({ material: Cesium.Material.fromType('Color', { color: black }), }), }), ); } } catch (err) { console.warn('[CesiumGlobe] 行政区划加载失败:', err); } finally { this._dataReady(); } } // 全部城市:Natural Earth 10m 人口稠密点(7342 个),中文名直接用数据自带的 name_zh async _buildCityLabels() { const scene = this.viewer.scene; const textGold = Cesium.Color.fromCssColorString('#e0c46a'); const black = Cesium.Color.fromCssColorString('#14100d'); const ddc = new Cesium.DistanceDisplayCondition(0, 4e6); const tdc = new Cesium.NearFarScalar(2e6, 1.0, 4e6, 0.0); let cities = []; try { const res = await fetch('/world-cities-zh.json'); cities = await res.json(); } catch (err) { console.warn('[CesiumGlobe] 城市数据加载失败:', err); cities = []; } // 用 primitive 集合替代 7342 个 entity,大幅降低 Cesium 实体系统开销 const points = new Cesium.PointPrimitiveCollection(); const labels = new Cesium.LabelCollection({ scene }); scene.primitives.add(points); scene.primitives.add(labels); for (const c of cities) { const position = Cesium.Cartesian3.fromDegrees(c.lng, c.lat); points.add({ position, pixelSize: 3, color: textGold, outlineColor: black, outlineWidth: 1, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, distanceDisplayCondition: ddc, translucencyByDistance: tdc, }); labels.add({ position, text: c.name, font: 'bold 14px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif', fillColor: black, style: Cesium.LabelStyle.FILL, horizontalOrigin: Cesium.HorizontalOrigin.CENTER, verticalOrigin: Cesium.VerticalOrigin.BOTTOM, pixelOffset: new Cesium.Cartesian2(0, -6), heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, distanceDisplayCondition: ddc, translucencyByDistance: tdc, }); } this._dataReady(); } _dataReady() { this._pendingData--; if (this._pendingData <= 0 && this._resolveReady) { this._resolveReady(); this._resolveReady = null; } } setSelected(index) { this.selected = index; if (!this.viewer) return; this.markers.forEach((m) => { const on = m.index === index; if (m.el) m.el.style.backgroundImage = `url("${on ? m.selectedUrl : m.normalUrl}")`; }); } setVisible(visible) { this._visible = visible; if (visible && this.viewer) this.viewer.scene.requestRender(); } // 等待当前视图的影像/地形瓦片加载完成(超时兜底),用于切换时“一起出现” waitUntilViewReady(timeout = 4000) { return new Promise((resolve) => { const globe = this.viewer.scene.globe; let done = false; const finish = () => { if (done) return; done = true; globe.tileLoadProgressEvent.removeEventListener(onProgress); resolve(); }; const onProgress = (remaining) => { if (remaining <= 0) finish(); }; globe.tileLoadProgressEvent.addEventListener(onProgress); if (globe.tilesLoaded) finish(); setTimeout(finish, timeout); }); } resize() { if (this.viewer) this.viewer.resize(); } }