Files
Hist/src/scene/CesiumGlobe.js
T

461 lines
17 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';
// 计算多边形集合的球面面积(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 = 10, padY = 6, 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 = '#e0c46a';
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, width: w, height: h };
}
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;
// 调整实体渲染顺序:先渲染标签(城市/国家名),再渲染 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('#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() {
ENTRIES.forEach((entry, i) => {
const position = Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat);
const noteNormal = makeStickyNote(entry.name, 19, false);
const noteSelected = makeStickyNote(entry.name, 19, true);
this.markers.push({ 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.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);
}
this.viewer.scene.postRender.addEventListener(() => this._updateMarkerLayer());
}
// 每帧更新便签屏幕位置(相机移动/缩放/飞行时跟随)
_updateMarkerLayer() {
const scene = this.viewer.scene;
const camera = scene.camera;
const outOfRange = camera.positionCartographic.height > 1e7; // 最远 1 万 km 内显示
for (const m of this.markers) {
const el = m.el;
if (!el) continue;
if (outOfRange) { el.style.display = 'none'; continue; }
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, m.position);
if (!win) { el.style.display = 'none'; continue; }
// 背面(远半球)隐藏:dot(P, C) <= |P|² 时为背对地球
const dotPC = Cesium.Cartesian3.dot(m.position, camera.positionWC);
const r2 = Cesium.Cartesian3.magnitudeSquared(m.position);
if (dotPC <= r2) { el.style.display = 'none'; continue; }
el.style.display = 'block';
el.style.left = `${win.x}px`;
el.style.top = `${win.y}px`;
}
}
_setupPick() {
const viewer = this.viewer;
// 移除 Viewer 默认的单击选中与双击“追踪实体”:否则双击后会锁定相机(定住)
// 标记便签已改为 DOM 覆盖层,点击由 DOM 元素自行处理
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
}
// 相机平滑飞至某个标记上空并居中:回到与初始一致的高度与北朝上方向,左键拖拽手感不变
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 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((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();
}
resize() {
if (this.viewer) this.viewer.resize();
}
}