- 时间轴文字精灵超采样 SS 4→2,显存省 75% - Three.js 像素比上限 2→1.5,Cesium resolutionScale 1.5→1.0 - 麂皮法线贴图 1024×1280→512×640,启动逐像素计算省 75%
507 lines
18 KiB
JavaScript
507 lines
18 KiB
JavaScript
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();
|
||
}
|
||
}
|