Files
Hist/src/scene/CesiumGlobe.js
T
fukunee e05143ad73 性能优化:精灵降采样、像素比降低、麂皮法线降分辨率
- 时间轴文字精灵超采样 SS 4→2,显存省 75%
- Three.js 像素比上限 2→1.5,Cesium resolutionScale 1.5→1.0
- 麂皮法线贴图 1024×1280→512×640,启动逐像素计算省 75%
2026-09-19 04:12:43 +08:00

507 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:黄底圆角 + 左侧箭头 + 黑色加粗文字(整张图,箭头尖端指向坐标)
function makeStickyNote(text, fontSize, selected = false) {
const padX = 8, padY = 5, radius = 7, arrowW = 10, arrowH = 14, 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; });
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('#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.0; // 关闭超采样(DPR 已提供足够分辨率),省填充率
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),
});
// 创建完成:同步当前选中/可见/相机状态(首次进入时这些调用可能因 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, 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 > 8e6; // 最远 8000 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 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();
}
resize() {
if (this.viewer) this.viewer.resize();
}
}