407 lines
15 KiB
JavaScript
407 lines
15 KiB
JavaScript
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();
|
|
}
|
|
}
|