import * as Cesium from 'cesium'; import { ENTRIES } from '../data/entries.js'; // 免费、无需 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 FLY_TO_HEIGHT = 7.2e6; // 计算多边形集合的球面面积(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:黄底圆角 + 左侧箭头 + 黑色加粗文字(整张图,箭头尖端指向坐标) function makeStickyNote(text, fontSize, selected = false) { const padX = 14, padY = 8, radius = 8, arrowW = 12, arrowH = 16, S = 2; // S: 2x 超采样 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 c = document.createElement('canvas'); c.width = w * S; c.height = h * S; const ctx = c.getContext('2d'); ctx.scale(S, S); // 阴影 ctx.shadowColor = 'rgba(0, 0, 0, 0.20)'; ctx.shadowBlur = 5; ctx.shadowOffsetY = 2; // 箭头 + 圆角矩形一体化路径(箭头尖端在左侧 x=0,指向坐标) ctx.beginPath(); ctx.moveTo(0, h / 2); // 箭头尖端 ctx.lineTo(arrowW, h / 2 - arrowH / 2); // 箭头上沿 ctx.lineTo(arrowW, radius); // 左边缘上部 ctx.arcTo(arrowW, 0, arrowW + radius, 0, radius); // 左上圆角 ctx.lineTo(w - radius, 0); // 上边 ctx.arcTo(w, 0, w, radius, radius); // 右上圆角 ctx.lineTo(w, h - radius); // 右边 ctx.arcTo(w, h, w - radius, h, radius); // 右下圆角 ctx.lineTo(arrowW + radius, h); // 底边 ctx.arcTo(arrowW, h, arrowW, h - radius, radius); // 左下圆角 ctx.lineTo(arrowW, h / 2 + arrowH / 2); // 左边缘下部 ctx.closePath(); // 闭合回箭头尖端 ctx.fillStyle = '#FFF9A6'; ctx.fill(); // 选中时加黑边 if (selected) { ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.shadowOffsetY = 0; ctx.strokeStyle = '#14100d'; ctx.lineWidth = 3; ctx.stroke(); } // 黑色加粗文字(无阴影) ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.shadowOffsetY = 0; ctx.fillStyle = '#14100d'; ctx.font = font; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText(text, arrowW + bodyW / 2, h / 2 + 1); return { canvas: c, scale: 1 / S }; } 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; }); } ensureCreated() { if (this._created) return; this._created = true; 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('#0a1626'); // 相机交互:限制滚轮缩放范围,避免过近/过远 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 = false; // 关闭抗锯齿后处理,省一个全屏通道 viewer.resolutionScale = 1.5; // 1.5 倍超采样,替代 FXAA 提供更锐利的抗锯齿 scene.maximumRenderTimeChange = Infinity; // 配合 requestRenderMode:静止时停止重绘,大幅省电省帧 // 卫星影像 const imagery = new Cesium.UrlTemplateImageryProvider({ url: IMAGERY_URL, credit: 'Imagery © Esri, Maxar, Earthstar Geographics', maximumLevel: 19, }); viewer.imageryLayers.addImageryProvider(imagery); // 真实地形(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._loadBoundaries(); this._buildCityLabels(); viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(35, 12, 7.2e6), }); } _buildMarkers() { const viewer = this.viewer; ENTRIES.forEach((entry, i) => { // 便签:黄底黑字 + 左侧箭头指向坐标(整张图) const noteNormal = makeStickyNote(entry.name, 19, false); const noteSelected = makeStickyNote(entry.name, 19, true); const entity = viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat), billboard: { image: noteNormal.canvas, scale: noteNormal.scale, horizontalOrigin: Cesium.HorizontalOrigin.LEFT, verticalOrigin: Cesium.VerticalOrigin.CENTER, pixelOffset: new Cesium.Cartesian2(0, 0), heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, disableDepthTestDistance: Number.POSITIVE_INFINITY, distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7), }, }); entity.entryIndex = i; this.markers.push({ entity, noteNormal, noteSelected, index: i }); }); } _setupPick() { const viewer = this.viewer; // 移除 Viewer 默认的单击选中与双击“追踪实体”:否则双击后会锁定相机(定住) viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK); viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((movement) => { const picked = viewer.scene.pick(movement.position); if (Cesium.defined(picked) && picked.id && picked.id.entryIndex !== undefined) { const index = picked.id.entryIndex; this.onPick?.(index); this.flyToEntry(index); } }, Cesium.ScreenSpaceEventType.LEFT_CLICK); this._handler = 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; this.viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat, FLY_TO_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 提供的标注点上,字号按国土面积分级 const cname = props.NAME_ZH || props.NAME; 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.0 }) })); } } } 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 viewer = this.viewer; 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 = []; } for (const c of cities) { viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(c.lng, c.lat), point: { pixelSize: 3, color: textGold, outlineColor: black, outlineWidth: 1, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, distanceDisplayCondition: ddc, translucencyByDistance: tdc, }, label: { 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(({ entity, noteNormal, noteSelected, index: i }) => { const on = i === index; entity.billboard.image = on ? noteSelected.canvas : noteNormal.canvas; // 选中加黑边 }); this.viewer.scene.requestRender(); } setVisible(visible) { this._visible = visible; if (visible && this.viewer) this.viewer.scene.requestRender(); } resize() { if (this.viewer) this.viewer.resize(); } }