Compare commits

...
26 Commits
Author SHA1 Message Date
fukunee 7498409c7d README:新增国家/城市名贴地 TODO 2026-09-19 17:58:22 +08:00
fukunee db8b14705d README:补充 Cesium 地理视图精要说明 2026-09-19 17:53:17 +08:00
fukunee f11dcb0ea5 词条便签逆时针旋转45°(绕箭头尖),命中测试改用实际矩形 2026-09-19 17:44:32 +08:00
fukunee 6d46d51685 词条便签整体缩小,圆角更小 2026-09-19 17:41:14 +08:00
fukunee 4cfeb7cba0 撤回:恢复箭头平滑曲线 2026-09-19 17:40:47 +08:00
fukunee 929e55c32f 箭头改回直角三角(无圆角) 2026-09-19 17:40:17 +08:00
fukunee 847d361431 便签文字左移2px 2026-09-19 17:39:24 +08:00
fukunee 30e2f562bc 便签:箭头曲线直接平滑过渡到长边,消除小拐角 2026-09-19 17:38:24 +08:00
fukunee 46c6d2f34a 便签箭头用二次贝塞尔曲线平滑过渡到主体 2026-09-19 17:36:33 +08:00
fukunee d327ab5bc1 便签:箭头加大、圆角更小更方 2026-09-19 17:34:51 +08:00
fukunee 0215fa71d4 便签形状:更圆角、指向箭头更小更简洁 2026-09-19 17:33:15 +08:00
fukunee d0732a9ac3 国家/城市字号再调大;城市最大距离增至2.5e6 2026-09-19 17:32:07 +08:00
fukunee 52b09f5049 标签字号再调大;国家名最大距离恢复1e7 2026-09-19 17:30:55 +08:00
fukunee 63777b83ce 标签样式改为 FILL_AND_OUTLINE,使黑边生效 2026-09-19 17:30:05 +08:00
fukunee 46f6681b6a 国家名:字号调大、黑边黄字、最大显示距离6e6 2026-09-19 17:29:24 +08:00
fukunee 3522ece7b3 城市名:字号13px、黑边黄字、最大显示距离2e6 2026-09-19 17:28:47 +08:00
fukunee 1fd386b6cf 便签贴附:失败点降级重试(12/8/4)尽量补全 2026-09-19 17:24:40 +08:00
fukunee 10b2b77887 便签贴附:等待地形provider就绪后再采样 2026-09-19 17:23:15 +08:00
fukunee d4e0999506 便签地形采样改用固定层级 sampleTerrain,并输出贴附数量日志 2026-09-19 17:19:46 +08:00
fukunee 0e150f09df 便签地形贴附改用 sampleTerrainMostDetailed 2026-09-19 17:15:46 +08:00
fukunee 5b71877ae3 便签锚点贴到地形表面,修复放大旋转时的漂移 2026-09-19 17:12:56 +08:00
fukunee 89d51a7f59 便签不拦截事件:滚轮/右键穿透,左键改为canvas命中选中 2026-09-19 17:10:07 +08:00
fukunee 9df646c91c 中键回正改为天底+北朝上(不恢复原视角) 2026-09-19 17:07:49 +08:00
fukunee d02e7da220 关闭光照/大气辉光,地形回到2.0 2026-09-19 17:04:30 +08:00
fukunee 6256098a94 丢弃 ESRI 灰色'Map data not yet available'错误瓦片,回退父级放大 2026-09-19 17:03:38 +08:00
fukunee 174f162565 右键不自动回正,改为中键回正(保留动画) 2026-09-19 16:58:36 +08:00
3 changed files with 240 additions and 69 deletions
+76 -23
View File
@@ -1,22 +1,56 @@
# 人类文明里程碑 · 活页编年史 # 人类文明里程碑 · 活页编年史
一个以 **Three.js** 为核心、艺术化呈现世界历史里程碑的三维网站。它以一只**活页本**为隐喻,把历史中里程碑式的人、事、物登记成一张张两面活页:正面是登记卡(编号 / 时间 / 地区 / 名称 / 描述),背面是程序化生成的"铅笔素描"。 一个以 **Three.js** 为核心、艺术化呈现世界历史里程碑的三维网站。它以一只**活页本**为隐喻,把历史中里程碑式的人、事、物登记成一张张两面活页:正面是登记卡(编目号 / 时间 / 地区 / 名称 / 描述),背面是程序化生成的"铅笔素描"。
## 三种视图 ## 三种视图
| 视图 | 内容 | | 视图 | 内容 |
| --- | --- | | --- | --- |
| **活页本** | 皮革封面 + 金属活页环 + 20 张双面活页,翻页动画、纸张弯曲、堆叠阴影 | | **活页本** | 皮革封面 + 金属活页环 + 全部词条双面活页(正面登记卡、背面素描),翻页动画、纸张弯曲、堆叠阴影 |
| **时间轴** | 20 张活页沿金色时间轴一字排开,选中卡片上浮并显示年代 | | **时间轴** | 全部词条活页沿金色时间轴等距排开,上方常显主题、轴下方常显时间;点击/左右键选中并居中 + 翻面(纸张弯曲动画) |
| **地理** | 点阵地球 + 经纬网 + 事件标记 + 相邻事件间的金色大圆航线,缓慢自转 | | **地理** | Cesium 三维地球:卫星影像 + 真实地形 + 国界/城市标注 + 词条便签标记 |
## 地理视图(Cesium)
地理视图使用 **Cesium**(懒加载,进入该视图才加载脚本),免费、无需 Ion token:
- **影像/地形**:ESRI World Imagery 卫星影像(限制 ±85°)+ Esri World Elevation 3D 真实地形。
- **国界**:Natural Earth 110m,黑色单线。
- **国家/城市名**:Natural Earth(10m 城市点,直接使用其 `name_zh` 中文名),黑边黄字(`FILL_AND_OUTLINE`),按距离淡出,背面经深度测试隐藏。
- **词条便签**:DOM 覆盖层(非 billboard),贴地形高度,逆时针 45° 斜放、平滑曲线箭头;未选中金底黑字带黑边,选中黑底金字。
- **交互**:左键旋转、滚轮缩放(500 m ~ 8000 km)、右键原生 tilt(松手不回正)、中键回正(flyTo 动画)。
- **画质**:按设备像素比渲染(高分屏清晰)、FXAA + MSAA、地形 `maximumScreenSpaceError=2.0`。
- **兜底**:丢弃 ESRI 灰色 "Map data not yet available" 错误瓦片,自动回退父级放大。
## 技术要点 ## 技术要点
- **Three.js**(ES 模块 + Vite)渲染全部三维内容,`OrbitControls` 供旋转/缩放。 - **Three.js**(ES 模块 + Vite)渲染全部三维内容,`OrbitControls` 供旋转/缩放。
- **程序化纹理**:纸张、皮革、活页孔、登记卡、素描全部由 Canvas 2D 即时生成,无需外部图片。 - **程序化纹理**:纸张、皮革、活页孔、登记卡、素描全部由 Canvas 2D 即时生成,无需外部图片。
- **程序化素描**:18 种手绘风格速写(头骨、泥板、金字塔、人像、神庙、王冠、斗兽场、卷轴、骑士、帆船、地球仪、苹果、蒸汽机、自由钟、进化树、原子、和平鸽、火箭),带抖动线条与排线阴影,同一词条每次渲染结果确定一致。 - **程序化素描**:53 种手绘风格速写(化石、泥板、金字塔、人像、神庙、王冠、斗兽场、卷轴、骑士、帆船、地球仪、苹果、蒸汽机、自由钟、进化树、原子、和平鸽、火箭、书、桥、旗帜、天平、剑、头盔、齿轮……),带抖动线条与排线阴影,同一词条每次渲染结果确定一致。
- **双面活页**:正面用 `FrontSide`、背面用 `BackSide`(内容预先水平镜像),配合纸张柔性弯曲,翻页时自然显露背面素描。 - **双面活页**:正面用 `FrontSide`、背面用 `BackSide`(内容预先水平镜像),配合纸张柔性弯曲,翻页时自然显露背面素描。
- 数据(`src/data/entries.js`)包含 20 条真实历史里程碑,含经纬度与分类(人 / 事 / 物)。 - 数据(`src/data/entries.js`)包含 182 条真实历史里程碑,含经纬度、分类(人 / 事 / 物)与档案式编目号。
- **编目号**:每页登记 `NO.{时间}-{地区}-{序号}`,如 `NO.BCE3-AFETNE-I0001`。时间=BC/AD 两位世纪,或 BCE1–BCE4 地质年代(1万/10万/100万/1000万年前);地区=大洲码+国家码+方位码(如 AFETNE=非洲·埃塞俄比亚·东北部);序号前缀 E/P/T/I/B(事件/人物/学说技术/文物/建筑)。
- **时间轴交互**:拖拽左右滑动、滚轮缩放;点击卡片 / 左右键 / 底部按钮 选中并平滑居中 + 翻面(带纸张弯曲动画),选中另一张时其它翻到背面的卡片自动翻回正面。
- **文字清晰度**:文字精灵 2 倍超采样栅格化;页面纹理以完整分辨率(768×1075)上传,避免降采样发糊。
## 性能优化(已完成)
- **纹理懒加载 + LRU**:页面 / 卡片纹理按需生成,引用计数 + LRU 释放(上限 40 页),启动只为可见窗口生成;时间轴卡片用独立低分辨率纹理池(256×358)。
- **按需渲染**:静止时停止重绘,动画 / 交互 / 状态变化时才渲染(含页面 z 过渡的 `isSettling` 判定)。
- **几何降级**:栈内页用极简几何(2 三角形),顶层 / 翻页页才用高分段;封面皮革曲面、活页环、铆钉均降低分段;时间轴卡片 24×6 → 12×3。
- **双面材质合并**:页面正反面合并为单 mesh(`gl_FrontFacing` 双纹理),draw call 减半;活页孔用 alphaTest 免透明排序。
- **Cesium 懒加载 + 后台预载**:首屏不加载 6MB 脚本,首帧后后台预载,地理按钮就绪前在循环切换中自动跳过。
- **地理数据 primitive 化**:7342 城市点/标签与约 240 国家名标签由 entity 改为 `PointPrimitiveCollection` / `LabelCollection`。
- **渲染分辨率**:Three.js 像素比上限 2(`min(devicePixelRatio, 2)`)+ MSAA 抗锯齿、Cesium 开 FXAA,三视图清晰;页面纹理关闭 mipmap、anisotropy 8→4。
- **时间轴专项**:拖拽期间暂停纹理生成(避免滑动时同步 canvas 绘制掉帧);时间轴视图关闭阴影贴图、灯光 5→2;刻度线合并为单个 LineSegments。
- **射线检测节流**:hover 合帧到渲染循环,只测可见窗口对象(binder 当前页 ±1、时间轴可视窗口)。
- **gzip**:Nginx 开启 gzip(含 `application/octet-stream`),geojson 3MB→1MB、城市 641KB→182KB。
## 翻页交互
- **空间点击翻页**:按命中点的空间左右半区判定方向——书脊右侧向前翻、左侧向后翻,翻页过程中也不受页索引错位影响。
- **松开触发**:左键「松开且未拖拽(≤4px)」才翻页;拖拽旋转视角不会误翻页。
- **首尾回卷 + END 页**:最后一页再向前进入 END 页——末页翻到左侧露出其背面素描;END 页向前回卷到第一页,第一页向后直接快进到 END 页,首尾闭环,底部导航联动显示「END」。
## 运行 ## 运行
@@ -27,6 +61,13 @@ npm run build # 生产构建(dist/)
npm run preview # 预览生产构建 npm run preview # 预览生产构建
``` ```
生成 / 切换词条数据(跑完重新 `npm run build`):
```bash
python scripts/gen_entries.py # 182 条真实词条(含坐标去重、编目号、素描分配)
python scripts/gen_entries_stress.py # 1000 条压力测试数据
```
## 部署 ## 部署
### 1. 构建 ### 1. 构建
@@ -70,33 +111,45 @@ curl -s -o /dev/null -w "%{http_code}\n" https://hist.fukun.net/
## 操作 ## 操作
- **拖动** 旋转视角,**滚轮** 缩放 - **活页本**:拖动旋转、滚轮缩放;点击书页右侧向前/左侧向后(松开触发);**← →** / 底部按钮翻页;第一页再向后 / 最后一页再向前回卷到 END 页
- **← →** 或底部按钮 翻页(翻面查看背面素描) - **时间轴**:拖拽左右滑动、滚轮缩放;点击卡片 / **← →** / 底部按钮 选中并居中 + 翻面
- 点击 活页 / 时间轴卡片 / 地球标记 选中词条 - **地理**:左键拖拽旋转、滚轮缩放(右键 tilt、中键回正);点击标记选中
- 顶部按钮或按键 **1 / 2 / 3** 切换 活页本 / 时间轴 / 地理 视图 - 底部导航栏按钮(或按键 **1 / 2 / 3**)切换 活页本 / 时间轴 / 地理 视图
- 右下角登记卡在各视图间保持显示
## 目录结构 ## 目录结构
``` ```
src/ src/
main.js 入口 main.js 入口
core/App.js 编排器:渲染、灯光、相机、视图切换、拾取 core/App.js 编排器:渲染、灯光、相机、视图切换、拾取
scene/Binder.js 活页本(封面、环、双面活页、翻页动画) scene/binder.js 活页本(封面、环、双面活页、翻页动画)
scene/Timeline.js 时间轴视图 scene/timeline.js 时间轴视图
scene/Globe.js 地理视图(点阵地球、弧线、标记) scene/CesiumGlobe.js 地理视图(Cesium 地球、国界、城市、词条便签)
textures/pages.js 纸张 + 登记卡/素描两面纹理 textures/pages.js 纸张 + 登记卡/素描两面纹理
textures/sketch.js 程序化素描生成器 textures/sketch.js 程序化素描生成器(53 种)
data/entries.js 历史词条数据 data/entries.js 历史词条数据(182 条)
data/worldMap.js 简化大陆轮廓 ui/overlay.js HTML 覆盖层
ui/overlay.js HTML 覆盖层 ui/loading.js 加载遮罩
utils/ 缓动 / 文字精灵 utils/ 缓动 / 文字精灵
scripts/ 截图与验证辅助(开发用) scripts/
gen_entries.py 生成真实词条(坐标去重、编目号、素描分配)
gen_entries_stress.py 生成 1000 条压力测试数据
region_codes.py 地区编码 + 条目类型分类
analyze/ascii/colorascii/shot/verify 开发辅助
public/
world-countries.geojson 国家边界(Natural Earth 110m)
world-cities-zh.json 城市点(Natural Earth 10m,中文名)
``` ```
## TODO / 后续规划 ## TODO / 后续规划
- [ ] **纹理懒加载**:书页与时间轴卡片的 Canvas 纹理改为按需 / 增量生成。当前启动时一次性生成全部纹理(每条词条正反两面),在 1000 条词条压力测试下启动耗时约 6 秒;懒加载后应降到约 1 秒。 - [ ] **国家/城市名贴地(参考便签)**:当前国家名、城市名标签在地形起伏处“钉地”不准(`CLAMP_TO_GROUND` 表现不理想)。需参考词条便签的做法,用 `Cesium.sampleTerrain` 显式采样地形高度后设置标签锚点高度,使其真正贴合地形表面。
- [x] **纹理懒加载**:已完成(见「性能优化」——可见窗口按需生成 + 引用计数/LRU 释放)。
- [ ] **活页本分册**:为全部词条增加分册(分卷)机制——按历史时期或主题分组,顶部提供分册导航与切换,活页本只展示当前分册的内容,降低单册厚度、便于浏览。 - [ ] **活页本分册**:为全部词条增加分册(分卷)机制——按历史时期或主题分组,顶部提供分册导航与切换,活页本只展示当前分册的内容,降低单册厚度、便于浏览。
- [ ] **时间轴时期划分**:时间轴轴线上标注中西方历史时期刻度(如中国朝代:夏商周 / 秦汉 / 隋唐 / 宋元明清;西方:古典时代 / 中世纪 / 文艺复兴 / 启蒙运动 / 工业革命等),作为背景衬托。 - [ ] **时间轴时期划分**:时间轴轴线上标注中西方历史时期刻度(如中国朝代:夏商周 / 秦汉 / 隋唐 / 宋元明清;西方:古典时代 / 中世纪 / 文艺复兴 / 启蒙运动 / 工业革命等),作为背景衬托。
- [ ] **词条插入时间轴**:词条按其 `timeNumeric` 真实时间位置插入时间轴(当前为等距排列),使时期刻度与词条位置相互对应。 - [ ] **词条插入时间轴**:词条按其 `timeNumeric` 真实时间位置插入时间轴(当前为等距排列),使时期刻度与词条位置相互对应。
- [ ] **词条结构考究与排版优化**:重新审视 `entries.js` 数据模型(补充年代精度、文明圈、影响、关键词等字段),并优化登记卡正面排版(信息层级、留白、装饰细节)。 - [ ] **词条结构考究与排版优化**:重新审视 `entries.js` 数据模型(补充年代精度、文明圈、影响、关键词等字段),并优化登记卡正面排版(信息层级、留白、装饰细节)。
- [ ] **性能进一步优化**:继续排查渲染 / 显存 / 加载瓶颈(已做:纹理懒加载、按需渲染、几何降级、双面材质、gzip 等)。
- [x] **首尾边界回卷**:活页本第一页 / 最后一页 / END 页边界已处理,首尾闭环。
- [x] **渲染清晰度**:像素比上限 2 + MSAA / FXAA 抗锯齿,三视图不再发糊。
+164 -44
View File
@@ -55,7 +55,7 @@ function sphericalAreaKm2(polys) {
// 生成便签 canvas:黄底圆角 + 左侧箭头 + 黑色加粗文字(整张图,箭头尖端指向坐标) // 生成便签 canvas:黄底圆角 + 左侧箭头 + 黑色加粗文字(整张图,箭头尖端指向坐标)
// 选中态:先描粗边再填充,得到一条干净的 2px 外描边(四周预留 B 的 padding 防止裁切) // 选中态:先描粗边再填充,得到一条干净的 2px 外描边(四周预留 B 的 padding 防止裁切)
function makeStickyNote(text, fontSize, selected = false) { function makeStickyNote(text, fontSize, selected = false) {
const padX = 8, padY = 5, radius = 7, arrowW = 10, arrowH = 14, S = 2, B = 2; // S: 2x 超采样,B: 边框预留 const padX = 7, padY = 4, radius = 2, arrowW = 11, arrowH = 14, S = 2, B = 2; // S: 2x 超采样,B: 边框预留
const font = `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`; const font = `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`;
const m = document.createElement('canvas'); const m = document.createElement('canvas');
const mctx = m.getContext('2d'); const mctx = m.getContext('2d');
@@ -77,16 +77,15 @@ function makeStickyNote(text, fontSize, selected = false) {
// 箭头 + 圆角矩形一体化路径(箭头尖端在 (ox, oy + h/2),指向坐标) // 箭头 + 圆角矩形一体化路径(箭头尖端在 (ox, oy + h/2),指向坐标)
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(ox, oy + h / 2); ctx.moveTo(ox, oy + h / 2);
ctx.lineTo(ox + arrowW, oy + h / 2 - arrowH / 2); // 上侧:一条曲线从尖端平滑过渡到上边缘(终点水平切线,无折角)
ctx.lineTo(ox + arrowW, oy + radius); ctx.quadraticCurveTo(ox + arrowW * 0.7, oy, ox + arrowW, oy);
ctx.arcTo(ox + arrowW, oy, ox + arrowW + radius, oy, radius);
ctx.lineTo(ox + w - radius, oy); ctx.lineTo(ox + w - radius, oy);
ctx.arcTo(ox + w, oy, ox + w, oy + radius, radius); ctx.arcTo(ox + w, oy, ox + w, oy + radius, radius);
ctx.lineTo(ox + w, oy + h - radius); ctx.lineTo(ox + w, oy + h - radius);
ctx.arcTo(ox + w, oy + h, ox + w - radius, oy + h, radius); ctx.arcTo(ox + w, oy + h, ox + w - radius, oy + h, radius);
ctx.lineTo(ox + arrowW + radius, oy + h); ctx.lineTo(ox + arrowW, oy + h);
ctx.arcTo(ox + arrowW, oy + h, ox + arrowW, oy + h - radius, radius); // 下侧:一条曲线从下边缘平滑过渡回尖端(起点水平切线)
ctx.lineTo(ox + arrowW, oy + h / 2 + arrowH / 2); ctx.quadraticCurveTo(ox + arrowW * 0.7, oy + h, ox, oy + h / 2);
ctx.closePath(); ctx.closePath();
if (selected) { if (selected) {
@@ -117,7 +116,7 @@ function makeStickyNote(text, fontSize, selected = false) {
ctx.font = font; ctx.font = font;
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(text, ox + arrowW + bodyW / 2, oy + h / 2 + 1); ctx.fillText(text, ox + arrowW + bodyW / 2 - 2, oy + h / 2 + 1);
return { canvas: c, scale: 1 / S, width: cw, height: ch, tipX: B, tipY: B + h / 2 }; return { canvas: c, scale: 1 / S, width: cw, height: ch, tipX: B, tipY: B + h / 2 };
} }
@@ -203,8 +202,8 @@ export class CesiumGlobe {
scene.skyBox.show = false; scene.skyBox.show = false;
scene.sun.show = false; scene.sun.show = false;
scene.moon.show = false; scene.moon.show = false;
scene.globe.enableLighting = true; // 地形法线明暗 + 水面高光/波纹 scene.globe.enableLighting = false;
scene.globe.showGroundAtmosphere = true; // 地平线大气辉光 scene.globe.showGroundAtmosphere = false;
scene.globe.baseColor = Cesium.Color.fromCssColorString('#d9e6f0'); // 冰盖浅色:影像/地形未覆盖的南北极区域显示为冰盖,而非黑洞 scene.globe.baseColor = Cesium.Color.fromCssColorString('#d9e6f0'); // 冰盖浅色:影像/地形未覆盖的南北极区域显示为冰盖,而非黑洞
// 相机交互:限制滚轮缩放范围,避免过近/过远 // 相机交互:限制滚轮缩放范围,避免过近/过远
@@ -228,11 +227,12 @@ export class CesiumGlobe {
}, },
]; ];
// 阻止右键弹出浏览器菜单 // 阻止右键弹出浏览器菜单;阻止中键触发浏览器自动滚动
scene.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); scene.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
scene.canvas.addEventListener('mousedown', (e) => { if (e.button === 1) e.preventDefault(); });
// 精细化渲染:高清 + 精细地形 // 精细化渲染:高清 + 精细地形
scene.globe.maximumScreenSpaceError = 1.0; // 更精细的地形起伏 scene.globe.maximumScreenSpaceError = 2.0; // 地形细分
scene.globe.tileCacheSize = 600; // 更大的瓦片缓存,减少细节刷新 scene.globe.tileCacheSize = 600; // 更大的瓦片缓存,减少细节刷新
scene.fxaa = true; // 后处理抗锯齿(MSAA 默认已 4x) scene.fxaa = true; // 后处理抗锯齿(MSAA 默认已 4x)
viewer.useBrowserRecommendedResolution = false; // 按设备像素比渲染,高分屏清晰 viewer.useBrowserRecommendedResolution = false; // 按设备像素比渲染,高分屏清晰
@@ -240,10 +240,45 @@ export class CesiumGlobe {
scene.maximumRenderTimeChange = Infinity; // 配合 requestRenderMode:静止时停止重绘,大幅省电省帧 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({ const imagery = new Cesium.UrlTemplateImageryProvider({
url: IMAGERY_URL, url: IMAGERY_URL,
credit: 'Imagery © Esri, Maxar, Earthstar Geographics', credit: 'Imagery © Esri, Maxar, Earthstar Geographics',
maximumLevel: 19, maximumLevel: 19,
tileDiscardPolicy: discardNotAvailableTile,
}); });
const imageryLayer = new Cesium.ImageryLayer(imagery, { const imageryLayer = new Cesium.ImageryLayer(imagery, {
// 卫星影像不覆盖极区(约 ±85° 外返回黑块),限制范围让极区用冰盖底色 // 卫星影像不覆盖极区(约 ±85° 外返回黑块),限制范围让极区用冰盖底色
@@ -263,6 +298,7 @@ export class CesiumGlobe {
viewer.clock.shouldAnimate = false; viewer.clock.shouldAnimate = false;
this._buildMarkers(); this._buildMarkers();
this._clampMarkersToTerrain();
this._setupPick(); this._setupPick();
this._setupRightRestore(); this._setupRightRestore();
this._loadBoundaries(); this._loadBoundaries();
@@ -288,9 +324,11 @@ export class CesiumGlobe {
_buildMarkers() { _buildMarkers() {
ENTRIES.forEach((entry, i) => { ENTRIES.forEach((entry, i) => {
const position = Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat); const position = Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat);
const noteNormal = makeStickyNote(entry.name, 16, false); const noteNormal = makeStickyNote(entry.name, 14, false);
const noteSelected = makeStickyNote(entry.name, 16, true); const noteSelected = makeStickyNote(entry.name, 14, true);
this.markers.push({ this.markers.push({
lng: entry.lng,
lat: entry.lat,
position, position,
r2: Cesium.Cartesian3.magnitudeSquared(position), r2: Cesium.Cartesian3.magnitudeSquared(position),
noteNormal, noteNormal,
@@ -301,6 +339,64 @@ export class CesiumGlobe {
this._initMarkerLayer(); 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 覆盖层渲染:始终在城市/国家名上层,且缩放到最近也不会漂移 // 便签用 DOM 覆盖层渲染:始终在城市/国家名上层,且缩放到最近也不会漂移
_initMarkerLayer() { _initMarkerLayer() {
const container = document.getElementById('cesium-container'); const container = document.getElementById('cesium-container');
@@ -319,11 +415,11 @@ export class CesiumGlobe {
m.tipY = m.noteNormal.tipY; m.tipY = m.noteNormal.tipY;
m.normalUrl = m.noteNormal.canvas.toDataURL(); m.normalUrl = m.noteNormal.canvas.toDataURL();
m.selectedUrl = m.noteSelected.canvas.toDataURL(); m.selectedUrl = m.noteSelected.canvas.toDataURL();
m.screenX = -1e9;
m.screenY = -1e9;
el.style.backgroundImage = `url("${m.normalUrl}")`; el.style.backgroundImage = `url("${m.normalUrl}")`;
el.addEventListener('click', (e) => { el.style.transform = 'rotate(-45deg)';
e.stopPropagation(); el.style.transformOrigin = `${m.tipX}px ${m.tipY}px`;
this.onPick?.(m.index);
});
this._markerLayer.appendChild(el); this._markerLayer.appendChild(el);
} }
@@ -351,48 +447,68 @@ export class CesiumGlobe {
el.style.display = 'block'; el.style.display = 'block';
el.style.opacity = '1'; el.style.opacity = '1';
el.style.left = `${win.x - m.tipX}px`; m.screenX = win.x - m.tipX;
el.style.top = `${win.y - m.tipY}px`; m.screenY = win.y - m.tipY;
el.style.left = `${m.screenX}px`;
el.style.top = `${m.screenY}px`;
} }
} }
_setupPick() { _setupPick() {
const viewer = this.viewer; const viewer = this.viewer;
// 移除 Viewer 默认的单击选中与双击“追踪实体”:否则双击后会锁定相机(定住) // 移除 Viewer 默认的单击选中与双击“追踪实体”
// 标记便签已改为 DOM 覆盖层,点击由 DOM 元素自行处理
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK); viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_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;
const canvasRect = viewer.canvas.getBoundingClientRect();
for (const m of this.markers) {
const el = m.el;
if (!el || el.style.display === 'none') continue;
const r = el.getBoundingClientRect();
if (
x >= r.left - canvasRect.left && x <= r.right - canvasRect.left &&
y >= r.top - canvasRect.top && y <= r.bottom - canvasRect.top
) {
this.onPick?.(m.index);
break;
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
} }
// 右键原生 tilt 松开后自动回位:按下记录相机状态,松开恢复(并清掉 tilt 惯性余力) // 右键原生 tilt:按下记录位置;松开不回正。中键:回正到“不倾斜不旋转”(天底 + 北朝上)
_setupRightRestore() { _setupRightRestore() {
const scene = this.viewer.scene; const scene = this.viewer.scene;
const camera = this.viewer.camera; const camera = this.viewer.camera;
const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas); const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas);
this._rightRestore = null; this._rightHome = null;
handler.setInputAction(() => { handler.setInputAction(() => {
camera.cancelFlight(); // 取消可能仍在进行的回正动画 camera.cancelFlight(); // 取消可能仍在进行的回正动画
this._rightRestore = { this._rightHome = Cesium.Cartesian3.clone(camera.position);
position: Cesium.Cartesian3.clone(camera.position),
direction: Cesium.Cartesian3.clone(camera.direction),
up: Cesium.Cartesian3.clone(camera.up),
};
}, Cesium.ScreenSpaceEventType.RIGHT_DOWN); }, Cesium.ScreenSpaceEventType.RIGHT_DOWN);
handler.setInputAction(() => { handler.setInputAction(() => {
if (!this._rightRestore) return; // 松开右键:只清掉 tilt 惯性余力,不回正
const s = this._rightRestore;
this._rightRestore = null;
const tiltState = scene.screenSpaceCameraController._lastInertiaTiltMovement; const tiltState = scene.screenSpaceCameraController._lastInertiaTiltMovement;
if (tiltState) tiltState.inertiaEnabled = false; if (tiltState) tiltState.inertiaEnabled = false;
// 回正改用 flyTo 渐变,而不是瞬间 setView }, Cesium.ScreenSpaceEventType.RIGHT_UP);
handler.setInputAction(() => {
// 中键:回正(天底 + 北朝上,保持原注视点)
if (!this._rightHome) return;
const pos = this._rightHome;
this._rightHome = null;
camera.flyTo({ camera.flyTo({
destination: s.position, destination: pos,
orientation: { direction: s.direction, up: s.up }, orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0 },
duration: 0.5, duration: 0.5,
}); });
}, Cesium.ScreenSpaceEventType.RIGHT_UP); }, Cesium.ScreenSpaceEventType.MIDDLE_DOWN);
this._rightRestoreHandler = handler; this._rightRestoreHandler = handler;
} }
@@ -441,13 +557,15 @@ export class CesiumGlobe {
let cname = props.NAME_ZH || props.NAME; let cname = props.NAME_ZH || props.NAME;
if (cname === '中华民国') cname = '中国台湾'; if (cname === '中华民国') cname = '中国台湾';
if (cname && props.LABEL_X != null && props.LABEL_Y != null) { if (cname && props.LABEL_X != null && props.LABEL_Y != null) {
const fontSize = areaKm2 > 5e6 ? 22 : areaKm2 > 1.5e6 ? 15 : areaKm2 > 3e5 ? 12 : 10; const fontSize = areaKm2 > 5e6 ? 28 : areaKm2 > 1.5e6 ? 21 : areaKm2 > 3e5 ? 18 : 16;
countryLabels.add({ countryLabels.add({
position: Cesium.Cartesian3.fromDegrees(props.LABEL_X, props.LABEL_Y), position: Cesium.Cartesian3.fromDegrees(props.LABEL_X, props.LABEL_Y),
text: cname, text: cname,
font: `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`, font: `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`,
fillColor: black, fillColor: textGold,
style: Cesium.LabelStyle.FILL, outlineColor: black,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: 1000, // 1km 内不深度测试(近处不消失),远处仍遮挡背面 disableDepthTestDistance: 1000, // 1km 内不深度测试(近处不消失),远处仍遮挡背面
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7), distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
@@ -491,8 +609,8 @@ export class CesiumGlobe {
const scene = this.viewer.scene; const scene = this.viewer.scene;
const textGold = Cesium.Color.fromCssColorString('#e0c46a'); const textGold = Cesium.Color.fromCssColorString('#e0c46a');
const black = Cesium.Color.fromCssColorString('#14100d'); const black = Cesium.Color.fromCssColorString('#14100d');
const ddc = new Cesium.DistanceDisplayCondition(0, 4e6); const ddc = new Cesium.DistanceDisplayCondition(0, 2.5e6);
const tdc = new Cesium.NearFarScalar(2e6, 1.0, 4e6, 0.0); const tdc = new Cesium.NearFarScalar(1.25e6, 1.0, 2.5e6, 0.0);
let cities = []; let cities = [];
try { try {
@@ -522,9 +640,11 @@ export class CesiumGlobe {
labels.add({ labels.add({
position, position,
text: c.name, text: c.name,
font: 'bold 11px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif', font: 'bold 17px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif',
fillColor: black, fillColor: textGold,
style: Cesium.LabelStyle.FILL, outlineColor: black,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER, horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM, verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
pixelOffset: new Cesium.Cartesian2(0, -6), pixelOffset: new Cesium.Cartesian2(0, -6),
-2
View File
@@ -44,10 +44,8 @@ html, body {
} }
.marker-note { .marker-note {
position: absolute; position: absolute;
pointer-events: auto;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 100% 100%; background-size: 100% 100%;
cursor: pointer;
} }
/* 气氛遮罩 */ /* 气氛遮罩 */