709 lines
26 KiB
JavaScript
709 lines
26 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:黄底圆角 + 左侧箭头 + 黑色加粗文字(整张图,箭头尖端指向坐标)
|
||
// 选中态:先描粗边再填充,得到一条干净的 2px 外描边(四周预留 B 的 padding 防止裁切)
|
||
function makeStickyNote(text, fontSize, selected = false) {
|
||
const padX = 8, padY = 5, radius = 4, arrowW = 13, arrowH = 16, S = 2, B = 2; // S: 2x 超采样,B: 边框预留
|
||
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 cw = w + B * 2;
|
||
const ch = h + B * 2;
|
||
const c = document.createElement('canvas');
|
||
c.width = cw * S;
|
||
c.height = ch * S;
|
||
const ctx = c.getContext('2d');
|
||
ctx.scale(S, S);
|
||
|
||
const ox = B, oy = B; // 形状整体内缩 B,给黑边留空间
|
||
|
||
// 箭头 + 圆角矩形一体化路径(箭头尖端在 (ox, oy + h/2),指向坐标)
|
||
ctx.beginPath();
|
||
ctx.moveTo(ox, oy + h / 2);
|
||
// 上侧:平滑曲线从尖端连到左上角
|
||
ctx.quadraticCurveTo(
|
||
ox + arrowW * 0.7, oy + h / 2 - arrowH / 2,
|
||
ox + arrowW, oy + radius,
|
||
);
|
||
ctx.arcTo(ox + arrowW, oy, ox + arrowW + radius, oy, radius);
|
||
ctx.lineTo(ox + w - radius, oy);
|
||
ctx.arcTo(ox + w, oy, ox + w, oy + radius, radius);
|
||
ctx.lineTo(ox + w, oy + h - radius);
|
||
ctx.arcTo(ox + w, oy + h, ox + w - radius, oy + h, radius);
|
||
ctx.lineTo(ox + arrowW + radius, oy + h);
|
||
ctx.arcTo(ox + arrowW, oy + h, ox + arrowW, oy + h - radius, radius);
|
||
// 下侧:平滑曲线从下边缘连回尖端
|
||
ctx.quadraticCurveTo(
|
||
ox + arrowW * 0.7, oy + h / 2 + arrowH / 2,
|
||
ox, oy + h / 2,
|
||
);
|
||
ctx.closePath();
|
||
|
||
if (selected) {
|
||
// 选中:黑底金字,外圈 2px 金边(先描边再填充,外露一半)
|
||
ctx.strokeStyle = '#e0c46a';
|
||
ctx.lineWidth = B * 2;
|
||
ctx.lineJoin = 'round';
|
||
ctx.stroke();
|
||
ctx.fillStyle = '#14100d';
|
||
ctx.fill();
|
||
ctx.fillStyle = '#e0c46a';
|
||
} else {
|
||
// 非选中:金底黑字 + 1px 黑边(描 2px,填充盖掉内侧 1px)+ 柔和投影
|
||
ctx.strokeStyle = '#14100d';
|
||
ctx.lineWidth = 2;
|
||
ctx.lineJoin = 'round';
|
||
ctx.stroke();
|
||
ctx.shadowColor = 'rgba(0, 0, 0, 0.20)';
|
||
ctx.shadowBlur = 5;
|
||
ctx.shadowOffsetY = 2;
|
||
ctx.fillStyle = '#e0c46a';
|
||
ctx.fill();
|
||
ctx.shadowColor = 'transparent';
|
||
ctx.fillStyle = '#14100d';
|
||
}
|
||
|
||
// 文字
|
||
ctx.font = font;
|
||
ctx.textBaseline = 'middle';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(text, ox + arrowW + bodyW / 2, oy + h / 2 + 1);
|
||
|
||
return { canvas: c, scale: 1 / S, width: cw, height: ch, tipX: B, tipY: B + h / 2 };
|
||
}
|
||
|
||
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('#d9e6f0'); // 冰盖浅色:影像/地形未覆盖的南北极区域显示为冰盖,而非黑洞
|
||
|
||
// 相机交互:限制滚轮缩放范围,避免过近/过远
|
||
const controller = scene.screenSpaceCameraController;
|
||
controller.enableCollisionDetection = true; // 必须开启:缩放距离限制在关闭时会被忽略
|
||
controller.minimumZoomDistance = 500; // 最近约 500 m(街区尺度)
|
||
controller.maximumZoomDistance = 8e6; // 最远约 8000 km
|
||
|
||
// 限制鼠标按钮:只保留左键旋转 + 滚轮缩放;禁用中键倾斜、右键缩放
|
||
controller.rotateEventTypes = Cesium.CameraEventType.LEFT_DRAG;
|
||
controller.zoomEventTypes = [
|
||
Cesium.CameraEventType.WHEEL,
|
||
Cesium.CameraEventType.PINCH,
|
||
];
|
||
controller.tiltEventTypes = [
|
||
Cesium.CameraEventType.RIGHT_DRAG, // 右键 = 原生 tilt(传统环视)
|
||
Cesium.CameraEventType.PINCH,
|
||
{
|
||
eventType: Cesium.CameraEventType.LEFT_DRAG,
|
||
modifier: Cesium.KeyboardEventModifier.CTRL,
|
||
},
|
||
];
|
||
|
||
// 阻止右键弹出浏览器菜单;阻止中键触发浏览器自动滚动
|
||
scene.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||
scene.canvas.addEventListener('mousedown', (e) => { if (e.button === 1) e.preventDefault(); });
|
||
|
||
// 精细化渲染:高清 + 精细地形
|
||
scene.globe.maximumScreenSpaceError = 2.0; // 地形细分
|
||
scene.globe.tileCacheSize = 600; // 更大的瓦片缓存,减少细节刷新
|
||
scene.fxaa = true; // 后处理抗锯齿(MSAA 默认已 4x)
|
||
viewer.useBrowserRecommendedResolution = false; // 按设备像素比渲染,高分屏清晰
|
||
viewer.resolutionScale = 1.0;
|
||
scene.maximumRenderTimeChange = Infinity; // 配合 requestRenderMode:静止时停止重绘,大幅省电省帧
|
||
|
||
// 卫星影像
|
||
// 检测 ESRI "Map data not yet available" 灰色错误瓦片并丢弃,自动回退父级放大
|
||
const discardNotAvailableTile = {
|
||
_canvas: document.createElement('canvas'),
|
||
_ctx: null,
|
||
isReady() {
|
||
return true;
|
||
},
|
||
shouldDiscardImage(image) {
|
||
try {
|
||
if (!this._ctx) {
|
||
this._ctx = this._canvas.getContext('2d', { willReadFrequently: true });
|
||
}
|
||
this._canvas.width = 8;
|
||
this._canvas.height = 8;
|
||
this._ctx.drawImage(image, 0, 0, 8, 8);
|
||
const d = this._ctx.getImageData(0, 0, 8, 8).data;
|
||
let gray = 0;
|
||
let total = 0;
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
const r = d[i];
|
||
const g = d[i + 1];
|
||
const b = d[i + 2];
|
||
const mx = Math.max(r, g, b);
|
||
const mn = Math.min(r, g, b);
|
||
if (mx - mn < 15 && r > 110 && r < 240) gray++;
|
||
total++;
|
||
}
|
||
return total > 0 && gray / total > 0.92;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
},
|
||
};
|
||
|
||
const imagery = new Cesium.UrlTemplateImageryProvider({
|
||
url: IMAGERY_URL,
|
||
credit: 'Imagery © Esri, Maxar, Earthstar Geographics',
|
||
maximumLevel: 19,
|
||
tileDiscardPolicy: discardNotAvailableTile,
|
||
});
|
||
const imageryLayer = new Cesium.ImageryLayer(imagery, {
|
||
// 卫星影像不覆盖极区(约 ±85° 外返回黑块),限制范围让极区用冰盖底色
|
||
rectangle: Cesium.Rectangle.fromDegrees(-180, -85, 180, 85),
|
||
});
|
||
viewer.imageryLayers.add(imageryLayer);
|
||
|
||
// 真实地形(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._clampMarkersToTerrain();
|
||
this._setupPick();
|
||
this._setupRightRestore();
|
||
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({
|
||
lng: entry.lng,
|
||
lat: entry.lat,
|
||
position,
|
||
r2: Cesium.Cartesian3.magnitudeSquared(position),
|
||
noteNormal,
|
||
noteSelected,
|
||
index: i,
|
||
});
|
||
});
|
||
this._initMarkerLayer();
|
||
}
|
||
|
||
// 把便签锚点贴到地形表面(否则在椭球面高度 0 上,放大旋转时会飘)
|
||
async _clampMarkersToTerrain() {
|
||
const scene = this.viewer.scene;
|
||
if (!scene) return;
|
||
|
||
// 等待地形 provider 就绪(setTerrain 是异步的,调用时 provider 可能还没准备好)
|
||
let tp = scene.terrainProvider;
|
||
if (!tp) {
|
||
tp = await new Promise((resolve) => {
|
||
const started = Date.now();
|
||
const poll = () => {
|
||
const p = scene.terrainProvider;
|
||
if (p) { resolve(p); return; }
|
||
if (Date.now() - started > 30000) { resolve(undefined); return; }
|
||
setTimeout(poll, 500);
|
||
};
|
||
poll();
|
||
});
|
||
}
|
||
if (!tp) {
|
||
console.warn('[CesiumGlobe] 地形 provider 未就绪,便签保持椭球面高度');
|
||
return;
|
||
}
|
||
|
||
const cartos = this.markers.map((m) =>
|
||
Cesium.Cartographic.fromDegrees(m.lng, m.lat),
|
||
);
|
||
try {
|
||
// 固定层级 15(约 30m):ArcGIS 高程服务无 availability,不用 sampleTerrainMostDetailed
|
||
await Cesium.sampleTerrain(tp, 15, cartos);
|
||
} catch (err) {
|
||
console.warn('[CesiumGlobe] 地形高度采样失败:', err);
|
||
}
|
||
|
||
// 失败的点逐级降级重试(更粗的层级更容易成功)
|
||
for (const level of [12, 8, 4]) {
|
||
const retry = cartos.filter((c) => c.height == null || !isFinite(c.height));
|
||
if (retry.length === 0) break;
|
||
try {
|
||
await Cesium.sampleTerrain(tp, level, retry);
|
||
} catch (err) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
let clamped = 0;
|
||
for (let i = 0; i < this.markers.length; i++) {
|
||
const h = cartos[i] && cartos[i].height;
|
||
if (h != null && isFinite(h)) {
|
||
const m = this.markers[i];
|
||
m.position = Cesium.Cartesian3.fromDegrees(m.lng, m.lat, h);
|
||
m.r2 = Cesium.Cartesian3.magnitudeSquared(m.position);
|
||
clamped++;
|
||
}
|
||
}
|
||
console.log(`[CesiumGlobe] 便签已贴附地形:${clamped}/${this.markers.length}`);
|
||
}
|
||
|
||
// 便签用 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.tipX = m.noteNormal.tipX; // 箭头尖相对元素左上角的偏移
|
||
m.tipY = m.noteNormal.tipY;
|
||
m.normalUrl = m.noteNormal.canvas.toDataURL();
|
||
m.selectedUrl = m.noteSelected.canvas.toDataURL();
|
||
m.screenX = -1e9;
|
||
m.screenY = -1e9;
|
||
el.style.backgroundImage = `url("${m.normalUrl}")`;
|
||
this._markerLayer.appendChild(el);
|
||
}
|
||
|
||
// 用 postUpdate(每帧都触发,即使 requestRenderMode 下不真正渲染)更新便签位置,
|
||
// 避免拖拽时 postRender 不触发导致的闪烁/消失。
|
||
this.viewer.scene.postUpdate.addEventListener(() => this._updateMarkerLayer());
|
||
}
|
||
|
||
// 每帧更新便签屏幕位置(相机移动/缩放/飞行时跟随)
|
||
_updateMarkerLayer() {
|
||
const scene = this.viewer.scene;
|
||
const camera = scene.camera;
|
||
const camPos = camera.positionWC; // 每帧只取一次相机位置
|
||
|
||
for (const m of this.markers) {
|
||
const el = m.el;
|
||
if (!el) continue;
|
||
|
||
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, m.position);
|
||
if (!win) { el.style.display = 'none'; continue; }
|
||
|
||
// 前/后半球:dot(P, C) > |P|²;背面隐藏(不渐变淡出)
|
||
const frontness = Cesium.Cartesian3.dot(m.position, camPos) - m.r2;
|
||
if (frontness <= 0) { el.style.display = 'none'; continue; }
|
||
|
||
el.style.display = 'block';
|
||
el.style.opacity = '1';
|
||
m.screenX = win.x - m.tipX;
|
||
m.screenY = win.y - m.tipY;
|
||
el.style.left = `${m.screenX}px`;
|
||
el.style.top = `${m.screenY}px`;
|
||
}
|
||
}
|
||
|
||
_setupPick() {
|
||
const viewer = this.viewer;
|
||
// 移除 Viewer 默认的单击选中与双击“追踪实体”
|
||
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||
|
||
// 便签层 pointer-events:none 后,左键通过 canvas 命中测试选中便签
|
||
viewer.screenSpaceEventHandler.setInputAction((movement) => {
|
||
const x = movement.position.x;
|
||
const y = movement.position.y;
|
||
for (const m of this.markers) {
|
||
if (!m.el || m.el.style.display === 'none') continue;
|
||
const w = m.noteNormal.width;
|
||
const h = m.noteNormal.height;
|
||
if (
|
||
x >= m.screenX && x <= m.screenX + w &&
|
||
y >= m.screenY && y <= m.screenY + h
|
||
) {
|
||
this.onPick?.(m.index);
|
||
break;
|
||
}
|
||
}
|
||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||
}
|
||
|
||
// 右键原生 tilt:按下记录位置;松开不回正。中键:回正到“不倾斜不旋转”(天底 + 北朝上)
|
||
_setupRightRestore() {
|
||
const scene = this.viewer.scene;
|
||
const camera = this.viewer.camera;
|
||
const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas);
|
||
this._rightHome = null;
|
||
|
||
handler.setInputAction(() => {
|
||
camera.cancelFlight(); // 取消可能仍在进行的回正动画
|
||
this._rightHome = Cesium.Cartesian3.clone(camera.position);
|
||
}, Cesium.ScreenSpaceEventType.RIGHT_DOWN);
|
||
|
||
handler.setInputAction(() => {
|
||
// 松开右键:只清掉 tilt 惯性余力,不回正
|
||
const tiltState = scene.screenSpaceCameraController._lastInertiaTiltMovement;
|
||
if (tiltState) tiltState.inertiaEnabled = false;
|
||
}, Cesium.ScreenSpaceEventType.RIGHT_UP);
|
||
|
||
handler.setInputAction(() => {
|
||
// 中键:回正(天底 + 北朝上,保持原注视点)
|
||
if (!this._rightHome) return;
|
||
const pos = this._rightHome;
|
||
this._rightHome = null;
|
||
camera.flyTo({
|
||
destination: pos,
|
||
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0 },
|
||
duration: 0.5,
|
||
});
|
||
}, Cesium.ScreenSpaceEventType.MIDDLE_DOWN);
|
||
|
||
this._rightRestoreHandler = 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;
|
||
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');
|
||
|
||
// 国家名用 LabelCollection(避免大量 entity 开销)
|
||
const countryLabels = new Cesium.LabelCollection({ scene: this.viewer.scene });
|
||
this.viewer.scene.primitives.add(countryLabels);
|
||
|
||
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 ? 28 : areaKm2 > 1.5e6 ? 21 : areaKm2 > 3e5 ? 18 : 16;
|
||
countryLabels.add({
|
||
position: Cesium.Cartesian3.fromDegrees(props.LABEL_X, props.LABEL_Y),
|
||
text: cname,
|
||
font: `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`,
|
||
fillColor: textGold,
|
||
outlineColor: black,
|
||
outlineWidth: 2,
|
||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||
disableDepthTestDistance: 1000, // 1km 内不深度测试(近处不消失),远处仍遮挡背面
|
||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 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, 2.5e6);
|
||
const tdc = new Cesium.NearFarScalar(1.25e6, 1.0, 2.5e6, 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,
|
||
});
|
||
labels.add({
|
||
position,
|
||
text: c.name,
|
||
font: 'bold 17px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif',
|
||
fillColor: textGold,
|
||
outlineColor: black,
|
||
outlineWidth: 2,
|
||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||
pixelOffset: new Cesium.Cartesian2(0, -6),
|
||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||
disableDepthTestDistance: 1000, // 1km 内不深度测试(近处不消失),远处仍遮挡背面
|
||
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();
|
||
}
|
||
|
||
// 等待当前视图的影像/地形瓦片加载完成(超时兜底),用于切换时“一起出现”
|
||
waitUntilViewReady(timeout = 4000) {
|
||
return new Promise((resolve) => {
|
||
const globe = this.viewer.scene.globe;
|
||
let done = false;
|
||
const finish = () => {
|
||
if (done) return;
|
||
done = true;
|
||
globe.tileLoadProgressEvent.removeEventListener(onProgress);
|
||
resolve();
|
||
};
|
||
const onProgress = (remaining) => { if (remaining <= 0) finish(); };
|
||
globe.tileLoadProgressEvent.addEventListener(onProgress);
|
||
if (globe.tilesLoaded) finish();
|
||
setTimeout(finish, timeout);
|
||
});
|
||
}
|
||
|
||
resize() {
|
||
if (this.viewer) this.viewer.resize();
|
||
}
|
||
}
|