Expand chronicle to 300 entries with world data

Add a generator script that produces the entries dataset, dedupe
entries sharing coordinates, and ship world city and country
GeoJSON assets for the globe view.

Make the timeline card spacing derive from entry count, move the
view switcher into the bottom bar, and align card and label fonts
with the timeline titles.
This commit is contained in:
2026-09-19 01:07:41 +08:00
parent 9a1a3685ce
commit 71750e5dad
13 changed files with 2716 additions and 216 deletions
+210 -21
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { ENTRIES, CATEGORY_INFO } from '../data/entries.js';
import { ENTRIES } from '../data/entries.js';
// 免费、无需 Ion 令牌的影像与地形源
const IMAGERY_URL =
@@ -7,8 +7,59 @@ const IMAGERY_URL =
const TERRAIN_URL =
'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer';
// 点击标记后相机飞到的目标高度(米,约 1500 km 区域尺度)
const FLY_TO_HEIGHT = 1.5e6;
// 点击标记后飞到的目标高度(米,与初始视图一致,保证左键拖拽始终是“整球旋转”)
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 makeRoundedBg(text, fontSize) {
const padX = 12, padY = 7, radius = 10;
const m = document.createElement('canvas');
const mctx = m.getContext('2d');
mctx.font = `${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`;
const textW = mctx.measureText(text).width;
const w = Math.max(1, Math.ceil(textW + padX * 2));
const h = Math.ceil(fontSize + padY * 2);
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const ctx = c.getContext('2d');
ctx.beginPath();
ctx.moveTo(radius, 0);
ctx.arcTo(w, 0, w, h, radius);
ctx.arcTo(w, h, 0, h, radius);
ctx.arcTo(0, h, 0, 0, radius);
ctx.arcTo(0, 0, w, 0, radius);
ctx.closePath();
ctx.fillStyle = 'rgba(20, 16, 13, 0.88)';
ctx.fill();
return { canvas: c, textW };
}
export class CesiumGlobe {
constructor({ onPick } = {}) {
@@ -61,6 +112,7 @@ export class CesiumGlobe {
controller.maximumZoomDistance = 8e6; // 最远约 8000 km
// 限制鼠标按钮:只保留左键旋转 + 滚轮缩放;禁用中键倾斜、右键缩放
controller.rotateEventTypes = Cesium.CameraEventType.LEFT_DRAG;
controller.zoomEventTypes = [
Cesium.CameraEventType.WHEEL,
Cesium.CameraEventType.PINCH,
@@ -104,6 +156,8 @@ export class CesiumGlobe {
this._buildMarkers();
this._setupPick();
this._loadBoundaries();
this._buildCityLabels();
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(35, 12, 7.2e6),
@@ -117,40 +171,64 @@ export class CesiumGlobe {
const ink = Cesium.Color.fromCssColorString('#14100d');
ENTRIES.forEach((entry, i) => {
const info = CATEGORY_INFO[entry.category] || CATEGORY_INFO['事'];
const color = Cesium.Color.fromCssColorString(info.color);
const entity = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat),
point: {
pixelSize: 8,
color,
outlineColor: gold,
outlineWidth: 1.5,
pixelSize: 10, // 标记宽一点
color: gold, // 金色圆形
outlineColor: ink, // 外侧 1px 黑边
outlineWidth: 1,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
},
label: {
text: entry.name,
font: '17px Georgia, "Noto Serif SC", "Songti SC", "Microsoft YaHei", serif',
fillColor: ivory,
font: '19px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif',
fillColor: gold,
outlineColor: ink,
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -16),
horizontalOrigin: Cesium.HorizontalOrigin.LEFT,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
pixelOffset: new Cesium.Cartesian2(10, 0),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
show: false,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
show: true,
},
});
entity.entryIndex = i;
this.markers.push({ entity, index: i });
// 选中时显示的圆角黑色背景
const bg = makeRoundedBg(entry.name, 19);
const bgEntity = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat),
billboard: {
image: bg.canvas,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
pixelOffset: new Cesium.Cartesian2(10 + bg.textW / 2, 0),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: false,
},
});
this.markers.push({ entity, bg: bgEntity, index: i });
});
}
_setupPick() {
const handler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas);
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 = this.viewer.scene.pick(movement.position);
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);
@@ -160,10 +238,13 @@ export class CesiumGlobe {
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: {
@@ -175,13 +256,121 @@ export class CesiumGlobe {
});
}
// 加载行政区划(国家边界,自然地球 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);
}
}
// 全部城市: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,
},
});
}
}
setSelected(index) {
this.selected = index;
if (!this.viewer) return;
this.markers.forEach(({ entity, index: i }) => {
this.markers.forEach(({ entity, bg, index: i }) => {
const on = i === index;
entity.point.pixelSize = on ? 12 : 8;
entity.label.show = on;
entity.point.outlineWidth = on ? 3 : 1; // 选中加粗黑圈,不放大
if (bg) bg.show = on;
});
this.viewer.scene.requestRender();
}