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'; // 计算多边形集合的球面面积(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 = 4, arrowW = 13, arrowH = 16, 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.quadraticCurveTo( ox + arrowW * 0.7, oy + h / 2 - arrowH / 2, 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.quadraticCurveTo( ox + arrowW * 0.7, oy + h / 2 + arrowH / 2, ox, oy + h / 2, ); ctx.closePath(); if (selected) { // 选中:黑底金字,外圈 2px 金边(先描边再填充,外露一半) ctx.strokeStyle = '#e0c46a'; ctx.lineWidth = B * 2; ctx.lineJoin = 'round'; ctx.stroke(); ctx.fillStyle = '#14100d'; ctx.fill(); ctx.fillStyle = '#e0c46a'; } else { // 非选中:金底黑字 + 1px 黑边(描 2px,填充盖掉内侧 1px)+ 柔和投影 ctx.strokeStyle = '#14100d'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke(); ctx.shadowColor = 'rgba(0, 0, 0, 0.20)'; ctx.shadowBlur = 5; ctx.shadowOffsetY = 2; ctx.fillStyle = '#e0c46a'; ctx.fill(); ctx.shadowColor = 'transparent'; 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 = 500; // 最近约 500 m(街区尺度) controller.maximumZoomDistance = 8e6; // 最远约 8000 km // 限制鼠标按钮:只保留左键旋转 + 滚轮缩放;禁用中键倾斜、右键缩放 controller.rotateEventTypes = Cesium.CameraEventType.LEFT_DRAG; controller.zoomEventTypes = [ Cesium.CameraEventType.WHEEL, Cesium.CameraEventType.PINCH, ]; controller.tiltEventTypes = [ Cesium.CameraEventType.RIGHT_DRAG, // 右键 = 原生 tilt(传统环视) Cesium.CameraEventType.PINCH, { eventType: Cesium.CameraEventType.LEFT_DRAG, modifier: Cesium.KeyboardEventModifier.CTRL, }, ]; // 阻止右键弹出浏览器菜单;阻止中键触发浏览器自动滚动 scene.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); scene.canvas.addEventListener('mousedown', (e) => { if (e.button === 1) e.preventDefault(); }); // 精细化渲染:高清 + 精细地形 scene.globe.maximumScreenSpaceError = 2.0; // 地形细分 scene.globe.tileCacheSize = 600; // 更大的瓦片缓存,减少细节刷新 scene.fxaa = true; // 后处理抗锯齿(MSAA 默认已 4x) viewer.useBrowserRecommendedResolution = false; // 按设备像素比渲染,高分屏清晰 viewer.resolutionScale = 1.0; scene.maximumRenderTimeChange = Infinity; // 配合 requestRenderMode:静止时停止重绘,大幅省电省帧 // 卫星影像 // 检测 ESRI "Map data not yet available" 灰色错误瓦片并丢弃,自动回退父级放大 const discardNotAvailableTile = { _canvas: document.createElement('canvas'), _ctx: null, isReady() { return true; }, shouldDiscardImage(image) { try { if (!this._ctx) { this._ctx = this._canvas.getContext('2d', { willReadFrequently: true }); } this._canvas.width = 8; this._canvas.height = 8; this._ctx.drawImage(image, 0, 0, 8, 8); const d = this._ctx.getImageData(0, 0, 8, 8).data; let gray = 0; let total = 0; for (let i = 0; i < d.length; i += 4) { const r = d[i]; const g = d[i + 1]; const b = d[i + 2]; const mx = Math.max(r, g, b); const mn = Math.min(r, g, b); if (mx - mn < 15 && r > 110 && r < 240) gray++; total++; } return total > 0 && gray / total > 0.92; } catch (e) { return false; } }, }; const imagery = new Cesium.UrlTemplateImageryProvider({ url: IMAGERY_URL, credit: 'Imagery © Esri, Maxar, Earthstar Geographics', maximumLevel: 19, tileDiscardPolicy: discardNotAvailableTile, }); 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._clampMarkersToTerrain(); this._setupPick(); this._setupRightRestore(); 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({ lng: entry.lng, lat: entry.lat, position, r2: Cesium.Cartesian3.magnitudeSquared(position), noteNormal, noteSelected, index: i, }); }); this._initMarkerLayer(); } // 把便签锚点贴到地形表面(否则在椭球面高度 0 上,放大旋转时会飘) async _clampMarkersToTerrain() { const scene = this.viewer.scene; if (!scene) return; // 等待地形 provider 就绪(setTerrain 是异步的,调用时 provider 可能还没准备好) let tp = scene.terrainProvider; if (!tp) { tp = await new Promise((resolve) => { const started = Date.now(); const poll = () => { const p = scene.terrainProvider; if (p) { resolve(p); return; } if (Date.now() - started > 30000) { resolve(undefined); return; } setTimeout(poll, 500); }; poll(); }); } if (!tp) { console.warn('[CesiumGlobe] 地形 provider 未就绪,便签保持椭球面高度'); return; } const cartos = this.markers.map((m) => Cesium.Cartographic.fromDegrees(m.lng, m.lat), ); try { // 固定层级 15(约 30m):ArcGIS 高程服务无 availability,不用 sampleTerrainMostDetailed await Cesium.sampleTerrain(tp, 15, cartos); } catch (err) { console.warn('[CesiumGlobe] 地形高度采样失败:', err); } // 失败的点逐级降级重试(更粗的层级更容易成功) for (const level of [12, 8, 4]) { const retry = cartos.filter((c) => c.height == null || !isFinite(c.height)); if (retry.length === 0) break; try { await Cesium.sampleTerrain(tp, level, retry); } catch (err) { break; } } let clamped = 0; for (let i = 0; i < this.markers.length; i++) { const h = cartos[i] && cartos[i].height; if (h != null && isFinite(h)) { const m = this.markers[i]; m.position = Cesium.Cartesian3.fromDegrees(m.lng, m.lat, h); m.r2 = Cesium.Cartesian3.magnitudeSquared(m.position); clamped++; } } console.log(`[CesiumGlobe] 便签已贴附地形:${clamped}/${this.markers.length}`); } // 便签用 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(); m.screenX = -1e9; m.screenY = -1e9; el.style.backgroundImage = `url("${m.normalUrl}")`; 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|²;背面隐藏(不渐变淡出) const frontness = Cesium.Cartesian3.dot(m.position, camPos) - m.r2; if (frontness <= 0) { el.style.display = 'none'; continue; } el.style.display = 'block'; el.style.opacity = '1'; m.screenX = win.x - m.tipX; m.screenY = win.y - m.tipY; el.style.left = `${m.screenX}px`; el.style.top = `${m.screenY}px`; } } _setupPick() { const viewer = this.viewer; // 移除 Viewer 默认的单击选中与双击“追踪实体” viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK); viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); // 便签层 pointer-events:none 后,左键通过 canvas 命中测试选中便签 viewer.screenSpaceEventHandler.setInputAction((movement) => { const x = movement.position.x; const y = movement.position.y; for (const m of this.markers) { if (!m.el || m.el.style.display === 'none') continue; const w = m.noteNormal.width; const h = m.noteNormal.height; if ( x >= m.screenX && x <= m.screenX + w && y >= m.screenY && y <= m.screenY + h ) { this.onPick?.(m.index); break; } } }, Cesium.ScreenSpaceEventType.LEFT_CLICK); } // 右键原生 tilt:按下记录位置;松开不回正。中键:回正到“不倾斜不旋转”(天底 + 北朝上) _setupRightRestore() { const scene = this.viewer.scene; const camera = this.viewer.camera; const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas); this._rightHome = null; handler.setInputAction(() => { camera.cancelFlight(); // 取消可能仍在进行的回正动画 this._rightHome = Cesium.Cartesian3.clone(camera.position); }, Cesium.ScreenSpaceEventType.RIGHT_DOWN); handler.setInputAction(() => { // 松开右键:只清掉 tilt 惯性余力,不回正 const tiltState = scene.screenSpaceCameraController._lastInertiaTiltMovement; if (tiltState) tiltState.inertiaEnabled = false; }, Cesium.ScreenSpaceEventType.RIGHT_UP); handler.setInputAction(() => { // 中键:回正(天底 + 北朝上,保持原注视点) if (!this._rightHome) return; const pos = this._rightHome; this._rightHome = null; camera.flyTo({ destination: pos, orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0 }, duration: 0.5, }); }, Cesium.ScreenSpaceEventType.MIDDLE_DOWN); this._rightRestoreHandler = handler; } // 相机平滑飞至某个标记上空并居中:回到与初始一致的高度与北朝上方向,左键拖拽手感不变 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'); // 国家名用 LabelCollection(避免大量 entity 开销) const countryLabels = new Cesium.LabelCollection({ scene: this.viewer.scene }); this.viewer.scene.primitives.add(countryLabels); 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 ? 28 : areaKm2 > 1.5e6 ? 21 : areaKm2 > 3e5 ? 18 : 16; countryLabels.add({ position: Cesium.Cartesian3.fromDegrees(props.LABEL_X, props.LABEL_Y), text: cname, font: `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`, fillColor: textGold, outlineColor: black, outlineWidth: 2, style: Cesium.LabelStyle.FILL_AND_OUTLINE, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, disableDepthTestDistance: 1000, // 1km 内不深度测试(近处不消失),远处仍遮挡背面 distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 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, 2.5e6); const tdc = new Cesium.NearFarScalar(1.25e6, 1.0, 2.5e6, 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, }); labels.add({ position, text: c.name, font: 'bold 17px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif', fillColor: textGold, outlineColor: black, outlineWidth: 2, style: Cesium.LabelStyle.FILL_AND_OUTLINE, horizontalOrigin: Cesium.HorizontalOrigin.CENTER, verticalOrigin: Cesium.VerticalOrigin.BOTTOM, pixelOffset: new Cesium.Cartesian2(0, -6), heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, disableDepthTestDistance: 1000, // 1km 内不深度测试(近处不消失),远处仍遮挡背面 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(); } }