Compare commits

..
74 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
fukunee 906e53f1d5 发布:完整项目代码(三视图 + 词条数据 + 加载页等) 2026-09-19 16:53:18 +08:00
fukunee e3fd805de2 画质调整:高分渲染、精细地形、开启光照/大气 2026-09-19 16:50:56 +08:00
fukunee adfe4fca29 右键tilt回正改为flyTo渐变动画 2026-09-19 16:45:14 +08:00
fukunee b135971e7f 关闭便签渐变淡出 2026-09-19 16:42:02 +08:00
fukunee 11eecf2a42 恢复国家/城市标签距离限制,城市小圆保持常显 2026-09-19 16:38:43 +08:00
fukunee 2041004bfd 去掉标签显示距离限制与淡出 2026-09-19 16:36:54 +08:00
fukunee bd1618e226 标签1km内不深度测试,避免近处消失 2026-09-19 16:34:49 +08:00
fukunee db1a30cac2 缩放下限降到500m,低空 tilt 时减少碰撞回弹 2026-09-19 16:31:55 +08:00
fukunee 166833338e 移除 tilt 50° 限制 2026-09-19 16:27:58 +08:00
fukunee 53e7b551d0 tilt 50° 限制改用 postUpdate pitch 夹取,避免极限抖动 2026-09-19 16:26:01 +08:00
fukunee 9f1de616ea tilt 最大角度限制为 50° 2026-09-19 16:17:24 +08:00
fukunee c04efa849a 缩放下限从10km放宽到3km 2026-09-19 16:16:51 +08:00
fukunee 7ffcbbcfee 便签:未选中金底黑字带1px黑边,选中黑底金字带金边 2026-09-19 16:16:20 +08:00
fukunee 7f6855d9ae 标签改小;国家名近处不再隐藏 2026-09-19 16:14:41 +08:00
fukunee 2fe14ae09f 国家/城市标签开启深度测试,背面不渲染 2026-09-19 16:13:59 +08:00
fukunee 8ca2e9ebe3 右键原生 tilt 松开后自动回位 2026-09-19 16:08:25 +08:00
fukunee 0cd4018fcf 右键改回 Cesium 原生 tilt,移除自定义环视逻辑 2026-09-19 16:06:41 +08:00
fukunee 114cf32597 右键按下时取消左键惯性余力,避免叠加旋转 2026-09-19 16:01:25 +08:00
fukunee 57747e38c7 恢复北朝上 roll 计算(关闭后确实又转又倾斜) 2026-09-19 15:50:04 +08:00
fukunee 60988c5df8 关闭北朝上限制(roll 固定 0)供对比测试 2026-09-19 15:47:27 +08:00
fukunee 13f8023621 右键环视:计算北朝上所需的 roll,消除倾斜时的附加旋转 2026-09-19 15:45:54 +08:00
fukunee 34e6ce6d5c 恢复左键旋转的惯性(此前误关,非问题根源) 2026-09-19 15:39:53 +08:00
fukunee b0e68f3f32 恢复绕注视点轨道倾倒,用 ENU 直接求 heading/pitch 修复近天底奇点 2026-09-19 15:31:42 +08:00
fukunee 91999e25e5 右键环视改为原地倾倒(直接算 heading/pitch,修复近天底奇点翻转),并关闭相机惯性 2026-09-19 15:21:27 +08:00
fukunee 0cb6e46b92 右键环视球心改为地形表面点,统一视觉中心与旋转中心 2026-09-19 15:05:31 +08:00
fukunee 2dae2d2ff2 对调右键环视两轴:左右=经度方向,上下=纬度方向 2026-09-19 14:59:57 +08:00
fukunee 7a99756fc6 右键环视:左右=纬度方向倾倒,上下=经度方向倾倒,强制北朝上 2026-09-19 14:58:04 +08:00
fukunee 0357bb0fb1 右键环视:up 随方向一起旋转(保留 roll),修复左右拽异常 2026-09-19 14:52:45 +08:00
fukunee ccd3efde17 右键环视旋转中心改为屏幕中心点,消除按下即瞬移 2026-09-19 14:49:54 +08:00
fukunee 66fac3771d 右键环视改为手动绕点击点旋转,精确限制 ±40°/±20°,松开归位 2026-09-19 14:45:22 +08:00
fukunee e8745fa45f 右键环视限制:左右旋转 ±40°、上下倾角 ±20°,松开归位 2026-09-19 14:40:00 +08:00
fukunee 1126f9a79e 右键 tilt 松开后自动归位(记录/恢复相机状态) 2026-09-19 14:36:37 +08:00
fukunee 0dd279f2b5 修复:ImageryLayer rectangle 用构造参数设置 2026-09-19 14:33:07 +08:00
fukunee 67ad4f29af 限制卫星影像到 ±85°,极区改用冰盖底色解决黑洞 2026-09-19 14:32:01 +08:00
fukunee 018d5787da 南北极未覆盖区域改为冰盖浅色,解决黑洞 2026-09-19 14:30:01 +08:00
fukunee 5ab9bce49d 右键环视改用 Cesium 原生 tilt 行为,移除手写实现 2026-09-19 14:27:37 +08:00
fukunee 656f535467 修复右键环视:RIGHT_DRAG 不存在,改用 MOUSE_MOVE 2026-09-19 14:25:29 +08:00
fukunee 3bbfedeefa 地表附近支持右键环视(以点击点为球心,松开归位) 2026-09-19 14:23:31 +08:00
fukunee 0715b38b81 地理视图等瓦片加载完再淡入,避免逐块弹出 2026-09-19 14:20:37 +08:00
fukunee 86ee03f4a6 重写选中便签的黑边为干净的外描边 2026-09-19 14:13:25 +08:00
fukunee b94abe32fa 性能优化:精简便签每帧计算、地理视图跳过 Three.js 每帧逻辑 2026-09-19 14:02:47 +08:00
fukunee 37b7923849 标记便签移除距离控制,仅保留视野内判断 2026-09-19 13:32:42 +08:00
fukunee 199ef8f892 标记便签改用 postUpdate 更新,修复拖拽时闪烁/消失 2026-09-19 13:31:22 +08:00
fukunee dddf622d95 标记便签在地平线附近渐变淡出,修复拖拽闪烁 2026-09-19 13:27:59 +08:00
fukunee b33ffd3b2d 清理 CAMERA_POSES 里无效的 globe 相机配置 2026-09-19 13:25:54 +08:00
fukunee 83de329750 Add stress test generator for 1000 synthetic entries
Adds scripts/gen_entries_stress.py to produce a large synthetic
dataset for performance testing, and notes the texture lazy-loading
plan in the README.
2026-09-19 04:26:55 +08:00
fukunee f4ef046c9a README 增加 TODO:活页本分册、时间轴时期划分、词条结构与排版优化 2026-09-19 04:23:44 +08:00
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
19 changed files with 2298 additions and 606 deletions
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/ dist/
*.log *.log
.DS_Store .DS_Store
__pycache__/
+78 -17
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,10 +111,11 @@ curl -s -o /dev/null -w "%{http_code}\n" https://hist.fukun.net/
## 操作 ## 操作
- **拖动** 旋转视角,**滚轮** 缩放 - **活页本**:拖动旋转、滚轮缩放;点击书页右侧向前/左侧向后(松开触发);**← →** / 底部按钮翻页;第一页再向后 / 最后一页再向前回卷到 END 页
- **← →** 或底部按钮 翻页(翻面查看背面素描) - **时间轴**:拖拽左右滑动、滚轮缩放;点击卡片 / **← →** / 底部按钮 选中并居中 + 翻面
- 点击 活页 / 时间轴卡片 / 地球标记 选中词条 - **地理**:左键拖拽旋转、滚轮缩放(右键 tilt、中键回正);点击标记选中
- 顶部按钮或按键 **1 / 2 / 3** 切换 活页本 / 时间轴 / 地理 视图 - 底部导航栏按钮(或按键 **1 / 2 / 3**)切换 活页本 / 时间轴 / 地理 视图
- 右下角登记卡在各视图间保持显示
## 目录结构 ## 目录结构
@@ -81,14 +123,33 @@ curl -s -o /dev/null -w "%{http_code}\n" https://hist.fukun.net/
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 / 后续规划
- [ ] **国家/城市名贴地(参考便签)**:当前国家名、城市名标签在地形起伏处“钉地”不准(`CLAMP_TO_GROUND` 表现不理想)。需参考词条便签的做法,用 `Cesium.sampleTerrain` 显式采样地形高度后设置标签锚点高度,使其真正贴合地形表面。
- [x] **纹理懒加载**:已完成(见「性能优化」——可见窗口按需生成 + 引用计数/LRU 释放)。
- [ ] **活页本分册**:为全部词条增加分册(分卷)机制——按历史时期或主题分组,顶部提供分册导航与切换,活页本只展示当前分册的内容,降低单册厚度、便于浏览。
- [ ] **时间轴时期划分**:时间轴轴线上标注中西方历史时期刻度(如中国朝代:夏商周 / 秦汉 / 隋唐 / 宋元明清;西方:古典时代 / 中世纪 / 文艺复兴 / 启蒙运动 / 工业革命等),作为背景衬托。
- [ ] **词条插入时间轴**:词条按其 `timeNumeric` 真实时间位置插入时间轴(当前为等距排列),使时期刻度与词条位置相互对应。
- [ ] **词条结构考究与排版优化**:重新审视 `entries.js` 数据模型(补充年代精度、文明圈、影响、关键词等字段),并优化登记卡正面排版(信息层级、留白、装饰细节)。
- [ ] **性能进一步优化**:继续排查渲染 / 显存 / 加载瓶颈(已做:纹理懒加载、按需渲染、几何降级、双面材质、gzip 等)。
- [x] **首尾边界回卷**:活页本第一页 / 最后一页 / END 页边界已处理,首尾闭环。
- [x] **渲染清晰度**:像素比上限 2 + MSAA / FXAA 抗锯齿,三视图不再发糊。
+10 -12
View File
@@ -30,17 +30,17 @@
<!-- 底部:导航 --> <!-- 底部:导航 -->
<footer class="hud bottom-bar"> <footer class="hud bottom-bar">
<button class="nav-btn" id="btn-prev" title="上一页 (←)">◀</button> <button class="nav-btn" id="btn-prev" aria-label="上一页">◀</button>
<div class="page-indicator"> <div class="page-indicator">
<span id="page-num">01</span><span class="sep">/</span><span id="page-total">20</span> <span id="page-num">01</span><span class="sep">/</span><span id="page-total">20</span>
<span class="page-name" id="page-name">露西 · 南方古猿</span> <span class="page-name" id="page-name">露西 · 南方古猿</span>
</div> </div>
<button class="nav-btn" id="btn-next" title="下一页 (→)">▶</button> <button class="nav-btn" id="btn-next" aria-label="下一页">▶</button>
<div class="view-switch" aria-label="视图切换"> <button class="view-btn" id="btn-view" data-view="binder" aria-label="切换视图">
<button class="view-btn active" data-view="binder">活页本</button> <svg class="v-icon v-binder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="1.5"/><line x1="8" y1="3" x2="8" y2="21"/><circle cx="11.5" cy="8" r="0.6" fill="currentColor"/><circle cx="11.5" cy="12" r="0.6" fill="currentColor"/><circle cx="11.5" cy="16" r="0.6" fill="currentColor"/></svg>
<button class="view-btn" data-view="timeline">时间轴</button> <svg class="v-icon v-timeline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg>
<button class="view-btn" data-view="globe" disabled title="地理视图加载中">地理</button> <svg class="v-icon v-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><line x1="3" y1="12" x2="21" y2="12"/></svg>
</div> </button>
</footer> </footer>
<!-- 右侧:登记卡详情 --> <!-- 右侧:登记卡详情 -->
@@ -63,12 +63,10 @@
<div class="hud hint" id="hint">拖动旋转 · 滚轮缩放 · 时间轴视图拖拽左右滑动 · 点击选中 · ← → 翻页</div> <div class="hud hint" id="hint">拖动旋转 · 滚轮缩放 · 时间轴视图拖拽左右滑动 · 点击选中 · ← → 翻页</div>
</div> </div>
<!-- 加载界面 --> <!-- 加载界面:OS1 3D 加载环 -->
<div id="loading" aria-hidden="true"> <div id="loading" aria-hidden="true">
<div class="loading-box"> <div id="loading-canvas"></div>
<div class="spinner"></div> <div class="loading-text">加载中,约等待六秒…</div>
<div class="loading-text">载入中…</div>
</div>
</div> </div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
+41
View File
@@ -5,6 +5,8 @@
import math import math
from region_codes import make_code
DATA = [ DATA = [
# ===================== 史前 ===================== # ===================== 史前 =====================
("~ BC 3,200,000", -3200000, "东非 · 埃塞俄比亚", "露西 · 南方古猿", "人", "fossil", 11.2, 40.5, "1974年在埃塞俄比亚哈达尔发现的一具约40%完整的南方古猿阿法种骨架,是直立行走的最早证据之一,被亲切地称为“人类祖母”。"), ("~ BC 3,200,000", -3200000, "东非 · 埃塞俄比亚", "露西 · 南方古猿", "人", "fossil", 11.2, 40.5, "1974年在埃塞俄比亚哈达尔发现的一具约40%完整的南方古猿阿法种骨架,是直立行走的最早证据之一,被亲切地称为“人类祖母”。"),
@@ -360,6 +362,44 @@ def dedupe(entries, km=DEDUP_KM):
return out return out
DEDUPED = dedupe(DATA) DEDUPED = dedupe(DATA)
# —— 素描多样分配:同类主题轮换到不同画风,避免同一张素描重复出现 ——
SKETCH_POOLS = {
'sword': ['sword', 'helmet', 'cannon', 'fortress', 'bow', 'shield'],
'crown': ['crown', 'throne', 'scepter', 'palace', 'laurel'],
'machine': ['machine', 'gear', 'anvil', 'locomotive', 'factory'],
'atom': ['atom', 'microscope', 'telescope', 'flask', 'lightbulb'],
'scale': ['scale', 'gavel', 'columns', 'document'],
'ship': ['ship', 'anchor', 'compass', 'globe'],
'fossil': ['fossil', 'dinosaur', 'mountain'],
'tablet': ['tablet', 'coin', 'document', 'hourglass'],
'scroll': ['scroll', 'book', 'document', 'lyre'],
'temple': ['temple', 'columns', 'palace', 'pyramid'],
'colosseum': ['colosseum', 'fortress', 'helmet'],
'figure': ['figure', 'mask', 'lyre'],
'flag': ['flag', 'laurel', 'mountain', 'dove'],
'bell': ['bell', 'gavel', 'document'],
'rocket': ['rocket', 'telescope', 'lightbulb', 'compass'],
'pyramid': ['pyramid', 'temple', 'palace', 'mountain'],
'apple': ['apple', 'flask', 'tree', 'flower'],
'book': ['book', 'scroll', 'document', 'key'],
'tree': ['tree', 'flower', 'dinosaur'],
'dove': ['dove', 'laurel', 'flag'],
'globe': ['globe', 'compass', 'anchor', 'ship'],
'horseman': ['horseman', 'bow', 'fortress', 'helmet'],
}
_pool_idx = {}
def _reassign(e):
pool = SKETCH_POOLS.get(e[5])
if not pool:
return e
k = e[5]
idx = _pool_idx.get(k, 0)
s = pool[idx % len(pool)]
_pool_idx[k] = idx + 1
return (e[0], e[1], e[2], e[3], e[4], s, e[6], e[7], e[8])
DEDUPED = [_reassign(e) for e in DEDUPED]
print(f"坐标去重:{len(DATA)} -> {len(DEDUPED)} 条(阈值 {DEDUP_KM} km)") print(f"坐标去重:{len(DATA)} -> {len(DEDUPED)} 条(阈值 {DEDUP_KM} km)")
def esc(s): def esc(s):
@@ -397,6 +437,7 @@ lines = ["// 世界历史里程碑条目数据",
for i, (time, tn, region, name, cat, sketch, lat, lng, desc) in enumerate(DEDUPED, 1): for i, (time, tn, region, name, cat, sketch, lat, lng, desc) in enumerate(DEDUPED, 1):
lines.append(" {") lines.append(" {")
lines.append(f" id: 'No.{i:03d}',") lines.append(f" id: 'No.{i:03d}',")
lines.append(f" code: '{esc(make_code(tn, region, lat, lng, i, name, cat))}',")
lines.append(f" time: '{esc(normalize_time(time, tn))}',") lines.append(f" time: '{esc(normalize_time(time, tn))}',")
lines.append(f" timeNumeric: {tn},") lines.append(f" timeNumeric: {tn},")
lines.append(f" region: '{esc(region)}',") lines.append(f" region: '{esc(region)}',")
+117
View File
@@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""生成 1000 条合成词条,用于压力测试。
与 gen_entries.py 互斥:两者都写 src/data/entries.js,跑哪个就用哪份数据。
切换回真实数据: python scripts/gen_entries.py
"""
import random
import math
from region_codes import make_code
N = 1000
random.seed(20240101)
CATS = ['人', '事', '物']
SKETCHES = [
'fossil', 'tablet', 'pyramid', 'figure', 'temple', 'crown', 'colosseum', 'scroll',
'horseman', 'ship', 'globe', 'apple', 'machine', 'bell', 'tree', 'atom', 'dove', 'rocket',
'book', 'bridge', 'flag', 'scale', 'sword',
]
# 世界主要城市坐标池:制造既分散又带聚集的分布(考验重叠与覆盖)
CITY_POOL = [
(39.9, 116.4), (31.2, 121.5), (35.7, 139.7), (37.6, 127.0), (22.3, 114.2),
(13.8, 100.5), (1.35, 103.8), (-6.2, 106.8), (21.0, 105.8), (28.6, 77.2),
(19.1, 72.9), (23.8, 90.4), (35.7, 51.4), (33.3, 44.4), (24.7, 46.7),
(41.0, 29.0), (31.8, 35.2), (30.0, 31.2), (36.8, 3.1), (33.6, -7.6),
(6.5, 3.4), (9.0, 38.7), (-1.3, 36.8), (-26.2, 28.0), (-33.9, 18.4),
(51.5, -0.1), (48.9, 2.4), (52.5, 13.4), (41.9, 12.5), (40.4, -3.7),
(38.7, -9.1), (52.4, 4.9), (50.9, 4.4), (48.2, 16.4), (55.8, 37.6),
(59.3, 18.1), (50.5, 30.5), (42.7, 23.3), (40.7, -74.0), (34.1, -118.2),
(41.9, -87.6), (37.8, -122.4), (43.7, -79.4), (19.4, -99.1), (-23.6, -46.6),
(-22.9, -43.2), (-34.6, -58.4), (-33.4, -70.7), (-12.0, -77.0), (4.7, -74.1),
(-33.9, 151.2), (-37.8, 145.0), (-36.8, 174.8),
]
def fmt_time(tn):
if tn < 0:
a = abs(tn)
if a >= 1000000:
return f"~ BC {a:,}"
if a >= 10000:
return f"~{a:,} BC"
return f"~{a} BC"
if tn >= 10000:
return f"AD {tn:,}"
return f"AD {tn}"
def make_year(i):
# 前 400 条:公元前(对数分布,远古稀疏、近古密集)
# 后 600 条:公元后(线性分布 1..2022)
if i < 400:
u = i / 399.0 # 0..1
return -int(round(10 ** (u * math.log10(3.2e6))))
u = (i - 400) / 599.0 # 0..1
return int(round(1 + u * 2021))
def esc(s):
return s.replace("\\", "\\\\").replace("'", "\\'")
entries = []
for i in range(N):
tn = make_year(i)
cat = CATS[i % 3]
sketch = SKETCHES[i % len(SKETCHES)]
# 坐标:60% 落在城市池附近(带抖动),40% 均匀随机,制造聚集 + 分散
if random.random() < 0.6:
base = random.choice(CITY_POOL)
lat = round(base[0] + random.uniform(-0.4, 0.4), 5)
lng = round(base[1] + random.uniform(-0.4, 0.4), 5)
else:
lat = round(random.uniform(-55, 70), 5)
lng = round(random.uniform(-180, 180), 5)
region = f"压力测试区 {i + 1:03d}"
name = f"词条 {i + 1:03d}"
desc = "这是一条用于压力测试的合成词条,用来检验在大量数据下三维翻页、时间轴与地理视图的渲染和交互性能表现。"
entries.append((fmt_time(tn), tn, region, name, cat, sketch, lat, lng, desc))
# 按年份升序排列,保证时间轴(按列表顺序均布)与年代一致
entries.sort(key=lambda e: e[1])
lines = ["// 压力测试用合成词条数据(1000 条)",
"// timeNumeric 用于时间轴排序/定位(公元前为负,公元后为正,单位:年)",
"",
"export const CATEGORY_INFO = {",
" 人: { color: '#a03a2a', en: 'PERSONA' },",
" 事: { color: '#2c4a6b', en: 'EVENTUS' },",
" 物: { color: '#3f5c3a', en: 'ARTIFEX' },",
"};",
"",
"export const ENTRIES = ["]
for i, (time, tn, region, name, cat, sketch, lat, lng, desc) in enumerate(entries, 1):
lines.append(" {")
lines.append(f" id: 'No.{i:03d}',")
lines.append(f" code: '{esc(make_code(tn, region, lat, lng, i, name, cat))}',")
lines.append(f" time: '{esc(time)}',")
lines.append(f" timeNumeric: {tn},")
lines.append(f" region: '{esc(region)}',")
lines.append(f" name: '{esc(name)}',")
lines.append(f" category: '{esc(cat)}',")
lines.append(f" sketch: '{esc(sketch)}',")
lines.append(f" lat: {lat}, lng: {lng},")
lines.append(f" description: '{esc(desc)}',")
lines.append(" },")
lines.append("];")
lines.append("")
lines.append("// 时间轴横向定位:对称对数映射,远古压缩、近代展开")
lines.append("export function timelineX(t) {")
lines.append(" const sign = t >= 0 ? 1 : -1;")
lines.append(" const mag = Math.log10(1 + Math.abs(t) / 50);")
lines.append(" return sign * mag * 1.15;")
lines.append("}")
with open("src/data/entries.js", "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
print(f"written entries.js with {len(entries)} entries (stress)")
+316
View File
@@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
"""地区编码:把 region 字符串映射成「大洲码 + 国家/地区码 + 方位码」,
用于生成编目号(如 AFETNE = 非洲 / 埃塞俄比亚 / 东北部)。
"""
import json
import math
# region -> (大洲码, 国家/地区码, 现代国名[用于查标注点], 固定方位或 None)
# 大洲码:AF 非洲 AS 亚洲 EU 欧洲 NA 北美 SA 南美 OC 大洋洲 AN 南极 GL 全球/海域
REGION_CC = {
# —— 非洲 ——
"东非 · 肯尼亚": ("AF", "KE", "肯尼亚", None),
"东非 · 埃塞俄比亚": ("AF", "ET", "埃塞俄比亚", None),
"东非 · 坦桑尼亚": ("AF", "TZ", "坦桑尼亚", None),
"北非 · 摩洛哥": ("AF", "MA", "摩洛哥", None),
"古埃及 · 孟菲斯": ("AF", "EG", "埃及", None),
"古埃及 · 阿拜多斯": ("AF", "EG", "埃及", None),
"古埃及 · 吉萨": ("AF", "EG", "埃及", None),
"古埃及 · 阿玛纳": ("AF", "EG", "埃及", None),
"古埃及 · 亚历山大港": ("AF", "EG", "埃及", None),
"北非 · 迦太基": ("AF", "TN", "突尼斯", None),
"葡萄牙 · 休达": ("AF", "MA", "摩洛哥", None),
"埃及 · 苏伊士": ("AF", "EG", "埃及", None),
"南非 · 开普敦": ("AF", "ZA", "南非", None),
"南非 · 比勒陀利亚": ("AF", "ZA", "南非", None),
# —— 亚洲 ——
"东亚 · 中国周口店": ("AS", "CN", "中国", None),
"西亚 · 以色列": ("AS", "IL", "以色列", None),
"西亚 · 伊拉克": ("AS", "IQ", "伊拉克", None),
"日本 · 本州": ("AS", "JP", "日本", None),
"西亚 · 两河流域": ("AS", "IQ", "伊拉克", None),
"南亚 · 印度河谷": ("AS", "PK", "巴基斯坦", None),
"美索不达米亚 · 两河流域": ("AS", "IQ", "伊拉克", None),
"美索不达米亚 · 乌尔": ("AS", "IQ", "伊拉克", None),
"美索不达米亚 · 巴比伦": ("AS", "IQ", "伊拉克", None),
"中国 · 河南安阳": ("AS", "CN", "中国", None),
"古印度 · 恒河流域": ("AS", "IN", "印度", None),
"西亚 · 安纳托利亚": ("AS", "TR", "土耳其", None),
"地中海 · 东岸": ("AS", "LB", "黎巴嫩", None),
"中国 · 咸阳": ("AS", "CN", "中国", None),
"古印度 · 蓝毗尼": ("AS", "NP", "尼泊尔", None),
"中国 · 鲁国曲阜": ("AS", "CN", "中国", None),
"古印度 · 华氏城": ("AS", "IN", "印度", None),
"中国 · 北方边塞": ("AS", "CN", "中国", None),
"中国 · 陕西西安": ("AS", "CN", "中国", None),
"中国 · 陕西汉中": ("AS", "CN", "中国", None),
"中国 · 四川": ("AS", "CN", "中国", None),
"犹太 · 伯利恒": ("AS", "PS", "巴勒斯坦", None),
"中国 · 东汉洛阳": ("AS", "CN", "中国", None),
"拜占庭 · 君士坦丁堡": ("AS", "TR", "土耳其", None),
"阿拉伯半岛 · 麦加": ("AS", "SA", "沙特阿拉伯", None),
"中国 · 建康": ("AS", "CN", "中国", None),
"阿拉伯半岛 · 麦地那": ("AS", "SA", "沙特阿拉伯", None),
"波斯 · 萨珊": ("AS", "IR", "伊朗", None),
"朝鲜半岛 · 百济": ("AS", "KR", "韩国", None),
"中亚 · 怛罗斯": ("AS", "KZ", "哈萨克斯坦", None),
"日本 · 京都": ("AS", "JP", "日本", None),
"中国 · 敦煌": ("AS", "CN", "中国", None),
"中国 · 开封": ("AS", "CN", "中国", None),
"中国 · 北宋": ("AS", "CN", "中国", None),
"柬埔寨 · 吴哥": ("AS", "KH", "柬埔寨", None),
"蒙古高原": ("AS", "MN", "蒙古", None),
"中国 · 大都": ("AS", "CN", "中国", None),
"印度 · 卡利卡特": ("AS", "IN", "印度", None),
"印度 · 帕尼帕特": ("AS", "IN", "印度", None),
"日本 · 江户": ("AS", "JP", "日本", None),
"中国 · 广东虎门": ("AS", "CN", "中国", None),
"中国 · 武昌": ("AS", "CN", "中国", None),
"中国 · 沈阳": ("AS", "CN", "中国", None),
"中国 · 江西": ("AS", "CN", "中国", None),
"日本 · 广岛": ("AS", "JP", "日本", None),
"印度 · 新德里": ("AS", "IN", "印度", None),
"以色列 · 特拉维夫": ("AS", "IL", "以色列", None),
"朝鲜半岛": ("AS", "KP", "朝鲜", None),
"尼泊尔 · 珠峰": ("AS", "NP", "尼泊尔", None),
"印度尼西亚 · 万隆": ("AS", "ID", "印度尼西亚", None),
"苏联 · 拜科努尔": ("AS", "KZ", "哈萨克斯坦", None),
"中国 · 罗布泊": ("AS", "CN", "中国", None),
"美国 · 越南": ("AS", "VN", "越南", None),
"中东 · 埃及叙利亚": ("AS", "EG", "埃及", None),
"伊朗 · 德黑兰": ("AS", "IR", "伊朗", None),
"中国 · 香港": ("AS", "CN", "中国", None),
"中国 · 澳门": ("AS", "CN", "中国", None),
"伊拉克 · 巴格达": ("AS", "IQ", "伊拉克", None),
"中国 · 酒泉": ("AS", "CN", "中国", None),
"中国 · 文昌": ("AS", "CN", "中国", None),
# —— 欧洲 ——
"欧洲 · 德国": ("EU", "DE", "德国", None),
"欧洲 · 奥地利": ("EU", "AT", "奥地利", None),
"欧洲 · 法国": ("EU", "FR", "法国", None),
"不列颠 · 威尔特郡": ("EU", "GB", "英国", None),
"爱琴海 · 克里特岛": ("EU", "GR", "希腊", None),
"古希腊 · 奥林匹亚": ("EU", "GR", "希腊", None),
"罗马帝国 · 罗马": ("EU", "IT", "意大利", None),
"古希腊 · 爱奥尼亚": ("EU", "GR", "希腊", None),
"古希腊 · 雅典": ("EU", "GR", "希腊", None),
"古希腊 · 马拉松": ("EU", "GR", "希腊", None),
"古希腊 · 温泉关": ("EU", "GR", "希腊", None),
"古希腊 · 斯塔基拉": ("EU", "GR", "希腊", None),
"马其顿 · 希腊半岛": ("EU", "GR", "希腊", None),
"古希腊 · 叙拉古": ("EU", "IT", "意大利", None),
"古罗马 · 卡普阿": ("EU", "IT", "意大利", None),
"古罗马 · 庞贝": ("EU", "IT", "意大利", None),
"古罗马 · 米兰": ("EU", "IT", "意大利", None),
"罗马帝国 · 塞萨洛尼基": ("EU", "GR", "希腊", None),
"西罗马 · 拉文纳": ("EU", "IT", "意大利", None),
"伊比利亚 · 直布罗陀": ("EU", "GB", "英国", None),
"法兰克 · 图尔": ("EU", "FR", "法国", None),
"法兰克 · 亚琛": ("EU", "DE", "德国", None),
"法兰克 · 凡尔登": ("EU", "FR", "法国", None),
"英格兰 · 黑斯廷斯": ("EU", "GB", "英国", None),
"意大利 · 博洛尼亚": ("EU", "IT", "意大利", None),
"西欧 · 克莱蒙": ("EU", "FR", "法国", None),
"法国 · 巴黎": ("EU", "FR", "法国", None),
"英格兰 · 兰尼米德": ("EU", "GB", "英国", None),
"意大利 · 佛罗伦萨": ("EU", "IT", "意大利", None),
"欧洲 · 英法": ("EU", "FR", "法国", None),
"欧洲 · 意大利": ("EU", "IT", "意大利", None),
"法国 · 鲁昂": ("EU", "FR", "法国", None),
"德意志 · 美因茨": ("EU", "DE", "德国", None),
"西班牙 · 格拉纳达": ("EU", "ES", "西班牙", None),
"德意志 · 维滕贝格": ("EU", "DE", "德国", None),
"波兰 · 弗龙堡": ("EU", "PL", "波兰", None),
"德意志 · 奥格斯堡": ("EU", "DE", "德国", None),
"英吉利海峡": ("EU", "GB", None, "CH"),
"英国 · 伦敦": ("EU", "GB", "英国", None),
"西班牙 · 马德里": ("EU", "ES", "西班牙", None),
"意大利 · 帕多瓦": ("EU", "IT", "意大利", None),
"欧洲 · 威斯特伐利亚": ("EU", "DE", "德国", None),
"英国 · 剑桥": ("EU", "GB", "英国", None),
"法国 · 凡尔赛": ("EU", "FR", "法国", None),
"瑞典 · 乌普萨拉": ("EU", "SE", "瑞典", None),
"欧洲 · 英格兰": ("EU", "GB", "英国", None),
"英国 · 格拉斯哥": ("EU", "GB", "英国", None),
"英格兰 · 伯克利": ("EU", "GB", "英国", None),
"俄国 · 莫斯科": ("EU", "RU", "俄罗斯", None),
"比利时 · 滑铁卢": ("EU", "BE", "比利时", None),
"英格兰 · 斯托克顿": ("EU", "GB", "英国", None),
"克里米亚 · 塞瓦斯托波尔": ("EU", "UA", "乌克兰", None),
"意大利 · 都灵": ("EU", "IT", "意大利", None),
"瑞典 · 斯德哥尔摩": ("EU", "SE", "瑞典", None),
"俄国 · 圣彼得堡": ("EU", "RU", "俄罗斯", None),
"德国 · 柏林": ("EU", "DE", "德国", None),
"德国 · 曼海姆": ("EU", "DE", "德国", None),
"德国 · 维尔茨堡": ("EU", "DE", "德国", None),
"瑞士 · 伯尔尼": ("EU", "CH", "瑞士", None),
"欧洲 · 萨拉热窝": ("EU", "BA", "波斯尼亚", None),
"欧洲 · 康边": ("EU", "FR", "法国", None),
"瑞士 · 日内瓦": ("EU", "CH", "瑞士", None),
"欧洲 · 波兰": ("EU", "PL", "波兰", None),
"苏联 · 斯大林格勒": ("EU", "RU", "俄罗斯", None),
"法国 · 诺曼底": ("EU", "FR", "法国", None),
"欧洲 · 布鲁塞尔": ("EU", "BE", "比利时", None),
"苏联 · 切尔诺贝利": ("EU", "UA", "乌克兰", None),
"欧洲 · 马斯特里赫特": ("EU", "NL", "荷兰", None),
"苏格兰 · 爱丁堡": ("EU", "GB", "英国", None),
"欧洲 · 法兰克福": ("EU", "DE", "德国", None),
# —— 北美 ——
"中南美 · 墨西哥湾": ("NA", "MX", "墨西哥", None),
"北美 · 普利茅斯": ("NA", "US", "美国", None),
"北美 · 费城": ("NA", "US", "美国", None),
"世界 · 纽约": ("NA", "US", "美国", None),
"海地 · 太子港": ("NA", "HT", "海地", None),
"美国 · 萨姆特堡": ("NA", "US", "美国", None),
"美国 · 葛底斯堡": ("NA", "US", "美国", None),
"美国 · 华盛顿": ("NA", "US", "美国", None),
"美国 · 波士顿": ("NA", "US", "美国", None),
"美国 · 门洛帕克": ("NA", "US", "美国", None),
"美国 · 基蒂霍克": ("NA", "US", "美国", None),
"美国 · 纽约": ("NA", "US", "美国", None),
"美国 · 珍珠港": ("NA", "US", "美国", None),
"美国 · 芝加哥": ("NA", "US", "美国", None),
"美国 · 新墨西哥": ("NA", "US", "美国", None),
"古巴 · 哈瓦那": ("NA", "CU", "古巴", None),
"古巴 · 加勒比": ("NA", "CU", "古巴", None),
"美国 · 洛杉矶": ("NA", "US", "美国", None),
"美国 · 西雅图": ("NA", "US", "美国", None),
"美国 · 库比蒂诺": ("NA", "US", "美国", None),
"美国 · 加利福尼亚": ("NA", "US", "美国", None),
"美国 · 旧金山": ("NA", "US", "美国", None),
"美国 · 卡纳维拉尔角": ("NA", "US", "美国", None),
# —— 南美 ——
"南美 · 秘鲁": ("SA", "PE", "秘鲁", None),
"拉丁美洲 · 委内瑞拉": ("SA", "VE", "委内瑞拉", None),
# —— 大洋洲 ——
"澳大利亚 · 植物学湾": ("OC", "AU", "澳大利亚", None),
# —— 跨区域 / 海域 / 极地 ——
"北欧 · 格陵兰": ("NA", "GL", "格陵兰", None),
"西班牙 → 美洲": ("NA", "ES", None, "AM"),
"大西洋 · 托德西利亚斯": ("GL", "PT", None, "TR"),
"欧洲 → 环球": ("GL", "GL", None, "GB"),
"大西洋": ("GL", "AT", None, "OC"),
"挪威 · 南极": ("AN", "AQ", None, "SP"),
}
# 从 Natural Earth 国家数据读取标注点(按 ISO_A2 编码,用于计算条目在国家内的方位)
COUNTRY_LABEL = {}
try:
with open("public/world-countries.geojson", encoding="utf-8") as _f:
_gj = json.load(_f)
for _ft in _gj.get("features", []):
_p = _ft.get("properties", {})
_iso = _p.get("ISO_A2")
if _iso and _iso != "-99" and _p.get("LABEL_X") is not None and _p.get("LABEL_Y") is not None:
COUNTRY_LABEL[_iso.upper()] = (_p["LABEL_X"], _p["LABEL_Y"])
except Exception as _e:
print(f"警告:读取国家标注点失败,方位将回退为 XX:{_e}")
def _dir(lat, lng, cx, cy):
dlat = lat - cy
dlng = lng - cx
ns = "N" if dlat > 0.5 else ("S" if dlat < -0.5 else "")
ew = "E" if dlng > 0.5 else ("W" if dlng < -0.5 else "")
if not ns and not ew:
return "CE"
if not ns:
return "EA" if ew == "E" else "WE"
if not ew:
return "NO" if ns == "N" else "SO"
return ns + ew
# 条目类型枚举:E 事件/战役 P 人物/政党 T 学说/技术 I 文物/艺术品 B 建筑/奇观
TYPE_OVERRIDE = {
# 化石标本(人 → 文物)
"露西 · 南方古猿": "I",
"杰贝尔依罗智人化石": "I",
# 建筑/奇观(物 → B)
"哥贝克力石阵": "B",
"加泰土丘聚落": "B",
"胡夫金字塔": "B",
"巨石阵": "B",
"乌尔金字形神塔": "B",
"米诺斯宫殿": "B",
"罗马斗兽场": "B",
"雅典卫城 · 帕特农神庙": "B",
"万里长城": "B",
"吴哥窟": "B",
"巴黎圣母院": "B",
"亚历山大图书馆": "B",
"博洛尼亚大学": "B",
"苏伊士运河通航": "B",
# 学说/技术(物 → T)
"赫梯铁器": "T",
"造纸术萌芽": "T",
"毕昇活字印刷": "T",
"古腾堡印刷术": "T",
"伽利略天文望远镜": "T",
"哥白尼日心说": "T",
"林奈分类法": "T",
"门捷列夫元素周期表": "T",
"瓦特改良蒸汽机": "T",
"第一条铁路": "T",
"诺贝尔炸药": "T",
"电话发明": "T",
"爱迪生白炽灯": "T",
"本茨汽车": "T",
"莱特兄弟首飞": "T",
"福特T型车": "T",
"晶体管发明": "T",
"斯普特尼克1号": "T",
"iPhone发布": "T",
# 学说/技术(事 → T)
"蔡伦造纸": "T",
"达尔文《物种起源》": "T",
"牛顿《自然哲学的数学原理》": "T",
"爱因斯坦狭义相对论": "T",
"互联网诞生": "T",
"科赫发现结核杆菌": "T",
"伦琴发现X射线": "T",
"琴纳牛痘接种": "T",
"首例心脏移植": "T",
"克隆羊多利": "T",
}
def classify(name, category):
if name in TYPE_OVERRIDE:
return TYPE_OVERRIDE[name]
if category == "人":
return "P"
if category == "事":
return "E"
return "I" # 物(默认文物/艺术品)
def _time_seg(tn):
age = abs(tn)
if age < 10000:
if tn < 0:
return "BC" + f"{math.ceil(age / 100):02d}"
return "AD" + f"{(tn - 1) // 100 + 1:02d}"
if age < 100000:
return "BCE1"
if age < 1000000:
return "BCE2"
if age < 10000000:
return "BCE3"
return "BCE4"
def make_code(tn, region, lat, lng, idx, name, category):
"""生成编目号,如 E3-AFETNE-I0001。"""
t = _time_seg(tn)
# 地区段:大洲 + 国家/地区 + 方位
cont, iso, zh, fixed = REGION_CC.get(region, ("GL", "XX", None, "XX"))
if fixed:
sub = fixed
elif iso.upper() in COUNTRY_LABEL:
cx, cy = COUNTRY_LABEL[iso.upper()]
sub = _dir(lat, lng, cx, cy)
else:
sub = "XX"
prefix = classify(name, category)
return f"{t}-{cont}{iso}{sub}-{prefix}{idx:04d}"
+27 -49
View File
@@ -3,69 +3,47 @@ import puppeteer from 'puppeteer-core';
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const URL = process.argv[2] || 'http://localhost:5199/'; const URL = process.argv[2] || 'http://localhost:5199/';
// 轻量冒烟测试:不做完整渲染流程 / 不触发 Cesium,
// 只检查「加载无 JS 报错 + loading 环出现 + 6 秒后淡出 + App 就绪」。
// 用真实 GPU(不强制 swiftshader 软件渲染)。
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
executablePath: CHROME, executablePath: CHROME,
headless: 'new', headless: 'new',
args: ['--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--window-size=1440,900'], args: ['--no-sandbox', '--window-size=1440,900'],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 }); await page.setViewport({ width: 1440, height: 900 });
const errors = []; const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); page.on('console', (m) => {
// preview 模式无 HMR,WebSocket 连接失败是正常现象,过滤掉
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); if (m.type() === 'error' && !/WebSocket|ERR_CONNECTION/i.test(m.text())) {
await new Promise((r) => setTimeout(r, 6000)); errors.push('console: ' + m.text());
async function snapshot(label) {
const info = await page.evaluate(() => {
const app = window.__app;
const gl = app.renderer.getContext();
const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
const buf = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
let sum = 0, n = 0, bright = 0;
for (let i = 0; i < buf.length; i += 4) {
const lum = 0.299 * buf[i] + 0.587 * buf[i + 1] + 0.114 * buf[i + 2];
sum += lum; n++;
if (lum > 40) bright++;
} }
return {
view: app.view,
selected: app.selected,
binderCurrent: app.binder.current,
pages: app.binder.pages.length,
timelineCards: app.timeline.cards.length,
globeMarkers: app.globe.markers.length,
detailName: document.getElementById('detail-name')?.textContent,
detailNo: document.getElementById('detail-no')?.textContent,
avgLum: +(sum / n).toFixed(2),
brightPct: +((bright / n) * 100).toFixed(2),
};
}); });
console.log(label, JSON.stringify(info, null, 0));
}
await snapshot('binder '); await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.click('.view-btn[data-view="timeline"]'); // 阶段一:加载环应已出现且正在展示
await new Promise((r) => setTimeout(r, 2400)); await new Promise((r) => setTimeout(r, 2000));
await snapshot('timeline'); const loading = await page.evaluate(() => ({
hasCanvas: !!document.querySelector('#loading-canvas canvas'),
loadingShown: !document.getElementById('loading').classList.contains('hidden'),
appReady: !!window.__app,
}));
await page.click('.view-btn[data-view="globe"]'); // 阶段二:约 9 秒后,loading 应已淡出、App 应已就绪
await new Promise((r) => setTimeout(r, 2400)); await new Promise((r) => setTimeout(r, 7000));
await snapshot('globe '); const done = await page.evaluate(() => ({
loadingHidden: document.getElementById('loading').classList.contains('hidden'),
await page.click('.view-btn[data-view="binder"]'); appReady: !!window.__app,
await new Promise((r) => setTimeout(r, 2400)); view: window.__app?.view,
await page.click('#btn-next'); pages: window.__app?.binder?.pages?.length,
await new Promise((r) => setTimeout(r, 1500)); timelineCards: window.__app?.timeline?.cards?.length,
await snapshot('flipped '); }));
await page.click('#btn-prev');
await new Promise((r) => setTimeout(r, 1500));
await snapshot('back ');
console.log('阶段一(加载中):', JSON.stringify(loading));
console.log('阶段二(加载后):', JSON.stringify(done));
console.log('ERRORS:', errors.length ? errors.join('\n') : '(none)'); console.log('ERRORS:', errors.length ? errors.join('\n') : '(none)');
await browser.close(); await browser.close();
+86 -34
View File
@@ -7,12 +7,14 @@ import { CesiumGlobe } from '../scene/CesiumGlobe.js';
import { Overlay } from '../ui/overlay.js'; import { Overlay } from '../ui/overlay.js';
import { clamp, easeInOutCubic } from '../utils/easing.js'; import { clamp, easeInOutCubic } from '../utils/easing.js';
// Three.js 相机的初始位(仅活页本/时间轴;地理视图用 Cesium 自己的相机)
const CAMERA_POSES = { const CAMERA_POSES = {
binder: { pos: new THREE.Vector3(0.2, 0.75, 2.95), target: new THREE.Vector3(0, 0.02, 0), max: 4.4, min: 2.4 }, binder: { pos: new THREE.Vector3(0.2, 0.75, 2.95), target: new THREE.Vector3(0, 0.02, 0), max: 4.4, min: 2.4 },
timeline: { pos: new THREE.Vector3(0, 1.4, 6.86), target: new THREE.Vector3(0, 0, 0), max: 8, min: 1.2 }, timeline: { pos: new THREE.Vector3(0, 1.4, 6.86), target: new THREE.Vector3(0, 0, 0), max: 8, min: 1.2 },
globe: { pos: new THREE.Vector3(0, 0.35, 3.7), target: new THREE.Vector3(0, 0, 0), max: 8, min: 2.2 },
}; };
const VIEWS = ['binder', 'timeline', 'globe'];
export class App { export class App {
constructor() { constructor() {
this.view = 'binder'; this.view = 'binder';
@@ -29,8 +31,8 @@ export class App {
this.overlay = new Overlay({ this.overlay = new Overlay({
onView: (v) => this.switchView(v), onView: (v) => this.switchView(v),
onPrev: () => this._step(-1), onPrev: () => this._stepByView(-1),
onNext: () => this._step(1), onNext: () => this._stepByView(1),
onFlip: () => this._step(1), onFlip: () => this._step(1),
}); });
this.overlay.setTotal(ENTRIES.length); this.overlay.setTotal(ENTRIES.length);
@@ -52,19 +54,21 @@ export class App {
this.cameraTween = null; this.cameraTween = null;
this._animate = this._animate.bind(this); this._animate = this._animate.bind(this);
requestAnimationFrame(this._animate); requestAnimationFrame(this._animate);
// 首屏渲染后,分帧生成时间轴文字标签(避免阻塞首屏加载环)
requestAnimationFrame(() => requestAnimationFrame(() => this.timeline.initLabels()));
} }
// 首屏加载完成后由 main.js 调用:后台预载地理视图,就绪后激活“地理”按钮 // 首屏加载完成后由 main.js 调用:后台预载地理视图,就绪后允许循环切换到地理
preloadGlobe() { preloadGlobe() {
const btn = document.querySelector('.view-btn[data-view="globe"]'); this.globe.viewerReady.then(() => this.overlay.setGlobeReady(true));
this.globe.viewerReady.then(() => { if (btn) btn.disabled = false; });
this.globe.ensureCreated(); this.globe.ensureCreated();
} }
_initRenderer() { _initRenderer() {
const canvas = document.getElementById('scene'); const canvas = document.getElementById('scene');
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 恢复高分辨率渲染,避免糊
this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
@@ -116,16 +120,19 @@ export class App {
const rim = new THREE.DirectionalLight(0x8fa0d0, 0.7); const rim = new THREE.DirectionalLight(0x8fa0d0, 0.7);
rim.position.set(-2.5, 1.2, -3.5); rim.position.set(-2.5, 1.2, -3.5);
this.scene.add(rim); this.scene.add(rim);
this.rimLight = rim;
// 低角度掠射光:横扫皮面,凸显皮革纹理凹凸 // 低角度掠射光:横扫皮面,凸显皮革纹理凹凸
const graze = new THREE.DirectionalLight(0xffd9a8, 1.1); const graze = new THREE.DirectionalLight(0xffd9a8, 1.1);
graze.position.set(-4.5, 0.7, 2.6); graze.position.set(-4.5, 0.7, 2.6);
this.scene.add(graze); this.scene.add(graze);
this.grazeLight = graze;
// 背面补光:让外侧牛皮从背面看也可见 // 背面补光:让外侧牛皮从背面看也可见
const backLight = new THREE.DirectionalLight(0xffd9a8, 0.55); const backLight = new THREE.DirectionalLight(0xffd9a8, 0.55);
backLight.position.set(0.5, 1.2, -3.5); backLight.position.set(0.5, 1.2, -3.5);
this.scene.add(backLight); this.scene.add(backLight);
this.backLight = backLight;
} }
_initScene() { _initScene() {
@@ -206,10 +213,12 @@ export class App {
if (this.view === 'timeline') { if (this.view === 'timeline') {
// 时间轴:按下开始拖拽左右滑动;未移动的抬起才视为点击选中 // 时间轴:按下开始拖拽左右滑动;未移动的抬起才视为点击选中
this.timelineSlide = null; // 手动拖拽时取消居中动画 this.timelineSlide = null; // 手动拖拽时取消居中动画
this._drag = { active: true, startX: e.clientX, startY: e.clientY, moved: false, offset: this.timelineOffset }; this.timeline.setPausedGeneration(true); // 拖拽期间暂停纹理生成,保持流畅
this._drag = { active: true, view: 'timeline', startX: e.clientX, startY: e.clientY, moved: false, offset: this.timelineOffset };
canvas.setPointerCapture(e.pointerId); canvas.setPointerCapture(e.pointerId);
} else { } else if (this.view === 'binder') {
this._pick(); // 活页本:按下仅记录,松开且未拖拽(<4px)时才翻页
this._drag = { active: true, view: 'binder', startX: e.clientX, startY: e.clientY, moved: false };
} }
}); });
@@ -221,37 +230,47 @@ export class App {
if (!this._drag.moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) { if (!this._drag.moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
this._drag.moved = true; this._drag.moved = true;
} }
if (this._drag.moved) { if (this._drag.view === 'timeline' && this._drag.moved) {
const limit = TIMELINE_SPREAD + 0.6; const limit = TIMELINE_SPREAD + 0.6;
this.timelineOffset = clamp(this._drag.offset + dx * this._worldPerPixel(), -limit, limit); this.timelineOffset = clamp(this._drag.offset + dx * this._worldPerPixel(), -limit, limit);
this.timeline.group.position.x = this.timelineOffset; this.timeline.group.position.x = this.timelineOffset;
canvas.style.cursor = 'grabbing'; canvas.style.cursor = 'grabbing';
this._needsRender = true; this._needsRender = true;
}
return; return;
} }
}
this._hoverDirty = true; // 射线检测移到 _animate 中每帧至多执行一次 this._hoverDirty = true; // 射线检测移到 _animate 中每帧至多执行一次
}); });
canvas.addEventListener('pointerup', (e) => { canvas.addEventListener('pointerup', (e) => {
if (this._drag && this._drag.active) { if (this._drag && this._drag.active) {
const wasClick = !this._drag.moved; const wasClick = !this._drag.moved;
const view = this._drag.view;
this._drag.active = false; this._drag.active = false;
canvas.style.cursor = 'grab'; canvas.style.cursor = 'grab';
if (view === 'timeline') {
this.timeline.setPausedGeneration(false); // 拖拽结束,恢复纹理生成
if (wasClick) this._pick(); if (wasClick) this._pick();
} else if (view === 'binder' && wasClick) {
setPointer(e); // 用松开位置做射线,松开时触发翻页
this._pick();
}
} }
}); });
canvas.addEventListener('pointercancel', () => { canvas.addEventListener('pointercancel', () => {
if (this._drag) this._drag.active = false; if (this._drag) this._drag.active = false;
canvas.style.cursor = 'grab'; canvas.style.cursor = 'grab';
this.timeline.setPausedGeneration(false);
}); });
window.addEventListener('resize', () => this._onResize()); window.addEventListener('resize', () => this._onResize());
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') this._step(1); if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
else if (e.key === 'ArrowLeft') this._step(-1); const dir = e.key === 'ArrowRight' ? 1 : -1;
this._stepByView(dir);
}
else if (e.key === '1') this.switchView('binder'); else if (e.key === '1') this.switchView('binder');
else if (e.key === '2') this.switchView('timeline'); else if (e.key === '2') this.switchView('timeline');
else if (e.key === '3') this.switchView('globe'); else if (e.key === '3') this.switchView('globe');
@@ -292,10 +311,10 @@ export class App {
_hitTest() { _hitTest() {
let list; let list;
if (this.view === 'binder') { if (this.view === 'binder') {
// 只检测顶层可点击页(左右页 + 翻页缓冲),避免对全部 182 页求交 // 只检测顶层可点击页(左右顶页 + 正反翻页缓冲),避免对全部页求交
list = []; list = [];
const cur = this.binder.current; const cur = this.binder.current;
for (let d = -1; d <= 1; d++) { for (let d = -2; d <= 1; d++) {
const i = cur + d; const i = cur + d;
if (i >= 0 && i < this.binder.pages.length) list.push(this.binder.pages[i].mesh); if (i >= 0 && i < this.binder.pages.length) list.push(this.binder.pages[i].mesh);
} }
@@ -322,13 +341,11 @@ export class App {
if (!hit) return; if (!hit) return;
const { view, index } = hit.object.userData; const { view, index } = hit.object.userData;
if (view === 'binder') { if (view === 'binder') {
const cur = this.binder.current; // 按点击位置的空间左右侧判断翻页方向(翻页过程中索引与左右侧会错位,不能按索引)
if (index === cur) { if (hit.point.x >= 0) {
this.binder.flipForward(); // 点击当前右页:翻到下一页 this.binder.flipForward(); // 点了右侧 → 向前翻
} else if (index === cur - 1) {
this.binder.flipBackward(); // 点击左页:翻回上一页
} else { } else {
this._select(index, { syncBinder: true }); this.binder.flipBackward(); // 点了左侧 → 向后翻
} }
} else { } else {
this._select(index, { syncBinder: false }); this._select(index, { syncBinder: false });
@@ -355,10 +372,29 @@ export class App {
else this.binder.flipBackward(); else this.binder.flipBackward();
} }
// 上一页/下一页(底部按钮与左右键共用):时间轴为选择+居中+翻面,其它为活页本翻页
_stepByView(dir) {
if (this.view === 'timeline') this._stepTimeline(dir);
else this._step(dir);
}
// 时间轴:左右键选择上一张/下一张,动画与点击一致(居中 + 翻面)
_stepTimeline(dir) {
const next = clamp(this.selected + dir, 0, ENTRIES.length - 1);
if (next === this.selected) return;
this._select(next, { syncBinder: true });
this._centerTimeline(next);
this.timeline.flip(next);
}
_select(index, { syncBinder = false } = {}) { _select(index, { syncBinder = false } = {}) {
const isEnd = index >= ENTRIES.length;
this.selected = clamp(index, 0, ENTRIES.length - 1); this.selected = clamp(index, 0, ENTRIES.length - 1);
const entry = ENTRIES[this.selected]; if (isEnd) {
this.overlay.setEntry(entry, this.selected, ENTRIES.length); this.overlay.setEnd(ENTRIES[this.selected]);
} else {
this.overlay.setEntry(ENTRIES[this.selected], this.selected, ENTRIES.length);
}
this.timeline.setSelected(this.selected); this.timeline.setSelected(this.selected);
this.globe.setSelected(this.selected); this.globe.setSelected(this.selected);
if (this.view === 'globe') this.globe.flyToEntry(this.selected); // 地球页切换时自动居中 if (this.view === 'globe') this.globe.flyToEntry(this.selected); // 地球页切换时自动居中
@@ -367,7 +403,7 @@ export class App {
} }
switchView(view) { switchView(view) {
if (view === this.view || !CAMERA_POSES[view]) return; if (view === this.view || !VIEWS.includes(view)) return;
// 地理视图尚未就绪(按钮仍置灰)时忽略切换,键盘也无法绕过 // 地理视图尚未就绪(按钮仍置灰)时忽略切换,键盘也无法绕过
if (view === 'globe' && !this.globe.viewer) return; if (view === 'globe' && !this.globe.viewer) return;
// 离开当前视图前,记住它的相机位置 // 离开当前视图前,记住它的相机位置
@@ -381,10 +417,15 @@ export class App {
iris.style.opacity = '1'; iris.style.opacity = '1';
const doSwap = () => { const doSwap = () => {
this._setView(view); this._setView(view);
if (view === 'globe') {
// 地理视图:等影像/地形瓦片加载完成再淡出,让球体与标记/标签一起出现
this.globe.waitUntilViewReady(4000).then(() => { iris.style.opacity = '0'; });
} else {
// 等新视图渲染一两帧后再淡出,避免半透明时看到切换瞬间 // 等新视图渲染一两帧后再淡出,避免半透明时看到切换瞬间
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { iris.style.opacity = '0'; }); requestAnimationFrame(() => { iris.style.opacity = '0'; });
}); });
}
}; };
setTimeout(doSwap, 200); // 与 0.18s 的淡入匹配,完全变黑后再切换 setTimeout(doSwap, 200); // 与 0.18s 的淡入匹配,完全变黑后再切换
} }
@@ -398,6 +439,15 @@ export class App {
this.binder.group.visible = view === 'binder'; this.binder.group.visible = view === 'binder';
this.timeline.group.visible = view === 'timeline'; this.timeline.group.visible = view === 'timeline';
// 阴影仅在活页本视图启用:时间轴/地理无投影,降低滑动时阴影贴图重绘开销
this.renderer.shadowMap.enabled = view === 'binder';
// 灯光:时间轴只用半球光 + 主方向光;活页本保留全部灯光(减少 PBR 每像素光照计算)
const fullLights = view === 'binder';
this.rimLight.visible = fullLights;
this.grazeLight.visible = fullLights;
this.backLight.visible = fullLights;
// Cesium 地球:地理视图显示 Cesium 画布,其余视图显示 Three.js 画布 // Cesium 地球:地理视图显示 Cesium 画布,其余视图显示 Three.js 画布
document.getElementById('scene').style.display = isGlobe ? 'none' : 'block'; document.getElementById('scene').style.display = isGlobe ? 'none' : 'block';
document.getElementById('cesium-container').classList.toggle('visible', isGlobe); document.getElementById('cesium-container').classList.toggle('visible', isGlobe);
@@ -417,6 +467,7 @@ export class App {
// 登记卡详情在视图切换时保持显示,不隐藏 // 登记卡详情在视图切换时保持显示,不隐藏
document.getElementById('detail-card').classList.remove('hidden'); document.getElementById('detail-card').classList.remove('hidden');
if (!isGlobe) {
const pose = CAMERA_POSES[view]; const pose = CAMERA_POSES[view];
this.controls.minDistance = pose.min; this.controls.minDistance = pose.min;
this.controls.maxDistance = pose.max; this.controls.maxDistance = pose.max;
@@ -430,15 +481,6 @@ export class App {
// 左键旋转限制:以各视图初始视角为中心,各方向 ±10° // 左键旋转限制:以各视图初始视角为中心,各方向 ±10°
this._applyRotationLimits(pose); this._applyRotationLimits(pose);
// 进入时间轴时:自动定位到当前选中的活页居中
if (view === 'timeline') {
const limit = TIMELINE_SPREAD + 0.6;
this.timelineOffset = clamp(-this.timeline.getCardX(this.selected), -limit, limit);
this.timeline.group.position.x = this.timelineOffset;
this.timeline.refreshTextures();
}
if (!isGlobe) {
// 切换视图:直接定位(有记忆则恢复上次的相机位置) // 切换视图:直接定位(有记忆则恢复上次的相机位置)
this.cameraTween = null; this.cameraTween = null;
this.zoomTarget = null; this.zoomTarget = null;
@@ -451,6 +493,14 @@ export class App {
this.controls.target.copy(pose.target); this.controls.target.copy(pose.target);
} }
} }
// 进入时间轴时:自动定位到当前选中的活页居中
if (view === 'timeline') {
const limit = TIMELINE_SPREAD + 0.6;
this.timelineOffset = clamp(-this.timeline.getCardX(this.selected), -limit, limit);
this.timeline.group.position.x = this.timelineOffset;
this.timeline.refreshTextures();
}
this._needsRender = true; this._needsRender = true;
} }
@@ -490,11 +540,13 @@ export class App {
_animate() { _animate() {
requestAnimationFrame(this._animate); requestAnimationFrame(this._animate);
if (this.view === 'globe') return; // 地理视图由 Cesium 渲染,跳过 Three.js 每帧逻辑
const dt = Math.min(this.clock.getDelta(), 0.05); const dt = Math.min(this.clock.getDelta(), 0.05);
// 是否有进行中的动画(决定本帧是否需要重绘) // 是否有进行中的动画(决定本帧是否需要重绘)
const animating = const animating =
this.binder.flip !== null || this.binder.flip !== null ||
this.binder.resetTween !== null ||
this.binder.isSettling() || this.binder.isSettling() ||
this.timeline.hasActiveFlip() || this.timeline.hasActiveFlip() ||
this.cameraTween !== null || this.cameraTween !== null ||
+311 -129
View File
File diff suppressed because it is too large Load Diff
+15 -7
View File
@@ -1,24 +1,32 @@
import { App } from './core/App.js'; import { App } from './core/App.js';
import { initLoading } from './ui/loading.js';
// 计时起点:脚本执行开始(含 App 构造耗时),加载界面强制最短展示 6 秒
const t0 = Date.now();
const MIN_DISPLAY = 8000;
// 全站禁止复制/剪切/右键菜单(配合 CSS user-select: none 禁止选中) // 全站禁止复制/剪切/右键菜单(配合 CSS user-select: none 禁止选中)
for (const ev of ['copy', 'cut', 'contextmenu']) { for (const ev of ['copy', 'cut', 'contextmenu']) {
document.addEventListener(ev, (e) => e.preventDefault()); document.addEventListener(ev, (e) => e.preventDefault());
} }
// OS1 加载环动画
const loadingAnim = initLoading();
// 启动应用 // 启动应用
const app = new App(); const app = new App();
window.__app = app; // 便于调试 window.__app = app; // 便于调试
// 加载界面:等首帧渲染后立即后台预载地理视图(与 loading 并行,不等其淡出); // 首帧渲染后立即后台预载地理视图(与 loading 并行)
// loading 最少展示 3 秒(期间 Cesium 预载已同步进行)。
const t0 = Date.now();
const MIN_DISPLAY = 6000;
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
app.preloadGlobe(); // 首帧已渲染,立即预载地理界面 app.preloadGlobe();
});
});
// 强制 6 秒后淡出加载界面
const wait = Math.max(0, MIN_DISPLAY - (Date.now() - t0)); const wait = Math.max(0, MIN_DISPLAY - (Date.now() - t0));
setTimeout(() => { setTimeout(() => {
document.getElementById('loading').classList.add('hidden'); document.getElementById('loading').classList.add('hidden');
loadingAnim.dispose();
}, wait); }, wait);
});
});
+271 -74
View File
@@ -53,8 +53,9 @@ function sphericalAreaKm2(polys) {
} }
// 生成便签 canvas:黄底圆角 + 左侧箭头 + 黑色加粗文字(整张图,箭头尖端指向坐标) // 生成便签 canvas:黄底圆角 + 左侧箭头 + 黑色加粗文字(整张图,箭头尖端指向坐标)
// 选中态:先描粗边再填充,得到一条干净的 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; // S: 2x 超采样 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');
@@ -63,55 +64,61 @@ function makeStickyNote(text, fontSize, selected = false) {
const bodyW = Math.max(1, Math.ceil(textW + padX * 2)); const bodyW = Math.max(1, Math.ceil(textW + padX * 2));
const w = arrowW + bodyW; const w = arrowW + bodyW;
const h = Math.ceil(fontSize + padY * 2); const h = Math.ceil(fontSize + padY * 2);
const cw = w + B * 2;
const ch = h + B * 2;
const c = document.createElement('canvas'); const c = document.createElement('canvas');
c.width = w * S; c.width = cw * S;
c.height = h * S; c.height = ch * S;
const ctx = c.getContext('2d'); const ctx = c.getContext('2d');
ctx.scale(S, S); 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, ox + arrowW, oy);
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, oy + h);
// 下侧:一条曲线从下边缘平滑过渡回尖端(起点水平切线)
ctx.quadraticCurveTo(ox + arrowW * 0.7, oy + h, 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.shadowColor = 'rgba(0, 0, 0, 0.20)';
ctx.shadowBlur = 5; ctx.shadowBlur = 5;
ctx.shadowOffsetY = 2; 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.fillStyle = '#e0c46a';
ctx.fill(); ctx.fill();
// 选中时加黑边
if (selected) {
ctx.shadowColor = 'transparent'; ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0; ctx.fillStyle = '#14100d';
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.font = font;
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(text, arrowW + bodyW / 2, h / 2 + 1); ctx.fillText(text, ox + arrowW + bodyW / 2 - 2, oy + h / 2 + 1);
return { canvas: c, scale: 1 / S, width: w, height: h }; return { canvas: c, scale: 1 / S, width: cw, height: ch, tipX: B, tipY: B + h / 2 };
} }
export class CesiumGlobe { export class CesiumGlobe {
@@ -197,12 +204,12 @@ export class CesiumGlobe {
scene.moon.show = false; scene.moon.show = false;
scene.globe.enableLighting = false; scene.globe.enableLighting = false;
scene.globe.showGroundAtmosphere = false; scene.globe.showGroundAtmosphere = false;
scene.globe.baseColor = Cesium.Color.fromCssColorString('#0a1626'); scene.globe.baseColor = Cesium.Color.fromCssColorString('#d9e6f0'); // 冰盖浅色:影像/地形未覆盖的南北极区域显示为冰盖,而非黑洞
// 相机交互:限制滚轮缩放范围,避免过近/过远 // 相机交互:限制滚轮缩放范围,避免过近/过远
const controller = scene.screenSpaceCameraController; const controller = scene.screenSpaceCameraController;
controller.enableCollisionDetection = true; // 必须开启:缩放距离限制在关闭时会被忽略 controller.enableCollisionDetection = true; // 必须开启:缩放距离限制在关闭时会被忽略
controller.minimumZoomDistance = 10000; // 最近约 10 km(区域尺度) controller.minimumZoomDistance = 500; // 最近约 500 m(街区尺度)
controller.maximumZoomDistance = 8e6; // 最远约 8000 km controller.maximumZoomDistance = 8e6; // 最远约 8000 km
// 限制鼠标按钮:只保留左键旋转 + 滚轮缩放;禁用中键倾斜、右键缩放 // 限制鼠标按钮:只保留左键旋转 + 滚轮缩放;禁用中键倾斜、右键缩放
@@ -212,6 +219,7 @@ export class CesiumGlobe {
Cesium.CameraEventType.PINCH, Cesium.CameraEventType.PINCH,
]; ];
controller.tiltEventTypes = [ controller.tiltEventTypes = [
Cesium.CameraEventType.RIGHT_DRAG, // 右键 = 原生 tilt(传统环视)
Cesium.CameraEventType.PINCH, Cesium.CameraEventType.PINCH,
{ {
eventType: Cesium.CameraEventType.LEFT_DRAG, eventType: Cesium.CameraEventType.LEFT_DRAG,
@@ -219,23 +227,64 @@ 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 = 2.0; // 默认细分,最省性能 scene.globe.maximumScreenSpaceError = 2.0; // 地形细分
scene.globe.tileCacheSize = 600; // 更大的瓦片缓存,减少细节刷新 scene.globe.tileCacheSize = 600; // 更大的瓦片缓存,减少细节刷新
scene.fxaa = false; // 关闭抗锯齿后处理,省一个全屏通道 scene.fxaa = true; // 后处理抗锯齿(MSAA 默认已 4x)
viewer.resolutionScale = 1.5; // 1.5 倍超采样,替代 FXAA 提供更锐利的抗锯齿 viewer.useBrowserRecommendedResolution = false; // 按设备像素比渲染,高分屏清晰
viewer.resolutionScale = 1.0;
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,
}); });
viewer.imageryLayers.addImageryProvider(imagery); const imageryLayer = new Cesium.ImageryLayer(imagery, {
// 卫星影像不覆盖极区(约 ±85° 外返回黑块),限制范围让极区用冰盖底色
rectangle: Cesium.Rectangle.fromDegrees(-180, -85, 180, 85),
});
viewer.imageryLayers.add(imageryLayer);
// 真实地形(Esri World Elevation 3D,免费无 token;失败则保持默认椭球) // 真实地形(Esri World Elevation 3D,免费无 token;失败则保持默认椭球)
const terrain = new Cesium.Terrain( const terrain = new Cesium.Terrain(
@@ -249,7 +298,9 @@ export class CesiumGlobe {
viewer.clock.shouldAnimate = false; viewer.clock.shouldAnimate = false;
this._buildMarkers(); this._buildMarkers();
this._clampMarkersToTerrain();
this._setupPick(); this._setupPick();
this._setupRightRestore();
this._loadBoundaries(); this._loadBoundaries();
this._buildCityLabels(); this._buildCityLabels();
@@ -273,13 +324,79 @@ 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({ position, noteNormal, noteSelected, index: i }); this.markers.push({
lng: entry.lng,
lat: entry.lat,
position,
r2: Cesium.Cartesian3.magnitudeSquared(position),
noteNormal,
noteSelected,
index: i,
});
}); });
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');
@@ -294,50 +411,106 @@ export class CesiumGlobe {
el.style.height = `${m.noteNormal.height}px`; el.style.height = `${m.noteNormal.height}px`;
el.dataset.index = m.index; el.dataset.index = m.index;
m.el = el; m.el = el;
m.tipX = m.noteNormal.tipX; // 箭头尖相对元素左上角的偏移
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);
} }
this.viewer.scene.postRender.addEventListener(() => this._updateMarkerLayer()); // 用 postUpdate(每帧都触发,即使 requestRenderMode 下不真正渲染)更新便签位置,
// 避免拖拽时 postRender 不触发导致的闪烁/消失。
this.viewer.scene.postUpdate.addEventListener(() => this._updateMarkerLayer());
} }
// 每帧更新便签屏幕位置(相机移动/缩放/飞行时跟随) // 每帧更新便签屏幕位置(相机移动/缩放/飞行时跟随)
_updateMarkerLayer() { _updateMarkerLayer() {
const scene = this.viewer.scene; const scene = this.viewer.scene;
const camera = scene.camera; const camera = scene.camera;
const outOfRange = camera.positionCartographic.height > 8e6; // 最远 8000 km 内显示 const camPos = camera.positionWC; // 每帧只取一次相机位置
for (const m of this.markers) { for (const m of this.markers) {
const el = m.el; const el = m.el;
if (!el) continue; if (!el) continue;
if (outOfRange) { el.style.display = 'none'; continue; }
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, m.position); const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, m.position);
if (!win) { el.style.display = 'none'; continue; } if (!win) { el.style.display = 'none'; continue; }
// 背面(远半球)隐藏:dot(P, C) <= |P|² 时为背对地球 // 前/后半球:dot(P, C) > |P|²;背面隐藏(不渐变淡出)
const dotPC = Cesium.Cartesian3.dot(m.position, camera.positionWC); const frontness = Cesium.Cartesian3.dot(m.position, camPos) - m.r2;
const r2 = Cesium.Cartesian3.magnitudeSquared(m.position); if (frontness <= 0) { el.style.display = 'none'; continue; }
if (dotPC <= r2) { el.style.display = 'none'; continue; }
el.style.display = 'block'; el.style.display = 'block';
el.style.left = `${win.x}px`; el.style.opacity = '1';
el.style.top = `${win.y}px`; 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() { _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:按下记录位置;松开不回正。中键:回正到“不倾斜不旋转”(天底 + 北朝上)
_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;
} }
// 相机平滑飞至某个标记上空并居中:回到与初始一致的高度与北朝上方向,左键拖拽手感不变 // 相机平滑飞至某个标记上空并居中:回到与初始一致的高度与北朝上方向,左键拖拽手感不变
@@ -368,6 +541,10 @@ export class CesiumGlobe {
const textGold = Cesium.Color.fromCssColorString('#e0c46a'); const textGold = Cesium.Color.fromCssColorString('#e0c46a');
const black = Cesium.Color.fromCssColorString('#14100d'); 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 = []; const lines = [];
for (const feature of geojson.features || []) { for (const feature of geojson.features || []) {
const props = feature.properties || {}; const props = feature.properties || {};
@@ -380,19 +557,20 @@ 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 ? 30 : areaKm2 > 1.5e6 ? 19 : areaKm2 > 3e5 ? 15 : 12; const fontSize = areaKm2 > 5e6 ? 28 : areaKm2 > 1.5e6 ? 21 : areaKm2 > 3e5 ? 18 : 16;
this.viewer.entities.add({ countryLabels.add({
position: Cesium.Cartesian3.fromDegrees(props.LABEL_X, props.LABEL_Y), position: Cesium.Cartesian3.fromDegrees(props.LABEL_X, props.LABEL_Y),
label: {
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,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(1e6, 1e7), disableDepthTestDistance: 1000, // 1km 内不深度测试(近处不消失),远处仍遮挡背面
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
scaleByDistance: new Cesium.NearFarScalar(1e4, 20 / 15, 6e6, 1.0), scaleByDistance: new Cesium.NearFarScalar(1e4, 20 / 15, 6e6, 1.0),
translucencyByDistance: new Cesium.NearFarScalar(9e6, 1.0, 1e7, 0.0), translucencyByDistance: new Cesium.NearFarScalar(9e6, 1.0, 1e7, 0.0),
},
}); });
} }
@@ -431,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 {
@@ -458,19 +636,20 @@ export class CesiumGlobe {
outlineColor: black, outlineColor: black,
outlineWidth: 1, outlineWidth: 1,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
distanceDisplayCondition: ddc,
translucencyByDistance: tdc,
}); });
labels.add({ labels.add({
position, position,
text: c.name, text: c.name,
font: 'bold 14px "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),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: 1000, // 1km 内不深度测试(近处不消失),远处仍遮挡背面
distanceDisplayCondition: ddc, distanceDisplayCondition: ddc,
translucencyByDistance: tdc, translucencyByDistance: tdc,
}); });
@@ -500,6 +679,24 @@ export class CesiumGlobe {
if (visible && this.viewer) this.viewer.scene.requestRender(); 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() { resize() {
if (this.viewer) this.viewer.resize(); if (this.viewer) this.viewer.resize();
} }
+107 -57
View File
@@ -1,7 +1,7 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { ENTRIES } from '../data/entries.js'; import { ENTRIES } from '../data/entries.js';
import { PAGE_W, PAGE_H, HOLE_U, acquirePageTextures, releasePageTextures, getPlaceholderTextures, makeTwoSidedMaterial } from '../textures/pages.js'; import { PAGE_W, PAGE_H, HOLE_U, acquirePageTextures, releasePageTextures, getPlaceholderTextures, prefetchPageTextures, makeTwoSidedMaterial } from '../textures/pages.js';
import { clamp, easeOutCubic } from '../utils/easing.js'; import { clamp, easeOutCubic, easeInOutCubic } from '../utils/easing.js';
const RING_R = 0.085; const RING_R = 0.085;
const RING_TUBE = 0.0115; const RING_TUBE = 0.0115;
@@ -234,7 +234,7 @@ function makeLeatherNormalMap() {
// 内侧绒面法线:密集细绒毛(高频噪点) // 内侧绒面法线:密集细绒毛(高频噪点)
function makeSuedeNormalMap() { function makeSuedeNormalMap() {
const w = 1024, h = 1280; const w = 512, h = 640;
const c = document.createElement('canvas'); const c = document.createElement('canvas');
c.width = w; c.height = h; c.width = w; c.height = h;
const ctx = c.getContext('2d', { willReadFrequently: true }); const ctx = c.getContext('2d', { willReadFrequently: true });
@@ -278,6 +278,7 @@ export class Binder {
this.pages = []; this.pages = [];
this.current = 0; this.current = 0;
this.flip = null; // { index, from, to, t, duration } this.flip = null; // { index, from, to, t, duration }
this.resetTween = null; // 回卷重置动画
this.onChange = null; this.onChange = null;
this.suedeTex = makeSuedeTexture(); this.suedeTex = makeSuedeTexture();
@@ -379,23 +380,25 @@ export class Binder {
return geo; return geo;
} }
_ensureHiGeo(p) {
if (!p.hiGeo) {
p.hiGeo = this._makePageGeometry(96, 8);
p.hiBase = Float32Array.from(p.hiGeo.attributes.position.array);
}
}
_buildPages() { _buildPages() {
const segX = 96, segY = 8; // 共享极简几何(所有栈内页相同,只露边缘无需弯曲)
this.loGeo = this._makePageGeometry(1, 1);
ENTRIES.forEach((entry, i) => { ENTRIES.forEach((entry, i) => {
// 高分段几何:翻页/顶层页(弯曲需要足够分段)
const hiGeo = this._makePageGeometry(segX, segY);
const hiBase = Float32Array.from(hiGeo.attributes.position.array);
// 极简几何:栈内页(只露边缘,无需弯曲,2 个三角形)
const loGeo = this._makePageGeometry(1, 1);
// 单 mesh + 双面材质:正面登记卡、背面素描(gl_FrontFacing 自动选择) // 单 mesh + 双面材质:正面登记卡、背面素描(gl_FrontFacing 自动选择)
// 活页孔用 alphaTest 直接丢弃透明像素(不启用 transparent,避免透明+双面拆两次 draw call) // 活页孔用 alphaTest 直接丢弃透明像素(不启用 transparent,避免透明+双面拆两次 draw call)
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, { const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, {
alphaTest: 0.5, alphaTest: 0.5,
}); });
const mesh = new THREE.Mesh(loGeo, mat); // 初始为栈内页,用极简几何 const mesh = new THREE.Mesh(this.loGeo, mat); // 初始为栈内页,用极简几何
mesh.castShadow = false; // 由 _restack 依据是否为顶层页控制 mesh.castShadow = false; // 由 _restackState 依据是否为顶层页控制
const g = new THREE.Group(); const g = new THREE.Group();
g.add(mesh); g.add(mesh);
@@ -404,9 +407,9 @@ export class Binder {
this.pages.push({ this.pages.push({
group: g, group: g,
hiGeo, hiGeo: null, // 高分段几何延迟创建(成为顶层页时才需要弯曲)
loGeo, hiBase: null,
hiBase, loGeo: this.loGeo,
material: mat, material: mat,
mesh, mesh,
isPlaceholder: true, isPlaceholder: true,
@@ -428,9 +431,16 @@ export class Binder {
const angle = j < this.current ? -Math.PI : 0; const angle = j < this.current ? -Math.PI : 0;
p.angle = angle; p.angle = angle;
p.group.rotation.y = angle; p.group.rotation.y = angle;
const onTop = (j === this.current || j === this.current - 1); });
const depth = j < this.current ? (this.current - 1 - j) : (j - this.current); this._restackState(this.current, instant);
// 顶层页抬高,与栈内页拉开深度,避免低精度深度缓冲下的穿模(z-fighting) this._updateCurvature();
}
// 堆叠状态(z 目标/亮度/投影/几何 LOD),不碰角度;可传未来 current 实现与翻页并行
_restackState(cur, instant = false) {
this.pages.forEach((p, j) => {
const onTop = (j === cur || j === cur - 1);
const depth = j < cur ? (cur - 1 - j) : (j - cur);
p.targetZ = onTop ? TOP_LIFT : -depth * STACK_GAP; p.targetZ = onTop ? TOP_LIFT : -depth * STACK_GAP;
if (instant) { if (instant) {
p.z = p.targetZ; p.z = p.targetZ;
@@ -438,28 +448,34 @@ export class Binder {
} }
p.group.position.y = 0; p.group.position.y = 0;
p.group.position.x = 0; p.group.position.x = 0;
// 下层页略暗
const dim = 1 - Math.min(depth * 0.06, 0.22); const dim = 1 - Math.min(depth * 0.06, 0.22);
p.material.color.setScalar(dim); p.material.color.setScalar(dim);
// 只让顶层(当前右页 + 左侧页)投影,栈内被遮挡页不投影,省阴影 pass
p.mesh.castShadow = onTop; p.mesh.castShadow = onTop;
// 顶层页用高分段几何(翻页弯曲),栈内页用极简几何(省顶点) if (onTop) {
if ((p.mesh.geometry === p.hiGeo) !== onTop) { this._ensureHiGeo(p);
p.mesh.geometry = onTop ? p.hiGeo : p.loGeo; if (p.mesh.geometry !== p.hiGeo) p.mesh.geometry = p.hiGeo;
} else if (p.mesh.geometry !== p.loGeo) {
p.mesh.geometry = p.loGeo;
} }
}); });
this._updateCurvature();
this._ensureTextures(); this._ensureTextures();
} }
// 需要真实纹理的窗口:当前页 ±2(左右可见页 + 翻页时会露出的下一层页 + 缓冲) // 需要真实纹理的窗口:左右可见页 + 翻页时会露出的下一层页(按需,首屏最小)
_pageWindow() { _pageWindow() {
const len = this.pages.length; const len = this.pages.length;
const win = new Set(); const win = new Set();
for (let d = -2; d <= 2; d++) { // 左页 + 右页(当前)
for (let d = -1; d <= 0; d++) {
const i = this.current + d; const i = this.current + d;
if (i >= 0 && i < len) win.add(i); if (i >= 0 && i < len) win.add(i);
} }
// 向前翻页会露出的下一层右页
const fwd = this.current + 1;
if (fwd >= 0 && fwd < len) win.add(fwd);
// 向后翻页会露出的下一层左页
const bwd = this.current - 2;
if (bwd >= 0 && bwd < len) win.add(bwd);
return win; return win;
} }
@@ -485,10 +501,17 @@ export class Binder {
} }
} }
// 预生成即将进入纹理窗口的页:推迟到下一帧,避免占用点击这一帧造成卡顿
_prefetchForFlip(index) {
if (index < 0 || index >= ENTRIES.length) return;
requestAnimationFrame(() => prefetchPageTextures(ENTRIES[index]));
}
_updateCurvature(index = null) { _updateCurvature(index = null) {
// 翻页时整页柔软弧弯:自由边最高、中段有“腹”、书脊侧贴环;顶边略强于底边;静止时完全平整 // 翻页时整页柔软弧弯:自由边最高、中段有“腹”、书脊侧贴环;顶边略强于底边;静止时完全平整
const pages = index === null ? this.pages : [this.pages[index]]; const pages = index === null ? this.pages : [this.pages[index]];
pages.forEach((p) => { pages.forEach((p) => {
if (!p.hiGeo) return; // 栈内页未创建高分段几何,跳过
// 弯曲只作用于高分段几何(顶层/翻页页);栈内极简几何不弯曲 // 弯曲只作用于高分段几何(顶层/翻页页);栈内极简几何不弯曲
const pos = p.hiGeo.attributes.position; const pos = p.hiGeo.attributes.position;
const arr = pos.array; const arr = pos.array;
@@ -514,12 +537,13 @@ export class Binder {
syncTo(index) { syncTo(index) {
this.current = clamp(index, 0, this.pages.length - 1); this.current = clamp(index, 0, this.pages.length - 1);
this.flip = null; this.flip = null;
this.resetTween = null; // 外部跳转时终止回卷/快进动画,避免旧状态残留
this._restack(true); this._restack(true);
if (this.onChange) this.onChange(this.current); if (this.onChange) this.onChange(this.current);
} }
canFlipForward() { return !this.flip; } canFlipForward() { return !this.flip && !this.resetTween; }
canFlipBackward() { return !this.flip && this.current > 0; } canFlipBackward() { return !this.flip && !this.resetTween; }
// 页面 z 是否仍在向目标位置过渡(用于按需渲染:过渡期间需要持续重绘) // 页面 z 是否仍在向目标位置过渡(用于按需渲染:过渡期间需要持续重绘)
isSettling() { isSettling() {
@@ -535,46 +559,74 @@ export class Binder {
p.group.rotation.y = f.to; p.group.rotation.y = f.to;
p.z = TOP_LIFT; p.z = TOP_LIFT;
p.group.position.z = TOP_LIFT; p.group.position.z = TOP_LIFT;
if (f.to === -Math.PI) { this.current = f.nextCurrent;
this.current = (this.current + 1) % this.pages.length;
} else {
this.current -= 1;
}
this.flip = null; this.flip = null;
this._restack(false); this._restack(false);
} }
_startWrap(targetCur) {
// 回卷/快进:所有页从各自当前角度平滑转到目标角度(左页→右侧 或 右页→左侧),current 跳到目标页
this.current = targetCur;
this.flip = null;
this.resetTween = { t: 0, duration: 0.5 };
this.pages.forEach((p, j) => {
p._resetFrom = p.angle;
p._resetTo = j < targetCur ? -Math.PI : 0;
});
this._restackState(targetCur, false);
if (this.onChange) this.onChange(targetCur);
}
flipForward() { flipForward() {
if (this.flip) this._finalizeFlip(); // 前一页没翻完也能继续翻 if (this.flip) this._finalizeFlip(); // 前一页没翻完也能继续翻
if (!this.canFlipForward()) return; if (!this.canFlipForward()) return;
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.38 }; // END 页(所有页都已翻到左侧,current = pages.length):向前 → 回卷到第一页
// 提前把即将露出的下一层右页切到顶层状态(高分段几何/亮度/投影),避免翻页完成瞬间跳变 if (this.current >= this.pages.length) {
const reveal = this.pages[(this.current + 1) % this.pages.length]; this._startWrap(0);
if (reveal) { return;
if (reveal.mesh.geometry !== reveal.hiGeo) reveal.mesh.geometry = reveal.hiGeo;
reveal.mesh.castShadow = true;
reveal.material.color.setScalar(1.0);
} }
// 最后一页翻完即进入 END 页:右侧无纸,左页为最后一页的背面
const nextCurrent = this.current + 1;
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.38, nextCurrent };
// 堆叠重排与旋转并行:翻页开始即设好目标位置,z-lerp 随旋转同步过渡
this._restackState(nextCurrent, false);
this._prefetchForFlip((nextCurrent + 2) % this.pages.length);
// 优先更新底部导航与信息卡(翻页开始即切换目标页) // 优先更新底部导航与信息卡(翻页开始即切换目标页)
if (this.onChange) this.onChange((this.current + 1) % this.pages.length); if (this.onChange) this.onChange(nextCurrent);
} }
flipBackward() { flipBackward() {
if (this.flip) this._finalizeFlip(); if (this.flip) this._finalizeFlip();
if (!this.canFlipBackward()) return; if (!this.canFlipBackward()) return;
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.38 }; // 第一页向后:快进到 END 页(所有未翻的右页都翻到左侧,展示末页背面)
// 提前把即将露出的下一层左页切到顶层状态,避免翻页完成瞬间跳变 if (this.current === 0) {
const reveal = this.pages[this.current - 2]; this._startWrap(this.pages.length);
if (reveal) { return;
if (reveal.mesh.geometry !== reveal.hiGeo) reveal.mesh.geometry = reveal.hiGeo;
reveal.mesh.castShadow = true;
reveal.material.color.setScalar(1.0);
} }
// 优先更新底部导航与信息卡 const nextCurrent = this.current - 1;
if (this.onChange) this.onChange(this.current - 1); this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.38, nextCurrent };
this._restackState(nextCurrent, false);
this._prefetchForFlip((nextCurrent - 2 + this.pages.length) % this.pages.length);
if (this.onChange) this.onChange(nextCurrent);
} }
update(dt) { update(dt) {
// 回卷重置动画:所有页角度从各自起点平滑回到 0(右侧)
if (this.resetTween) {
const r = this.resetTween;
r.t += dt / r.duration;
const k = clamp(r.t, 0, 1);
const e = easeInOutCubic(k);
this.pages.forEach((p) => {
p.angle = p._resetFrom + (p._resetTo - p._resetFrom) * e;
p.group.rotation.y = p.angle;
});
if (k >= 1) {
this.resetTween = null;
this._restack(true);
}
}
if (this.flip) { if (this.flip) {
const f = this.flip; const f = this.flip;
f.t += dt / f.duration; f.t += dt / f.duration;
@@ -591,12 +643,10 @@ export class Binder {
this._updateCurvature(f.index); this._updateCurvature(f.index);
if (k >= 1) { if (k >= 1) {
if (f.to === -Math.PI) { this.current = f.nextCurrent;
// 最后一页翻完后循环回第一页
this.current = (this.current + 1) % this.pages.length;
} else this.current -= 1;
this.flip = null; this.flip = null;
this._restack(false); this._updateCurvature();
this._ensureTextures();
} }
} }
@@ -610,9 +660,9 @@ export class Binder {
} }
dispose() { dispose() {
if (this.loGeo) this.loGeo.dispose();
this.pages.forEach((p) => { this.pages.forEach((p) => {
p.hiGeo.dispose(); if (p.hiGeo) p.hiGeo.dispose();
p.loGeo.dispose();
p.material.dispose(); p.material.dispose();
}); });
} }
+48 -19
View File
@@ -1,6 +1,6 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { ENTRIES } from '../data/entries.js'; import { ENTRIES } from '../data/entries.js';
import { acquirePageTextures, releasePageTextures, getPlaceholderTextures, makeTwoSidedMaterial } from '../textures/pages.js'; import { acquirePageTexturesLow, releasePageTexturesLow, getPlaceholderTexturesLow, makeTwoSidedMaterial } from '../textures/pages.js';
import { makeTextSprite } from '../utils/labels.js'; import { makeTextSprite } from '../utils/labels.js';
import { clamp, easeInOutCubic } from '../utils/easing.js'; import { clamp, easeInOutCubic } from '../utils/easing.js';
@@ -22,12 +22,13 @@ export class Timeline {
this.group = new THREE.Group(); this.group = new THREE.Group();
this.cards = []; this.cards = [];
this.selected = -1; this.selected = -1;
this.placeholder = getPlaceholderTextures(); this.placeholder = getPlaceholderTexturesLow();
this._textured = new Set(); this._textured = new Set();
this._visRange = null; this._visRange = null;
this._ready = false; this._ready = false;
this._pending = []; this._pending = [];
this._dirty = false; this._dirty = false;
this._paused = false;
this._buildLine(); this._buildLine();
this._buildCards(); this._buildCards();
@@ -44,15 +45,16 @@ export class Timeline {
]); ]);
this.group.add(new THREE.Line(lineGeo, lineMat)); this.group.add(new THREE.Line(lineGeo, lineMat));
// 刻度:向上指向卡片、向下指向时间标签 // 刻度:合并为单个 LineSegments,一次绘制,避免上百次 draw call
const tickMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.5 }); const tickMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.5 });
const tickPts = [];
ENTRIES.forEach((e, i) => { ENTRIES.forEach((e, i) => {
const x = cardX(i, n); const x = cardX(i, n);
const g = new THREE.BufferGeometry().setFromPoints([ tickPts.push(x, AXIS_Y - 0.14, 0, x, AXIS_Y + 0.12, 0);
new THREE.Vector3(x, AXIS_Y - 0.14, 0), new THREE.Vector3(x, AXIS_Y + 0.12, 0),
]);
this.group.add(new THREE.Line(g, tickMat));
}); });
const tickGeo = new THREE.BufferGeometry();
tickGeo.setAttribute('position', new THREE.Float32BufferAttribute(tickPts, 3));
this.group.add(new THREE.LineSegments(tickGeo, tickMat));
} }
_buildCards() { _buildCards() {
@@ -62,7 +64,7 @@ export class Timeline {
const x = cardX(i, n); const x = cardX(i, n);
// 每张卡片独立几何(带分段,便于翻面时弯曲出纸感) // 每张卡片独立几何(带分段,便于翻面时弯曲出纸感)
const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 24, 6); const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 12, 3);
const base = Float32Array.from(cardGeo.attributes.position.array); const base = Float32Array.from(cardGeo.attributes.position.array);
// 翻转容器:单 mesh + 双面材质(翻面时 gl_FrontFacing 自动切换正/背) // 翻转容器:单 mesh + 双面材质(翻面时 gl_FrontFacing 自动切换正/背)
@@ -76,17 +78,37 @@ export class Timeline {
flipGroup.add(cardMesh); flipGroup.add(cardMesh);
// 活页上方:主题(常显,随选中上浮,超宽居中换行) // 文字标签(名称/时间)延迟到首屏后由 initLabels() 分帧生成,避免阻塞首屏
this.group.add(flipGroup);
this.cards.push({ card: flipGroup, mesh: cardMesh, material: mat, geo: cardGeo, base, name: null, time: null, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null, isPlaceholder: true });
});
}
// 时间轴文字标签:延迟到首屏后空闲时分帧生成(364 个 canvas 文字精灵较慢,避免阻塞首屏)
initLabels() {
if (this._labelsDone) return;
this._labelsDone = true;
const n = ENTRIES.length;
let i = 0;
const next = () => window.requestAnimationFrame(step);
const step = () => {
const budget = 4; // 每帧生成 4 个标签,避免卡帧
for (let k = 0; k < budget && i < n; k++, i++) {
const c = this.cards[i];
const entry = ENTRIES[i];
const x = cardX(i, n);
const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 }); const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 });
name.position.set(x, NAME_Y, 0); name.position.set(x, NAME_Y, 0);
// 轴下方:时间(常显,固定不动)
const time = makeTextSprite(entry.time, { fontSize: 13, color: '#c9a227', scale: 0.11 }); const time = makeTextSprite(entry.time, { fontSize: 13, color: '#c9a227', scale: 0.11 });
time.position.set(x, TIME_Y, 0); time.position.set(x, TIME_Y, 0);
this.group.add(name, time);
this.group.add(flipGroup, name, time); c.name = name;
this.cards.push({ card: flipGroup, mesh: cardMesh, material: mat, geo: cardGeo, base, name, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null, isPlaceholder: true }); c.time = time;
}); if (this.selected === i) name.position.y = c.nameBaseY + CARD_FLOAT;
}
if (i < n) next();
};
next();
} }
setSelected(index) { setSelected(index) {
@@ -94,7 +116,7 @@ export class Timeline {
this.cards.forEach((c) => { this.cards.forEach((c) => {
const on = c.index === index; const on = c.index === index;
c.card.position.y = on ? CARD_FLOAT : 0; c.card.position.y = on ? CARD_FLOAT : 0;
c.name.position.y = c.nameBaseY + (on ? CARD_FLOAT : 0); if (c.name) c.name.position.y = c.nameBaseY + (on ? CARD_FLOAT : 0);
}); });
} }
@@ -106,7 +128,7 @@ export class Timeline {
// App 每帧根据可视区域调用:minI/maxI 为可见卡片索引范围(可为小数) // App 每帧根据可视区域调用:minI/maxI 为可见卡片索引范围(可为小数)
setVisibleRange(minI, maxI) { setVisibleRange(minI, maxI) {
const len = this.cards.length; const len = this.cards.length;
const PAD = 4; // 缓冲:可视范围外多生成几页,拖拽时提前就绪 const PAD = 6; // 缓冲:可视范围外多生成几页,拖拽时提前就绪
const lo = Math.max(0, Math.floor(minI) - PAD); const lo = Math.max(0, Math.floor(minI) - PAD);
const hi = Math.min(len - 1, Math.ceil(maxI) + PAD); const hi = Math.min(len - 1, Math.ceil(maxI) + PAD);
if (this._visRange && this._visRange.min === lo && this._visRange.max === hi) return; if (this._visRange && this._visRange.min === lo && this._visRange.max === hi) return;
@@ -138,7 +160,7 @@ export class Timeline {
for (const i of [...this._textured]) { for (const i of [...this._textured]) {
if (win.has(i)) continue; if (win.has(i)) continue;
const c = this.cards[i]; const c = this.cards[i];
releasePageTextures(ENTRIES[i]); releasePageTexturesLow(ENTRIES[i]);
c.material.setPageMaps(this.placeholder.front, this.placeholder.back); c.material.setPageMaps(this.placeholder.front, this.placeholder.back);
c.isPlaceholder = true; c.isPlaceholder = true;
this._textured.delete(i); this._textured.delete(i);
@@ -152,8 +174,15 @@ export class Timeline {
this.drainPending(); this.drainPending();
} }
// 拖拽期间暂停纹理生成,避免主线程同步绘制导致掉帧
setPausedGeneration(paused) {
this._paused = paused;
if (!paused) this.drainPending(); // 恢复时立即补生成
}
// 每帧由 App._animate 驱动,逐批生成窗口内纹理(避免阻塞) // 每帧由 App._animate 驱动,逐批生成窗口内纹理(避免阻塞)
drainPending() { drainPending() {
if (this._paused) return false;
if (!this._pending || !this._pending.length) return false; if (!this._pending || !this._pending.length) return false;
const budget = 2; // 每帧最多生成 2 页 const budget = 2; // 每帧最多生成 2 页
let did = false; let did = false;
@@ -161,7 +190,7 @@ export class Timeline {
const i = this._pending.shift(); const i = this._pending.shift();
const c = this.cards[i]; const c = this.cards[i];
if (!c || this._textured.has(i)) continue; if (!c || this._textured.has(i)) continue;
const { front, back } = acquirePageTextures(ENTRIES[i]); const { front, back } = acquirePageTexturesLow(ENTRIES[i]);
c.material.setPageMaps(front, back); c.material.setPageMaps(front, back);
c.isPlaceholder = false; c.isPlaceholder = false;
this._textured.add(i); this._textured.add(i);
+38 -45
View File
@@ -44,11 +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%;
transform: translateY(-50%);
cursor: pointer;
} }
/* 气氛遮罩 */ /* 气氛遮罩 */
@@ -66,27 +63,25 @@ html, body {
background: #0c0906; opacity: 0; transition: opacity 0.18s ease; background: #0c0906; opacity: 0; transition: opacity 0.18s ease;
} }
/* 加载界面 */ /* 加载界面:OS1 3D 加载环 */
#loading { #loading {
position: fixed; inset: 0; z-index: 1000; position: fixed; inset: 0; z-index: 1000;
display: flex; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 26px;
background: #14100d; background: #14100d;
transition: opacity 0.6s ease; transition: opacity 0.6s ease;
} }
#loading.hidden { opacity: 0; pointer-events: none; } #loading.hidden { opacity: 0; pointer-events: none; }
.loading-box { display: flex; flex-direction: column; align-items: center; gap: 18px; } #loading-canvas {
.spinner { width: 500px; height: 500px;
width: 54px; height: 54px; max-width: 88vw; max-height: 88vw;
border: 3px solid rgba(201, 162, 39, 0.22);
border-top-color: #c9a227;
border-radius: 50%;
animation: spin 0.9s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } #loading-canvas canvas { width: 100%; height: 100%; display: block; }
.loading-text { .loading-text {
font-family: var(--serif); font-family: var(--serif);
font-size: 14px; letter-spacing: 0.3em; font-size: 13px;
color: rgba(230, 216, 182, 0.7); letter-spacing: 0.2em;
color: rgba(224, 196, 106, 0.8);
} }
/* 顶部标题 */ /* 顶部标题 */
@@ -102,39 +97,35 @@ html, body {
color: rgba(230,216,182,0.65); font-family: var(--serif); color: rgba(230,216,182,0.65); font-family: var(--serif);
} }
/* 视图切换(位于底部导航栏内) */ /* 视图切换(单按钮,图标循环切换) */
.view-switch {
display: flex; align-items: center; gap: 8px;
}
.view-btn { .view-btn {
font-family: var(--serif); font-size: 14px; letter-spacing: 0.1em; flex-shrink: 0;
padding: 8px 16px; color: var(--paper); cursor: pointer; width: 32px; height: 32px;
background: rgba(20,14,10,0.55); border: 1px solid rgba(201,162,39,0.35); display: flex; align-items: center; justify-content: center;
border-radius: 3px; backdrop-filter: blur(4px); color: var(--gold-soft); cursor: pointer;
transition: all 0.25s ease; background: rgba(255,255,255,0.04); border: 1px solid rgba(201,162,39,0.4);
} border-radius: 50%;
.view-btn:hover { border-color: var(--gold); color: #fff; } transition: all 0.2s ease;
.view-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
.view-btn.active {
background: linear-gradient(180deg, rgba(201,162,39,0.85), rgba(150,115,20,0.85));
color: #1c1308; border-color: var(--gold); font-weight: 600;
} }
.view-btn:hover { background: rgba(201,162,39,0.25); color: #fff; }
.v-icon { display: none; width: 17px; height: 17px; }
.view-btn[data-view="binder"] .v-binder { display: block; }
.view-btn[data-view="timeline"] .v-timeline { display: block; }
.view-btn[data-view="globe"] .v-globe { display: block; }
/* 底部导航 */ /* 底部导航 */
.bottom-bar { .bottom-bar {
left: 50%; bottom: 22px; transform: translateX(-50%); left: 50%; bottom: 26px; transform: translateX(-50%);
display: flex; align-items: center; gap: 16px; display: flex; align-items: center; gap: 12px;
padding: 10px 18px; border-radius: 4px; padding: 5px 14px; border-radius: 4px;
background: rgba(20,14,10,0.6); border: 1px solid rgba(201,162,39,0.25); background: rgba(20,14,10,0.6); border: 1px solid rgba(201,162,39,0.25);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
} }
.nav-btn { .nav-btn {
font-family: var(--serif); font-size: 16px; line-height: 1; flex-shrink: 0;
width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-family: var(--serif); font-size: 15px; line-height: 1;
width: 32px; height: 32px; border-radius: 50%;
color: var(--gold-soft); cursor: pointer; color: var(--gold-soft); cursor: pointer;
background: rgba(255,255,255,0.04); border: 1px solid rgba(201,162,39,0.4); background: rgba(255,255,255,0.04); border: 1px solid rgba(201,162,39,0.4);
transition: all 0.2s ease; transition: all 0.2s ease;
@@ -142,13 +133,13 @@ html, body {
.nav-btn:hover { background: rgba(201,162,39,0.25); color: #fff; transform: scale(1.06); } .nav-btn:hover { background: rgba(201,162,39,0.25); color: #fff; transform: scale(1.06); }
.nav-btn:active { transform: scale(0.96); } .nav-btn:active { transform: scale(0.96); }
.flip-hint { width: auto; border-radius: 20px; padding: 0 18px; letter-spacing: 0.12em; font-size: 14px; } .flip-hint { width: auto; border-radius: 20px; padding: 0 18px; letter-spacing: 0.12em; font-size: 14px; }
.page-indicator { display: flex; align-items: baseline; gap: 6px; min-width: 200px; justify-content: center; } .page-indicator { display: flex; align-items: center; gap: 6px; min-width: 0; justify-content: center; }
#page-num { font-size: 24px; color: var(--gold-soft); font-family: var(--serif); } #page-num { font-size: 22px; color: var(--gold-soft); font-family: var(--serif); }
.sep { color: rgba(230,216,182,0.5); } .sep { color: rgba(230,216,182,0.5); }
#page-total { color: rgba(230,216,182,0.6); font-family: var(--serif); } #page-total { color: rgba(230,216,182,0.6); font-family: var(--serif); }
.page-name { .page-name {
font-size: 14px; color: var(--paper); letter-spacing: 0.05em; margin-left: 10px; font-size: 14px; color: var(--paper); letter-spacing: 0.05em; margin-left: 10px;
flex: 0 0 160px; /* 固定宽度,容纳多数词条名,避免翻页时布局抖动 */ flex: 0 0 160px; /* 固定宽度,导航条不随词条名长短变化 */
text-align: center; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
@@ -164,7 +155,7 @@ html, body {
} }
.detail-card.hidden { opacity: 0; pointer-events: none; transform: translateY(14px); } .detail-card.hidden { opacity: 0; pointer-events: none; transform: translateY(14px); }
.detail-head { display: flex; justify-content: space-between; align-items: center; } .detail-head { display: flex; justify-content: space-between; align-items: center; }
.detail-no { font-family: var(--serif); font-size: 13px; letter-spacing: 0.14em; color: var(--ink-soft); } .detail-no { font-family: Consolas, "Courier New", "Lucida Console", monospace; font-size: 13px; letter-spacing: 0.02em; color: var(--ink-soft); }
.detail-cat { .detail-cat {
font-size: 12px; padding: 2px 10px; border-radius: 2px; font-size: 12px; padding: 2px 10px; border-radius: 2px;
background: var(--leather); color: var(--gold-soft); letter-spacing: 0.2em; background: var(--leather); color: var(--gold-soft); letter-spacing: 0.2em;
@@ -185,10 +176,12 @@ html, body {
.detail-sketch { margin-top: 12px; border-top: 1px dashed rgba(90,61,43,0.4); padding-top: 10px; } .detail-sketch { margin-top: 12px; border-top: 1px dashed rgba(90,61,43,0.4); padding-top: 10px; }
.detail-sketch canvas { width: 100%; height: 170px; object-fit: contain; border-radius: 2px; opacity: 0.94; } .detail-sketch canvas { width: 100%; height: 170px; object-fit: contain; border-radius: 2px; opacity: 0.94; }
/* 提示 */ /* 提示(导航条下方) */
.hint { .hint {
left: 26px; bottom: 26px; font-size: 12px; letter-spacing: 0.06em; left: 50%; transform: translateX(-50%); bottom: 5px;
font-size: 12px; letter-spacing: 0.06em;
color: rgba(230,216,182,0.55); color: rgba(230,216,182,0.55);
white-space: nowrap;
} }
@media (max-width: 760px) { @media (max-width: 760px) {
+118 -71
View File
@@ -7,9 +7,6 @@ export const PAGE_W = 1.6; // 世界单位:页宽
export const PAGE_H = 2.24; // 世界单位:页高 export const PAGE_H = 2.24; // 世界单位:页高
export const TEX_W = 768; export const TEX_W = 768;
export const TEX_H = 1075; export const TEX_H = 1075;
// 输出纹理与绘制分辨率一致,保证文字清晰(显存靠懒加载 + LRU 控制,不牺牲画质)
export const TEX_OUT_W = 768;
export const TEX_OUT_H = 1075;
export const HOLE_U = [0.1, 0.26, 0.42, 0.58, 0.74, 0.9]; // 活页孔(自顶部归一化,6孔等距) export const HOLE_U = [0.1, 0.26, 0.42, 0.58, 0.74, 0.9]; // 活页孔(自顶部归一化,6孔等距)
export const HOLE_X = 0.065; // 孔中心相对页内缘(环前交点 0.085 − 页缘空隙 0.02) export const HOLE_X = 0.065; // 孔中心相对页内缘(环前交点 0.085 − 页缘空隙 0.02)
export const HOLE_R = 0.029; // 孔半径 ≈5.5mm 直径(真实活页本孔径) export const HOLE_R = 0.029; // 孔半径 ≈5.5mm 直径(真实活页本孔径)
@@ -17,6 +14,7 @@ export const HOLE_R = 0.029; // 孔半径 ≈5.5mm 直径(真实活页
const SERIF = 'Georgia, "Noto Serif SC", serif'; // 正文与标题字体一致(时间轴字体) const SERIF = 'Georgia, "Noto Serif SC", serif'; // 正文与标题字体一致(时间轴字体)
const KAI = '"Kaiti SC","STKaiti","KaiTi","Noto Serif SC",serif'; const KAI = '"Kaiti SC","STKaiti","KaiTi","Noto Serif SC",serif';
const NUM = 'Georgia,"Times New Roman",serif'; const NUM = 'Georgia,"Times New Roman",serif';
const CODE = 'Consolas, "Courier New", "Lucida Console", monospace'; // 编目号等宽字体
const TITLE_FONT = '"Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif'; // 与时间轴标题字体一致 const TITLE_FONT = '"Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif'; // 与时间轴标题字体一致
// 版面边距(左边缘避开活页孔,右/上/下为常规边距) // 版面边距(左边缘避开活页孔,右/上/下为常规边距)
@@ -69,28 +67,6 @@ export function makePaper(w, h, { tone = 0, ruled = false, margin = false, seed
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
} }
// 烫金斑点:不规则金色斑点(淡、纯色、硬边)
ctx.fillStyle = 'rgba(212, 168, 64, 0.5)';
const goldBlob = (x, y, r) => {
const pts = 8;
ctx.beginPath();
for (let p = 0; p < pts; p++) {
const a = (p / pts) * Math.PI * 2;
const rr = r * (0.55 + rand() * 0.9);
const px = x + Math.cos(a) * rr;
const py = y + Math.sin(a) * rr;
if (p === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
};
for (let i = 0; i < 70; i++) {
goldBlob(rand() * w, rand() * h, rand() * 8 + 3);
}
for (let i = 0; i < 320; i++) {
goldBlob(rand() * w, rand() * h, rand() * 2.4 + 0.8);
}
// 边缘做旧(暗角) // 边缘做旧(暗角)
const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.4, w / 2, h / 2, Math.max(w, h) * 0.72); const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.4, w / 2, h / 2, Math.max(w, h) * 0.72);
edge.addColorStop(0, 'rgba(0,0,0,0)'); edge.addColorStop(0, 'rgba(0,0,0,0)');
@@ -200,63 +176,56 @@ export function renderFront(entry) {
drawBorder(ctx, w, h); drawBorder(ctx, w, h);
drawHoles(ctx, w, h); drawHoles(ctx, w, h);
// 页眉:编号(去掉右上角印章标记) // 页眉:编目号(档案式编号,Consolas 等宽字体)
text(ctx, '编 号 · ' + entry.id, CONTENT_L, 92, { font: NUM, size: 26, color: '#5a432c' }); text(ctx, 'NO.' + (entry.code || entry.id), CONTENT_L, 92, { font: CODE, size: 23, color: '#33271a', weight: '700' });
// 顶部分隔线 // 顶部分隔线(网格)
ctx.strokeStyle = 'rgba(90,61,43,0.4)'; ctx.strokeStyle = 'rgba(90,61,43,0.45)';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(CONTENT_L, 160); ctx.lineTo(CONTENT_R, 160); ctx.stroke(); ctx.beginPath(); ctx.moveTo(CONTENT_L, 160); ctx.lineTo(CONTENT_R, 160); ctx.stroke();
// 名称(大字,居中,标题字体与时间轴一致) // 名称(大字,居中)
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.font = `700 ${fitFont(ctx, entry.name, CONTENT_W - 40, 36, 86, TITLE_FONT)}px ${TITLE_FONT}`; ctx.font = `700 ${fitFont(ctx, entry.name, CONTENT_W - 40, 36, 86, TITLE_FONT)}px ${TITLE_FONT}`;
ctx.fillStyle = '#33271a'; ctx.fillStyle = '#33271a';
ctx.fillText(entry.name, w / 2, 248); ctx.fillText(entry.name, w / 2, 210);
// 名称下划线装饰
ctx.strokeStyle = info.color; // —— 时间 / 地区(左对齐,字体同标题)——
ctx.lineWidth = 2.4; const gridTop = 276, rowH = 64;
ctx.beginPath(); ctx.moveTo(w / 2 - 220, 314); ctx.lineTo(w / 2 + 220, 314); ctx.stroke(); const gLeft = CONTENT_L, gRight = CONTENT_R;
ctx.fillStyle = info.color; const gBottom = gridTop + rowH * 2;
ctx.beginPath(); ctx.arc(w / 2, 314, 4, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(120,95,60,0.5)';
ctx.lineWidth = 1;
for (let r = 0; r <= 2; r++) {
const y = gridTop + r * rowH;
ctx.beginPath(); ctx.moveTo(gLeft, y); ctx.lineTo(gRight, y); ctx.stroke();
}
// 登记字段
const labelX = CONTENT_L + 56; // 标签右对齐
const colonX = CONTENT_L + 64; // 冒号
const valX = CONTENT_L + 82; // 值起始
const fy = 372, rowH = 62;
const fields = [ const fields = [
['时 间', entry.time], ['时 间', entry.time],
['地 区', entry.region], ['地 区', entry.region],
]; ];
fields.forEach(([label, value], i) => { fields.forEach(([label, value], i) => {
const y = fy + i * rowH; const cy = gridTop + i * rowH + rowH / 2;
text(ctx, label, labelX, y, { font: SERIF, size: 26, color: '#7a5c38', align: 'right' }); text(ctx, label, gLeft, cy, { font: TITLE_FONT, size: 26, color: '#7a5c38', baseline: 'middle' });
text(ctx, ':', colonX, y, { font: SERIF, size: 26, color: '#7a5c38' }); text(ctx, value, gLeft + 96, cy, { font: TITLE_FONT, size: 26, color: '#33271a', baseline: 'middle' });
text(ctx, value, valX, y, { font: SERIF, size: 26, color: '#33271a' });
// 值后虚线延伸至右界
const vw = ctx.measureText(value).width;
ctx.strokeStyle = 'rgba(120,95,60,0.35)';
ctx.lineWidth = 0.9;
ctx.setLineDash([2, 5]);
ctx.beginPath(); ctx.moveTo(valX + vw + 14, y + 12); ctx.lineTo(CONTENT_R, y + 12); ctx.stroke();
ctx.setLineDash([]);
}); });
// 描述区 // —— 描述区 ——
const dy = 600; const dy = 482;
text(ctx, '描 述', CONTENT_L, dy, { font: SERIF, size: 26, color: '#7a5c38' }); text(ctx, '描 述', CONTENT_L, dy, { font: TITLE_FONT, size: 26, color: '#7a5c38' });
ctx.strokeStyle = 'rgba(120,95,60,0.3)'; ctx.strokeStyle = 'rgba(120,95,60,0.45)';
ctx.lineWidth = 0.9; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(CONTENT_L, dy + 18); ctx.lineTo(CONTENT_R, dy + 18); ctx.stroke(); ctx.beginPath(); ctx.moveTo(CONTENT_L, dy + 22); ctx.lineTo(CONTENT_R, dy + 22); ctx.stroke();
ctx.font = `400 24px ${SERIF}`; ctx.font = `400 24px ${TITLE_FONT}`;
ctx.fillStyle = '#33271a'; ctx.fillStyle = '#33271a';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
const maxW = CONTENT_W; const maxW = CONTENT_W;
let line = '', yy = dy + 40; let line = '', yy = dy + 44;
for (const ch of entry.description) { for (const ch of entry.description) {
const test = line + ch; const test = line + ch;
if (ctx.measureText(test).width > maxW && line) { if (ctx.measureText(test).width > maxW && line) {
@@ -334,7 +303,9 @@ let placeholder = null;
function makeTexture(canvas) { function makeTexture(canvas) {
const tex = new THREE.CanvasTexture(canvas); const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace; tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8; // 保持倾斜视角下的清晰度 tex.anisotropy = 4; // 倾斜视角已够用,降低采样带宽
tex.generateMipmaps = false; // 页面距离变化小,关 mipmap 省 ~33% 显存
tex.minFilter = THREE.LinearFilter;
return tex; return tex;
} }
@@ -349,32 +320,36 @@ function renderPlaceholder() {
export function getPlaceholderTextures() { export function getPlaceholderTextures() {
if (!placeholder) { if (!placeholder) {
const front = makeTexture(downscale(renderPlaceholder())); const front = makeTexture(renderPlaceholder());
const back = makeTexture(downscale(renderPlaceholder())); const back = makeTexture(renderPlaceholder());
placeholder = { front, back }; placeholder = { front, back };
} }
return placeholder; return placeholder;
} }
function generatePair(entry) { function generatePair(entry) {
const front = makeTexture(downscale(renderFront(entry))); const front = makeTexture(renderFront(entry));
const back = makeTexture(downscale(renderBack(entry))); const back = makeTexture(renderBack(entry));
return { front, back }; return { front, back };
} }
function evictIfNeeded() { function evictCache(cache, maxPages) {
if (texCache.size <= MAX_CACHED_PAGES) return; if (cache.size <= maxPages) return;
const candidates = [...texCache.values()] const candidates = [...cache.values()]
.filter((r) => r.refs === 0) .filter((r) => r.refs === 0)
.sort((a, b) => a.lastUsed - b.lastUsed); .sort((a, b) => a.lastUsed - b.lastUsed);
while (texCache.size > MAX_CACHED_PAGES && candidates.length) { while (cache.size > maxPages && candidates.length) {
const r = candidates.shift(); const r = candidates.shift();
r.front.dispose(); r.front.dispose();
r.back.dispose(); r.back.dispose();
texCache.delete(r.entry.id); cache.delete(r.entry.id);
} }
} }
function evictIfNeeded() {
evictCache(texCache, MAX_CACHED_PAGES);
}
export function acquirePageTextures(entry) { export function acquirePageTextures(entry) {
let rec = texCache.get(entry.id); let rec = texCache.get(entry.id);
if (!rec) { if (!rec) {
@@ -392,6 +367,21 @@ export function acquirePageTextures(entry) {
return { front: rec.front, back: rec.back }; return { front: rec.front, back: rec.back };
} }
// 预生成纹理(不改变引用计数,供翻页前预热用,避免翻页中途同步生成造成卡顿)
export function prefetchPageTextures(entry) {
let rec = texCache.get(entry.id);
if (!rec) {
rec = { entry, front: null, back: null, refs: 0, lastUsed: 0 };
texCache.set(entry.id, rec);
}
if (!rec.front) {
const pair = generatePair(entry);
rec.front = pair.front;
rec.back = pair.back;
evictIfNeeded();
}
}
export function releasePageTextures(entry) { export function releasePageTextures(entry) {
const rec = texCache.get(entry.id); const rec = texCache.get(entry.id);
if (!rec) return; if (!rec) return;
@@ -399,6 +389,63 @@ export function releasePageTextures(entry) {
if (rec.refs === 0) rec.lastUsed = performance.now(); if (rec.refs === 0) rec.lastUsed = performance.now();
} }
// —— 低分辨率纹理池(时间轴卡片用,显示尺寸小,省显存与上传带宽)——
const TEX_LOW_W = 256;
const TEX_LOW_H = 358;
const MAX_CACHED_PAGES_LOW = 40;
const texCacheLow = new Map();
let placeholderLow = null;
function downscaleLow(src) {
const c = document.createElement('canvas');
c.width = TEX_LOW_W;
c.height = TEX_LOW_H;
const ctx = c.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(src, 0, 0, TEX_LOW_W, TEX_LOW_H);
return c;
}
function generatePairLow(entry) {
const front = makeTexture(downscaleLow(renderFront(entry)));
const back = makeTexture(downscaleLow(renderBack(entry)));
return { front, back };
}
export function getPlaceholderTexturesLow() {
if (!placeholderLow) {
const front = makeTexture(downscaleLow(renderPlaceholder()));
const back = makeTexture(downscaleLow(renderPlaceholder()));
placeholderLow = { front, back };
}
return placeholderLow;
}
export function acquirePageTexturesLow(entry) {
let rec = texCacheLow.get(entry.id);
if (!rec) {
rec = { entry, front: null, back: null, refs: 0, lastUsed: 0 };
texCacheLow.set(entry.id, rec);
}
rec.refs++;
rec.lastUsed = performance.now();
if (!rec.front) {
const pair = generatePairLow(entry);
rec.front = pair.front;
rec.back = pair.back;
evictCache(texCacheLow, MAX_CACHED_PAGES_LOW);
}
return { front: rec.front, back: rec.back };
}
export function releasePageTexturesLow(entry) {
const rec = texCacheLow.get(entry.id);
if (!rec) return;
rec.refs = Math.max(0, rec.refs - 1);
if (rec.refs === 0) rec.lastUsed = performance.now();
}
// 双面材质:同一 mesh 用 gl_FrontFacing 在正/反面显示不同纹理, // 双面材质:同一 mesh 用 gl_FrontFacing 在正/反面显示不同纹理,
// 替代原先 front/back 两个 mesh,把每页 draw call 减半。 // 替代原先 front/back 两个 mesh,把每页 draw call 减半。
export function makeTwoSidedMaterial(front, back, { export function makeTwoSidedMaterial(front, back, {
+513 -47
View File
@@ -581,68 +581,534 @@ export function renderSketch(entry, size = 760) {
}, },
book() { book() {
// 摊开的书 // 封面(略大一圈,深色底)
fill([[-0.5, -0.1], [0.0, -0.2], [0.5, -0.1], [0.5, 0.4], [0.0, 0.5], [-0.5, 0.4]], 'rgba(210,190,150,0.18)'); fill([[-0.55, -0.28], [0.55, -0.28], [0.55, 0.5], [0.0, 0.6], [-0.55, 0.5]], 'rgba(120,80,50,0.10)');
line([[-0.5, -0.1], [0.0, -0.2], [0.5, -0.1], [0.5, 0.4], [0.0, 0.5], [-0.5, 0.4], [-0.5, -0.1]], { lw: 2.4 }); line([[-0.55, -0.28], [0.55, -0.28], [0.55, 0.5], [0.0, 0.6], [-0.55, 0.5], [-0.55, -0.28]], { lw: 2.6 });
line([[0.0, -0.2], [0.0, 0.5]], { lw: 2 }); // 摊开的书页
for (let i = 0; i < 4; i++) { fill([[-0.5, -0.2], [0.0, -0.3], [0.5, -0.2], [0.5, 0.42], [0.0, 0.52], [-0.5, 0.42]], 'rgba(210,190,150,0.22)');
const y = -0.05 + i * 0.14; line([[-0.5, -0.2], [0.0, -0.3], [0.5, -0.2], [0.5, 0.42], [0.0, 0.52], [-0.5, 0.42], [-0.5, -0.2]], { lw: 2.4 });
line([[-0.4, y], [-0.08, y]], { lw: 1.2, color: INK_SOFT, passes: 1, wobble: 0.004 }); // 中缝与页缘叠层
line([[0.08, y], [0.4, y]], { lw: 1.2, color: INK_SOFT, passes: 1, wobble: 0.004 }); line([[0.0, -0.3], [0.0, 0.52]], { lw: 1.8, color: INK_SOFT });
for (let i = 0; i < 3; i++) {
const o = i * 0.016;
line([[-0.5 + o, -0.2], [-0.5 + o, 0.42]], { lw: 1, color: INK_FAINT, passes: 1 });
line([[0.5 - o, -0.2], [0.5 - o, 0.42]], { lw: 1, color: INK_FAINT, passes: 1 });
} }
hatch([[-0.5, -0.1], [0.0, -0.2], [0.5, -0.1], [0.5, 0.4], [0.0, 0.5], [-0.5, 0.4]], { spacing: 0.04, angle: -45, color: INK_FAINT, lw: 1 }); // 左右页文字行
for (let i = 0; i < 6; i++) {
const y = -0.14 + i * 0.105;
line([[-0.42, y], [-0.08, y]], { lw: 1.3, color: INK_SOFT, passes: 1, wobble: 0.004 });
line([[0.08, y], [0.42, y]], { lw: 1.3, color: INK_SOFT, passes: 1, wobble: 0.004 });
}
// 右页小插图
circle(0.25, 0.12, 0.09, { lw: 1.4, passes: 1, color: INK_SOFT });
line([[0.18, 0.12], [0.32, 0.12]], { lw: 1, color: INK_FAINT, passes: 1 });
line([[0.25, 0.05], [0.25, 0.19]], { lw: 1, color: INK_FAINT, passes: 1 });
// 书签丝带
line([[0.0, 0.52], [0.06, 0.8]], { lw: 1.6, color: '#a03a2a' });
line([[0.06, 0.8], [0.0, 0.76], [0.09, 0.7]], { lw: 1.4, color: '#a03a2a', passes: 1 });
// 阴影排线
hatch([[-0.5, -0.2], [0.0, -0.3], [0.5, -0.2], [0.5, 0.42], [0.0, 0.52], [-0.5, 0.42]], { spacing: 0.045, angle: -45, color: INK_FAINT, lw: 1 });
}, },
bridge() { bridge() {
line([[-0.7, 0.45], [0.7, 0.45]], { lw: 1.6, color: INK_SOFT, passes: 1 }); // 水面波纹
line([[-0.6, 0.6], [0.6, 0.6]], { lw: 1.2, color: INK_FAINT, passes: 1 }); line([[-0.72, 0.45], [0.72, 0.45]], { lw: 1.6, color: INK_SOFT, passes: 1 });
fill([[-0.5, 0.45], [-0.5, -0.05], [0.5, -0.05], [0.5, 0.45]], 'rgba(150,140,130,0.10)'); line([[-0.6, 0.56], [-0.3, 0.58], [0.0, 0.56], [0.3, 0.58], [0.6, 0.56]], { lw: 1.1, color: INK_FAINT, passes: 1, wobble: 0.01 });
line([[-0.5, 0.45], [-0.5, 0.05], [-0.35, -0.2], [0.0, -0.35], [0.35, -0.2], [0.5, 0.05], [0.5, 0.45]], { lw: 2.6 }); // 桥体拱形
line([[-0.55, 0.45], [-0.55, 0.28], [0.55, 0.28], [0.55, 0.45]], { lw: 1.6, color: INK_SOFT }); fill([[-0.5, 0.32], [-0.5, 0.06], [-0.34, -0.2], [0.0, -0.3], [0.34, -0.2], [0.5, 0.06], [0.5, 0.32]], 'rgba(150,140,130,0.12)');
for (let i = 0; i < 7; i++) { line([[-0.5, 0.32], [-0.5, 0.06], [-0.34, -0.2], [0.0, -0.3], [0.34, -0.2], [0.5, 0.06], [0.5, 0.32]], { lw: 2.6 });
const x = -0.45 + i * 0.15; // 拱券石缝
line([[x, 0.28], [x, 0.4]], { lw: 1.2, passes: 1, color: INK_SOFT }); for (let i = 1; i < 7; i++) {
const t = i / 7;
const x = -0.5 + t;
const y = 0.06 - Math.sin(Math.PI * t) * 0.36;
line([[x, y], [x, 0.32]], { lw: 1, color: INK_SOFT, passes: 1 });
} }
line([[-0.3, 0.52], [0.0, 0.56], [0.3, 0.52]], { lw: 1, color: INK_FAINT, passes: 1 }); // 桥面与栏杆
line([[-0.62, 0.32], [0.62, 0.32]], { lw: 2.6 });
line([[-0.64, 0.32], [-0.64, 0.2], [0.64, 0.2], [0.64, 0.32]], { lw: 1.6, color: INK_SOFT, passes: 1 });
for (let i = 0; i <= 10; i++) {
const x = -0.55 + i * 0.11;
line([[x, 0.2], [x, 0.29]], { lw: 1.4, passes: 1 });
}
line([[-0.6, 0.29], [0.6, 0.29]], { lw: 1.2, passes: 1, color: INK_SOFT });
// 倒影
line([[-0.4, 0.48], [0.0, 0.52], [0.4, 0.48]], { lw: 1.1, color: INK_FAINT, passes: 1, dash: [6, 5] });
// 两岸
line([[-0.74, 0.45], [-0.74, 0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[0.74, 0.45], [0.74, 0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
hatch([[-0.5, 0.32], [-0.5, 0.06], [-0.34, -0.2], [0.0, -0.3], [0.34, -0.2], [0.5, 0.06], [0.5, 0.32]], { spacing: 0.04, angle: -60, color: INK_FAINT, lw: 1 });
}, },
flag() { flag() {
line([[-0.4, 0.6], [-0.4, -0.7]], { lw: 2.4 }); // 地面与旗杆
fill([[-0.4, -0.65], [-0.1, -0.6], [0.05, -0.45], [-0.25, -0.5]], 'rgba(160,58,42,0.16)'); line([[-0.62, 0.6], [0.62, 0.6]], { lw: 1.6, color: INK_FAINT });
line([[-0.4, -0.65], [-0.1, -0.6], [0.05, -0.45], [-0.25, -0.5], [-0.4, -0.65]], { lw: 2.2 }); line([[-0.42, 0.6], [-0.42, -0.74]], { lw: 2.6 });
line([[-0.32, -0.55], [0.0, -0.48]], { lw: 1.4, color: INK_SOFT, passes: 1 }); line([[-0.47, 0.6], [-0.47, -0.74]], { lw: 1.2, color: INK_SOFT, passes: 1 });
line([[-0.25, -0.5], [-0.15, -0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 }); circle(-0.42, -0.77, 0.04, { lw: 1.8, passes: 1 });
line([[-0.6, 0.62], [0.6, 0.62]], { lw: 1.6, color: INK_FAINT }); // 飘动的旗面(S 形两褶)
for (let i = 0; i < 7; i++) { fill([[-0.42, -0.7], [-0.1, -0.62], [0.18, -0.5], [0.04, -0.36], [-0.42, -0.42]], 'rgba(160,58,42,0.16)');
const a = -0.5 + i * 0.3; line([[-0.42, -0.7], [-0.1, -0.62], [0.18, -0.5], [0.04, -0.36], [-0.42, -0.42], [-0.42, -0.7]], { lw: 2.4 });
line([[0.05 + Math.cos(a) * 0.3, -0.3 + Math.sin(a) * 0.2], [0.05 + Math.cos(a) * 0.42, -0.3 + Math.sin(a) * 0.3]], { lw: 1.2, color: INK_FAINT, passes: 1 }); // 褶皱与星徽
line([[-0.42, -0.56], [-0.08, -0.5], [0.1, -0.42]], { lw: 1.4, color: INK_SOFT, passes: 1 });
dot(-0.2, -0.55, 0.035, '#a03a2a');
// 底部垂穗
line([[0.04, -0.36], [0.06, -0.27]], { lw: 1.2, color: INK_SOFT, passes: 1 });
line([[0.18, -0.5], [0.24, -0.43]], { lw: 1.2, color: INK_SOFT, passes: 1 });
// 旗面阴影
hatch([[-0.42, -0.7], [-0.1, -0.62], [0.18, -0.5], [0.04, -0.36], [-0.42, -0.42]], { spacing: 0.04, angle: -40, color: INK_FAINT, lw: 1 });
// 光芒
for (let i = 0; i < 6; i++) {
const a = -0.9 + i * 0.35;
line([[0.1 + Math.cos(a) * 0.28, -0.12 + Math.sin(a) * 0.18], [0.1 + Math.cos(a) * 0.4, -0.12 + Math.sin(a) * 0.26]], { lw: 1.2, color: INK_FAINT, passes: 1 });
} }
}, },
scale() { scale() {
line([[0.0, 0.6], [0.0, -0.15]], { lw: 2.4 }); // 三级底座
line([[-0.55, -0.05], [0.55, -0.05]], { lw: 2.2 }); line([[-0.34, 0.6], [0.34, 0.6]], { lw: 2.6 });
circle(0.0, -0.1, 0.06, { lw: 1.8, passes: 1 }); line([[-0.26, 0.66], [0.26, 0.66]], { lw: 2.2 });
line([[-0.55, -0.05], [-0.55, 0.2]], { lw: 1.6, passes: 1 }); line([[-0.18, 0.72], [0.18, 0.72]], { lw: 1.8, color: INK_SOFT });
line([[0.55, -0.05], [0.55, 0.2]], { lw: 1.6, passes: 1 }); // 立柱与柱头
line([[-0.72, 0.2], [-0.38, 0.2]], { lw: 2 }); line([[-0.05, 0.6], [-0.05, -0.06]], { lw: 2.4 });
line([[0.38, 0.2], [0.72, 0.2]], { lw: 2 }); line([[0.05, 0.6], [0.05, -0.06]], { lw: 1.2, color: INK_SOFT, passes: 1 });
line([[-0.7, 0.2], [-0.72, 0.34], [-0.38, 0.34], [-0.4, 0.2]], { lw: 1.6, color: INK_SOFT, passes: 1 }); circle(0.0, -0.09, 0.06, { lw: 2, passes: 1 });
line([[0.38, 0.2], [0.4, 0.34], [0.7, 0.34], [0.72, 0.2]], { lw: 1.6, color: INK_SOFT, passes: 1 }); // 横梁与支点
line([[-0.3, 0.6], [0.3, 0.6]], { lw: 2.6 }); line([[-0.6, -0.02], [0.6, -0.02]], { lw: 2.4 });
line([[-0.2, 0.68], [0.2, 0.68]], { lw: 2 }); line([[-0.6, -0.1], [0.6, -0.1]], { lw: 1.2, color: INK_SOFT, passes: 1 });
circle(0.0, -0.05, 0.07, { lw: 2, passes: 1 });
// 吊链(两段折线)
line([[-0.6, -0.02], [-0.6, 0.2]], { lw: 1.6, passes: 1 });
line([[-0.6, 0.2], [-0.45, 0.28]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[-0.45, 0.28], [-0.45, 0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[0.6, -0.02], [0.6, 0.2]], { lw: 1.6, passes: 1 });
line([[0.6, 0.2], [0.45, 0.28]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[0.45, 0.28], [0.45, 0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
// 左托盘
fill([[-0.78, 0.3], [-0.12, 0.3], [-0.2, 0.42], [-0.7, 0.42]], 'rgba(150,140,130,0.12)');
line([[-0.78, 0.3], [-0.12, 0.3]], { lw: 2.2 });
line([[-0.78, 0.3], [-0.7, 0.42], [-0.2, 0.42], [-0.12, 0.3]], { lw: 1.6, color: INK_SOFT, passes: 1 });
// 右托盘
fill([[0.12, 0.3], [0.78, 0.3], [0.7, 0.42], [0.2, 0.42]], 'rgba(150,140,130,0.12)');
line([[0.12, 0.3], [0.78, 0.3]], { lw: 2.2 });
line([[0.12, 0.3], [0.2, 0.42], [0.7, 0.42], [0.78, 0.3]], { lw: 1.6, color: INK_SOFT, passes: 1 });
// 托盘阴影
hatch([[-0.78, 0.3], [-0.12, 0.3], [-0.2, 0.42], [-0.7, 0.42]], { spacing: 0.04, angle: -30, color: INK_FAINT, lw: 1 });
hatch([[0.12, 0.3], [0.78, 0.3], [0.7, 0.42], [0.2, 0.42]], { spacing: 0.04, angle: -30, color: INK_FAINT, lw: 1 });
// 横梁装饰点
dot(-0.3, -0.05, 0.02, INK_SOFT);
dot(0.3, -0.05, 0.02, INK_SOFT);
}, },
sword() { sword() {
line([[0.1, -0.6], [0.0, -0.05], [-0.02, 0.15]], { lw: 2.6 }); // 盾牌(背景)
line([[0.1, -0.6], [0.05, -0.05], [0.02, 0.15]], { lw: 1.4, color: INK_SOFT, passes: 1 }); fill([[-0.45, -0.2], [0.45, -0.2], [0.55, 0.15], [0.45, 0.5], [0.0, 0.68], [-0.45, 0.5], [-0.55, 0.15]], 'rgba(120,95,70,0.10)');
fill([[0.1, -0.6], [0.0, -0.05], [0.05, -0.05]], 'rgba(150,150,160,0.10)'); line([[-0.45, -0.2], [0.45, -0.2], [0.55, 0.15], [0.45, 0.5], [0.0, 0.68], [-0.45, 0.5], [-0.55, 0.15], [-0.45, -0.2]], { lw: 2.4 });
line([[-0.25, 0.05], [0.22, -0.02]], { lw: 3 }); // 盾面十字纹
line([[-0.02, 0.15], [0.05, 0.42]], { lw: 2.4 }); line([[0.0, -0.2], [0.0, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
circle(0.02, 0.45, 0.05, { lw: 1.6, passes: 1 }); line([[-0.35, 0.15], [0.35, 0.15]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[-0.5, 0.6], [0.5, 0.6]], { lw: 1.4, color: INK_FAINT, passes: 1 }); hatch([[-0.45, -0.2], [0.45, -0.2], [0.55, 0.15], [0.45, 0.5], [0.0, 0.68], [-0.45, 0.5], [-0.55, 0.15]], { spacing: 0.035, angle: 60, color: INK_FAINT, lw: 1 });
line([[0.15, -0.5], [0.28, -0.42]], { lw: 1.2, color: INK_FAINT, passes: 1 }); // 剑身(竖直插于盾前)
line([[0.18, -0.3], [0.32, -0.22]], { lw: 1, color: INK_FAINT, passes: 1 }); fill([[-0.1, -0.7], [0.1, -0.7], [0.1, 0.3], [-0.1, 0.3]], 'rgba(150,150,160,0.14)');
line([[-0.1, -0.7], [0.1, -0.7], [0.1, 0.3], [-0.1, 0.3], [-0.1, -0.7]], { lw: 2.6 });
line([[-0.1, -0.7], [0.0, -0.88], [0.1, -0.7]], { lw: 2.4 });
// 血槽与高光
line([[0.0, -0.6], [0.0, 0.2]], { lw: 1.2, color: INK_SOFT, passes: 1 });
line([[-0.04, -0.5], [-0.04, 0.1]], { lw: 1, color: INK_FAINT, passes: 1 });
// 护手
line([[-0.32, 0.3], [0.32, 0.3]], { lw: 3 });
// 柄与缠绕纹
fill([[-0.05, 0.3], [0.05, 0.3], [0.05, 0.52], [-0.05, 0.52]], 'rgba(90,65,45,0.12)');
line([[-0.05, 0.3], [0.05, 0.3], [0.05, 0.52], [-0.05, 0.52]], { lw: 2.2 });
for (let i = 0; i < 4; i++) {
const y = 0.34 + i * 0.05;
line([[-0.05, y], [0.05, y - 0.025]], { lw: 1, color: INK_SOFT, passes: 1 });
}
// 柄首
circle(0.0, 0.56, 0.05, { lw: 2, passes: 1 });
dot(0.0, 0.56, 0.02, '#a03a2a');
// 地面与星点
line([[-0.6, 0.72], [0.6, 0.72]], { lw: 1.4, color: INK_FAINT, passes: 1 });
dot(-0.62, -0.3, 0.02, INK_FAINT);
dot(0.62, -0.3, 0.02, INK_FAINT);
},
helmet() {
fill([[-0.4, 0.2], [0.4, 0.2], [0.35, -0.15], [0.2, -0.45], [-0.2, -0.45], [-0.35, -0.15]], 'rgba(150,150,160,0.12)');
line([[-0.4, 0.2], [0.4, 0.2], [0.35, -0.15], [0.2, -0.45], [-0.2, -0.45], [-0.35, -0.15], [-0.4, 0.2]], { lw: 2.6 });
circle(0.0, -0.45, 0.07, { lw: 2, passes: 1 });
line([[0.0, -0.5], [0.02, -0.78], [0.14, -0.74], [0.02, -0.68]], { lw: 1.8, passes: 1 });
line([[-0.4, 0.2], [-0.5, 0.5]], { lw: 2 });
line([[0.4, 0.2], [0.5, 0.5]], { lw: 2 });
line([[-0.5, 0.5], [-0.3, 0.5]], { lw: 1.6, color: INK_SOFT });
line([[0.5, 0.5], [0.3, 0.5]], { lw: 1.6, color: INK_SOFT });
line([[-0.26, 0.1], [-0.08, 0.16]], { lw: 1.8 });
line([[0.08, 0.16], [0.26, 0.1]], { lw: 1.8 });
hatch([[-0.4, 0.2], [0.4, 0.2], [0.35, -0.15], [0.2, -0.45], [-0.2, -0.45], [-0.35, -0.15]], { spacing: 0.04, angle: -30, color: INK_FAINT, lw: 1 });
},
cannon() {
line([[-0.7, 0.5], [0.7, 0.5]], { lw: 1.6, color: INK_FAINT });
fill([[-0.45, -0.05], [0.45, -0.05], [0.5, 0.2], [-0.5, 0.2]], 'rgba(90,70,50,0.12)');
line([[-0.45, -0.05], [0.45, -0.05], [0.5, 0.2], [-0.5, 0.2], [-0.45, -0.05]], { lw: 2.6 });
line([[-0.4, 0.05], [0.4, 0.05]], { lw: 1.4, color: INK_SOFT, passes: 1 });
circle(-0.38, 0.2, 0.22, { lw: 2.4 });
circle(-0.38, 0.2, 0.05, { lw: 1.6, passes: 1 });
for (let i = 0; i < 6; i++) { const a = (i / 6) * Math.PI * 2; line([[-0.38 + Math.cos(a) * 0.05, 0.2 + Math.sin(a) * 0.05], [-0.38 + Math.cos(a) * 0.22, 0.2 + Math.sin(a) * 0.22]], { lw: 1.2, passes: 1 }); }
line([[0.0, -0.05], [0.0, -0.35]], { lw: 2 });
line([[0.0, -0.35], [0.18, -0.45], [0.0, -0.4]], { lw: 1.6, passes: 1 });
circle(0.55, 0.35, 0.09, { lw: 1.6, passes: 1, fill: 'rgba(150,150,160,0.10)' });
hatch([[-0.45, -0.05], [0.45, -0.05], [0.5, 0.2], [-0.5, 0.2]], { spacing: 0.035, angle: -55, color: INK_FAINT, lw: 1 });
},
fortress() {
line([[-0.7, 0.55], [0.7, 0.55]], { lw: 1.6, color: INK_FAINT });
fill([[-0.55, 0.55], [-0.55, -0.2], [0.55, -0.2], [0.55, 0.55]], 'rgba(150,140,130,0.10)');
line([[-0.55, 0.55], [-0.55, -0.2], [0.55, -0.2], [0.55, 0.55]], { lw: 2.4 });
line([[-0.55, 0.3], [0.55, 0.3]], { lw: 1.6, color: INK_SOFT, passes: 1 });
for (let x = -0.55; x <= 0.55; x += 0.1375) { line([[x, 0.3], [x + 0.07, 0.3], [x + 0.07, 0.42], [x, 0.42]], { lw: 1.4, passes: 1 }); }
for (let x = -0.5; x <= 0.5; x += 0.2) { line([[x, 0.42], [x, 0.72]], { lw: 1.8 }); }
line([[-0.56, 0.72], [-0.4, 0.72]], { lw: 1.8 });
line([[0.4, 0.72], [0.56, 0.72]], { lw: 1.8 });
line([[-0.4, -0.2], [-0.32, -0.42], [0.32, -0.42], [0.4, -0.2]], { lw: 1.8 });
line([[0.0, -0.42], [0.0, -0.2]], { lw: 1.2, color: INK_SOFT, passes: 1 });
hatch([[-0.55, 0.55], [-0.55, 0.3], [0.55, 0.3], [0.55, 0.55]], { spacing: 0.04, angle: 45, color: INK_FAINT, lw: 1 });
},
bow() {
line([[-0.6, 0.6], [0.6, 0.6]], { lw: 1.2, color: INK_FAINT, passes: 1 });
line([[-0.55, -0.5], [-0.25, -0.1], [0.0, 0.1], [0.25, -0.1], [0.55, -0.5]], { lw: 2.6 });
line([[-0.55, -0.5], [0.55, -0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[0.0, 0.02], [0.5, -0.32]], { lw: 2 });
line([[0.5, -0.32], [0.62, -0.42]], { lw: 1.8, passes: 1 });
line([[0.5, -0.32], [0.4, -0.42], [0.5, -0.44]], { lw: 1.6, passes: 1 });
dot(0.55, -0.5, 0.03, INK_SOFT);
dot(-0.55, -0.5, 0.03, INK_SOFT);
},
shield() {
fill([[-0.5, -0.5], [0.5, -0.5], [0.55, -0.1], [0.35, 0.4], [0.0, 0.6], [-0.35, 0.4], [-0.55, -0.1]], 'rgba(150,140,130,0.10)');
line([[-0.5, -0.5], [0.5, -0.5], [0.55, -0.1], [0.35, 0.4], [0.0, 0.6], [-0.35, 0.4], [-0.55, -0.1], [-0.5, -0.5]], { lw: 2.6 });
line([[-0.5, -0.5], [0.5, -0.5]], { lw: 2 });
circle(0.0, -0.05, 0.16, { lw: 2, passes: 1 });
dot(0.0, -0.05, 0.05, '#a03a2a');
line([[0.0, 0.35], [0.0, 0.55]], { lw: 1.6, color: INK_SOFT, passes: 1 });
hatch([[-0.5, -0.5], [0.5, -0.5], [0.55, -0.1], [0.35, 0.4], [0.0, 0.6], [-0.35, 0.4], [-0.55, -0.1]], { spacing: 0.04, angle: -45, color: INK_FAINT, lw: 1 });
},
throne() {
line([[-0.6, 0.55], [0.6, 0.55]], { lw: 1.6, color: INK_FAINT });
fill([[-0.4, 0.5], [-0.4, -0.4], [0.4, -0.4], [0.4, 0.5]], 'rgba(160,120,50,0.10)');
line([[-0.4, 0.5], [-0.4, -0.4], [0.4, -0.4], [0.4, 0.5]], { lw: 2.6 });
line([[-0.34, 0.5], [-0.28, 0.72], [0.28, 0.72], [0.34, 0.5]], { lw: 2.2 });
circle(0.0, 0.66, 0.09, { lw: 1.8, passes: 1 });
line([[-0.2, -0.4], [-0.2, -0.62]], { lw: 2 });
line([[0.2, -0.4], [0.2, -0.62]], { lw: 2 });
line([[-0.28, 0.1], [0.28, 0.1]], { lw: 1.6, color: INK_SOFT, passes: 1 });
hatch([[-0.4, 0.5], [-0.4, -0.4], [0.4, -0.4], [0.4, 0.5]], { spacing: 0.04, angle: -60, color: INK_FAINT, lw: 1 });
},
scepter() {
line([[-0.05, 0.6], [0.0, -0.6]], { lw: 2.6 });
line([[0.05, 0.6], [0.08, -0.6]], { lw: 1.4, color: INK_SOFT, passes: 1 });
circle(0.0, 0.62, 0.12, { lw: 2.2 });
line([[-0.08, 0.7], [-0.16, 0.78], [0.0, 0.82], [0.16, 0.78], [0.08, 0.7]], { lw: 1.8 });
dot(0.0, 0.78, 0.05, '#a03a2a');
circle(0.03, -0.62, 0.07, { lw: 1.8, passes: 1 });
for (let i = 0; i < 5; i++) { const y = -0.4 + i * 0.2; line([[-0.05, y], [0.08, y - 0.04]], { lw: 1.2, color: INK_SOFT, passes: 1 }); }
line([[-0.4, 0.75], [0.4, 0.75]], { lw: 1.2, color: INK_FAINT, passes: 1 });
},
palace() {
line([[-0.7, 0.5], [0.7, 0.5]], { lw: 1.6, color: INK_FAINT });
fill([[-0.55, 0.5], [-0.55, -0.25], [0.55, -0.25], [0.55, 0.5]], 'rgba(160,140,110,0.10)');
line([[-0.55, 0.5], [-0.55, -0.25], [0.55, -0.25], [0.55, 0.5]], { lw: 2.4 });
line([[-0.55, 0.12], [0.55, 0.12]], { lw: 1.6, color: INK_SOFT, passes: 1 });
for (let x = -0.5; x <= 0.5; x += 0.2) { line([[x, -0.25], [x, 0.12]], { lw: 1.8 }); }
line([[-0.62, 0.12], [-0.55, 0.4], [-0.4, 0.12]], { lw: 2 });
line([[-0.3, 0.12], [-0.18, 0.4], [0.0, 0.12]], { lw: 2 });
line([[0.1, 0.12], [0.28, 0.42], [0.42, 0.12]], { lw: 2 });
line([[0.5, 0.12], [0.62, 0.4], [0.7, 0.12]], { lw: 2 });
line([[-0.32, -0.25], [-0.28, -0.5], [0.28, -0.5], [0.32, -0.25]], { lw: 1.8 });
hatch([[-0.55, 0.5], [-0.55, 0.12], [0.55, 0.12], [0.55, 0.5]], { spacing: 0.04, angle: 45, color: INK_FAINT, lw: 1 });
},
laurel() {
circle(0, 0, 0.55, { lw: 2.4 });
circle(0, 0, 0.42, { lw: 1.2, color: INK_FAINT, passes: 1 });
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
const x = Math.cos(a), y = Math.sin(a);
const px = x * 0.48, py = y * 0.48;
line([[px, py], [px - y * 0.1 - x * 0.06, py + x * 0.1 - y * 0.06]], { lw: 1.4, color: INK_SOFT, passes: 1 });
}
dot(0, 0.42, 0.03, '#a03a2a');
},
gavel() {
line([[-0.6, 0.4], [0.6, 0.4]], { lw: 1.4, color: INK_FAINT, passes: 1 });
fill([[-0.4, 0.1], [0.4, 0.1], [0.42, 0.28], [-0.42, 0.28]], 'rgba(120,90,60,0.14)');
line([[-0.4, 0.1], [0.4, 0.1], [0.42, 0.28], [-0.42, 0.28], [-0.4, 0.1]], { lw: 2.6 });
line([[-0.32, 0.16], [0.32, 0.16]], { lw: 1.2, color: INK_SOFT, passes: 1 });
line([[-0.05, 0.28], [0.0, 0.58]], { lw: 2.2 });
circle(0.0, 0.62, 0.05, { lw: 1.6, passes: 1 });
fill([[-0.6, 0.4], [0.6, 0.4], [0.55, 0.5], [-0.55, 0.5]], 'rgba(150,140,130,0.10)');
line([[-0.6, 0.4], [0.6, 0.4], [0.55, 0.5], [-0.55, 0.5], [-0.6, 0.4]], { lw: 1.8 });
hatch([[-0.4, 0.1], [0.4, 0.1], [0.42, 0.28], [-0.42, 0.28]], { spacing: 0.04, angle: -30, color: INK_FAINT, lw: 1 });
},
columns() {
line([[-0.7, 0.55], [0.7, 0.55]], { lw: 2 });
line([[-0.66, 0.62], [0.66, 0.62]], { lw: 1.8 });
line([[-0.7, -0.5], [0.7, -0.5]], { lw: 2 });
line([[-0.66, -0.57], [0.66, -0.57]], { lw: 1.8 });
for (let x = -0.55; x <= 0.55; x += 0.22) {
line([[x, 0.55], [x, -0.5]], { lw: 2 });
line([[x - 0.02, 0.55], [x - 0.02, -0.5]], { lw: 1, color: INK_SOFT, passes: 1 });
line([[x - 0.09, 0.42], [x + 0.09, 0.42]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[x - 0.09, -0.38], [x + 0.09, -0.38]], { lw: 1.4, color: INK_SOFT, passes: 1 });
}
},
document() {
fill([[-0.45, -0.55], [0.45, -0.55], [0.5, -0.2], [0.45, 0.5], [-0.45, 0.5], [-0.5, -0.2]], 'rgba(210,190,150,0.20)');
line([[-0.45, -0.55], [0.45, -0.55], [0.5, -0.2], [0.45, 0.5], [-0.45, 0.5], [-0.5, -0.2], [-0.45, -0.55]], { lw: 2.4 });
for (let i = 0; i < 6; i++) { const y = -0.4 + i * 0.15; line([[-0.34, y], [0.34, y]], { lw: 1.3, color: INK_SOFT, passes: 1, wobble: 0.004 }); }
line([[0.05, 0.0], [0.2, 0.1], [0.05, 0.2], [0.05, 0.0]], { lw: 1.6, passes: 1 });
dot(0.05, 0.1, 0.03, '#a03a2a');
line([[-0.05, 0.5], [0.0, 0.72]], { lw: 1.6, color: '#a03a2a' });
line([[0.0, 0.72], [-0.06, 0.68], [0.04, 0.64]], { lw: 1.4, color: '#a03a2a', passes: 1 });
hatch([[-0.45, -0.55], [0.45, -0.55], [0.5, -0.2], [0.45, 0.5], [-0.45, 0.5], [-0.5, -0.2]], { spacing: 0.045, angle: -40, color: INK_FAINT, lw: 1 });
},
gear() {
circle(0, 0, 0.5, { lw: 2.6 });
circle(0, 0, 0.32, { lw: 2 });
circle(0, 0, 0.12, { lw: 1.8, passes: 1 });
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2;
const x1 = Math.cos(a) * 0.5, y1 = Math.sin(a) * 0.5;
const x2 = Math.cos(a) * 0.62, y2 = Math.sin(a) * 0.62;
line([[x1, y1], [x2, y2]], { lw: 1.8, passes: 1 });
}
for (let i = 0; i < 6; i++) { const a = (i / 6) * Math.PI * 2 + 0.3; line([[Math.cos(a) * 0.12, Math.sin(a) * 0.12], [Math.cos(a) * 0.32, Math.sin(a) * 0.32]], { lw: 1.2, color: INK_SOFT, passes: 1 }); }
hatch([[Math.cos(0) * 0.32, Math.sin(0) * 0.32], [Math.cos(2) * 0.32, Math.sin(2) * 0.32], [Math.cos(4) * 0.32, Math.sin(4) * 0.32]], { spacing: 0.03, angle: -45, color: INK_FAINT, lw: 1 });
},
anvil() {
line([[-0.6, 0.5], [0.6, 0.5]], { lw: 1.6, color: INK_FAINT });
fill([[-0.4, 0.5], [-0.4, 0.15], [-0.25, -0.05], [0.25, -0.05], [0.4, 0.15], [0.4, 0.5]], 'rgba(90,70,50,0.14)');
line([[-0.4, 0.5], [-0.4, 0.15], [-0.25, -0.05], [0.25, -0.05], [0.4, 0.15], [0.4, 0.5]], { lw: 2.4 });
line([[-0.28, 0.5], [-0.28, 0.2], [0.28, 0.2], [0.28, 0.5]], { lw: 2 });
line([[-0.2, 0.5], [-0.2, 0.28], [0.2, 0.28], [0.2, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[-0.05, 0.28], [0.0, 0.72]], { lw: 2.2 });
line([[0.0, 0.72], [0.1, 0.62], [-0.05, 0.55]], { lw: 1.6, passes: 1 });
hatch([[-0.4, 0.5], [-0.4, 0.15], [-0.25, -0.05], [0.25, -0.05], [0.4, 0.15], [0.4, 0.5]], { spacing: 0.04, angle: 45, color: INK_FAINT, lw: 1 });
},
locomotive() {
line([[-0.7, 0.55], [0.7, 0.55]], { lw: 1.6, color: INK_FAINT });
fill([[-0.6, 0.1], [0.35, 0.1], [0.4, 0.35], [-0.55, 0.35]], 'rgba(90,70,50,0.14)');
line([[-0.6, 0.1], [0.35, 0.1], [0.4, 0.35], [-0.55, 0.35], [-0.6, 0.1]], { lw: 2.4 });
fill([[-0.3, 0.1], [-0.1, 0.1], [-0.1, -0.2], [-0.3, -0.2]], 'rgba(120,90,60,0.12)');
line([[-0.3, 0.1], [-0.1, 0.1], [-0.1, -0.2], [-0.3, -0.2], [-0.3, 0.1]], { lw: 2 });
line([[-0.2, -0.2], [-0.2, -0.42]], { lw: 1.8 });
circle(-0.3, 0.4, 0.08, { lw: 1.6, passes: 1 });
circle(0.15, 0.4, 0.08, { lw: 1.6, passes: 1 });
circle(0.55, 0.35, 0.28, { lw: 2.2 });
circle(0.55, 0.35, 0.06, { lw: 1.4, passes: 1 });
line([[-0.5, 0.1], [-0.5, 0.0]], { lw: 2 });
line([[0.28, 0.1], [0.28, 0.0]], { lw: 2 });
line([[-0.1, 0.28], [-0.02, 0.34], [-0.1, 0.36], [-0.16, 0.28]], { lw: 1.4, passes: 1 });
hatch([[-0.6, 0.1], [0.35, 0.1], [0.4, 0.35], [-0.55, 0.35]], { spacing: 0.04, angle: -55, color: INK_FAINT, lw: 1 });
},
factory() {
line([[-0.7, 0.5], [0.7, 0.5]], { lw: 1.6, color: INK_FAINT });
fill([[-0.55, 0.5], [-0.55, -0.3], [0.55, -0.3], [0.55, 0.5]], 'rgba(120,110,100,0.10)');
line([[-0.55, 0.5], [-0.55, -0.3], [0.55, -0.3], [0.55, 0.5]], { lw: 2.4 });
line([[-0.5, 0.5], [-0.5, -0.15]], { lw: 2 });
line([[-0.15, 0.5], [-0.15, -0.2]], { lw: 2 });
line([[0.2, 0.5], [0.2, -0.15]], { lw: 2 });
for (const x of [-0.4, 0.05, 0.35]) { line([[x, 0.1], [x - 0.1, 0.1], [x - 0.06, -0.3], [x + 0.06, -0.3], [x + 0.1, 0.1]], { lw: 1.6, passes: 1 }); }
for (let i = 0; i < 5; i++) { const a = -0.35 + i * 0.2; line([[a + 0.02, 0.3], [a, 0.12], [a + 0.06, -0.02], [a, -0.16]], { lw: 1.2, color: INK_SOFT, passes: 1 }); }
hatch([[-0.55, 0.5], [-0.55, -0.3], [0.55, -0.3], [0.55, 0.5]], { spacing: 0.04, angle: 45, color: INK_FAINT, lw: 1 });
},
microscope() {
line([[-0.5, 0.6], [0.5, 0.6]], { lw: 1.6, color: INK_FAINT });
line([[-0.15, -0.35], [-0.15, -0.2], [-0.5, -0.2]], { lw: 2.2 });
line([[-0.35, -0.35], [-0.35, 0.0]], { lw: 2.2 });
line([[-0.55, 0.0], [-0.35, 0.0]], { lw: 2.2 });
line([[-0.6, 0.0], [-0.6, -0.55]], { lw: 2.2 });
line([[-0.6, -0.55], [-0.05, -0.55]], { lw: 2.2 });
line([[-0.05, -0.55], [0.15, -0.55]], { lw: 2.2 });
circle(-0.12, -0.05, 0.18, { lw: 2 });
line([[-0.12, -0.2], [0.2, -0.5]], { lw: 1.6, color: INK_SOFT, passes: 1 });
fill([[-0.18, 0.3], [-0.06, 0.3], [-0.06, 0.1], [-0.18, 0.1]], 'rgba(150,150,160,0.10)');
line([[-0.18, 0.3], [-0.06, 0.3], [-0.06, 0.1], [-0.18, 0.1], [-0.18, 0.3]], { lw: 1.6, passes: 1 });
dot(-0.12, 0.2, 0.03, '#a03a2a');
},
telescope() {
line([[-0.4, 0.5], [0.0, 0.5], [0.0, 0.6]], { lw: 1.6, color: INK_FAINT });
fill([[-0.3, -0.3], [0.3, -0.3], [0.38, 0.05], [-0.38, 0.05]], 'rgba(150,150,160,0.12)');
line([[-0.3, -0.3], [0.3, -0.3], [0.38, 0.05], [-0.38, 0.05], [-0.3, -0.3]], { lw: 2.4 });
line([[-0.42, -0.28], [-0.32, -0.5], [-0.5, -0.5]], { lw: 2 });
line([[-0.42, -0.28], [-0.3, -0.5], [-0.5, -0.5]], { lw: 2 });
circle(0.32, -0.28, 0.1, { lw: 1.8, passes: 1 });
circle(-0.32, -0.28, 0.1, { lw: 1.8, passes: 1 });
line([[-0.35, -0.1], [0.35, -0.1]], { lw: 1.2, color: INK_SOFT, passes: 1 });
line([[0.0, -0.5], [0.0, 0.5]], { lw: 2 });
line([[-0.5, -0.5], [0.5, -0.5]], { lw: 2 });
line([[-0.4, -0.5], [-0.4, -0.7]], { lw: 1.8 });
line([[0.4, -0.5], [0.4, -0.7]], { lw: 1.8 });
hatch([[-0.3, -0.3], [0.3, -0.3], [0.38, 0.05], [-0.38, 0.05]], { spacing: 0.04, angle: -50, color: INK_FAINT, lw: 1 });
},
flask() {
line([[-0.5, 0.55], [0.5, 0.55]], { lw: 1.4, color: INK_FAINT, passes: 1 });
line([[-0.1, 0.1], [-0.1, -0.3]], { lw: 2 });
line([[0.1, 0.1], [0.1, -0.3]], { lw: 2 });
fill([[-0.1, -0.3], [0.1, -0.3], [0.32, 0.28], [0.12, 0.42], [-0.12, 0.42], [-0.32, 0.28]], 'rgba(150,150,160,0.10)');
line([[-0.1, -0.3], [0.1, -0.3], [0.32, 0.28], [0.12, 0.42], [-0.12, 0.42], [-0.32, 0.28], [-0.1, -0.3]], { lw: 2.2 });
line([[-0.16, 0.34], [0.16, 0.34]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[0.05, -0.45], [0.15, -0.7]], { lw: 1.2, color: INK_FAINT, passes: 1, dash: [6, 5] });
dot(0.2, -0.74, 0.02, INK_FAINT);
hatch([[-0.1, -0.3], [0.1, -0.3], [0.32, 0.28], [0.12, 0.42], [-0.12, 0.42], [-0.32, 0.28]], { spacing: 0.04, angle: 30, color: INK_FAINT, lw: 1 });
},
lightbulb() {
fill([[0, -0.6], [0.28, -0.4], [0.34, -0.1], [0.22, 0.15], [0.0, 0.3], [-0.22, 0.15], [-0.34, -0.1], [-0.28, -0.4]], 'rgba(200,170,80,0.14)');
line([[0, -0.6], [0.28, -0.4], [0.34, -0.1], [0.22, 0.15], [0.0, 0.3], [-0.22, 0.15], [-0.34, -0.1], [-0.28, -0.4], [0, -0.6]], { lw: 2.4 });
line([[0, -0.6], [0.02, -0.85]], { lw: 1.8 });
line([[-0.16, 0.12], [0.16, 0.12]], { lw: 1.6, color: INK_SOFT, passes: 1 });
line([[-0.1, 0.24], [0.1, 0.24]], { lw: 1.6, color: INK_SOFT, passes: 1 });
line([[-0.2, 0.36], [0.2, 0.36]], { lw: 1.6, color: INK_SOFT, passes: 1 });
for (let i = 0; i < 6; i++) { const a = -1.2 + i * 0.4; line([[Math.cos(a) * 0.4, Math.sin(a) * 0.4], [Math.cos(a) * 0.52, Math.sin(a) * 0.52]], { lw: 1.2, color: INK_FAINT, passes: 1 }); }
},
dinosaur() {
line([[-0.6, 0.6], [0.6, 0.6]], { lw: 1.4, color: INK_FAINT, passes: 1 });
line([[-0.55, 0.35], [-0.45, 0.1], [-0.3, -0.1], [-0.1, -0.2], [0.1, -0.2], [0.3, -0.1], [0.42, 0.0], [0.5, 0.2], [0.52, 0.4]], { lw: 2.4 });
line([[-0.45, 0.1], [-0.6, 0.0], [-0.65, 0.2]], { lw: 2 });
line([[0.42, 0.0], [0.55, -0.1], [0.58, 0.1]], { lw: 2 });
line([[-0.3, -0.1], [-0.32, 0.3]], { lw: 1.6, color: INK_SOFT, passes: 1 });
line([[-0.1, -0.2], [-0.1, 0.25]], { lw: 1.6, color: INK_SOFT, passes: 1 });
line([[0.1, -0.2], [0.12, 0.25]], { lw: 1.6, color: INK_SOFT, passes: 1 });
line([[0.3, -0.1], [0.32, 0.25]], { lw: 1.6, color: INK_SOFT, passes: 1 });
line([[-0.15, -0.16], [0.05, -0.08], [0.15, -0.16]], { lw: 1.4, passes: 1 });
line([[0.45, 0.3], [0.35, 0.42], [0.5, 0.5]], { lw: 1.6, color: INK_SOFT, passes: 1 });
},
flower() {
circle(0, 0, 0.12, { lw: 2, passes: 1 });
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2 + 0.3;
const x = Math.cos(a) * 0.24, y = Math.sin(a) * 0.24;
fill([[x - 0.05, y - 0.05], [x + 0.16, y - 0.02], [x + 0.2, y + 0.08], [x + 0.02, y + 0.18], [x - 0.12, y + 0.14], [x - 0.1, y + 0.02]], 'rgba(150,60,40,0.10)');
line([[x - 0.05, y - 0.05], [x + 0.16, y - 0.02], [x + 0.2, y + 0.08], [x + 0.02, y + 0.18], [x - 0.12, y + 0.14], [x - 0.1, y + 0.02], [x - 0.05, y - 0.05]], { lw: 1.8 });
}
line([[0.0, 0.1], [0.0, 0.75]], { lw: 2 });
fill([[0.0, 0.4], [0.18, 0.5], [0.1, 0.62]], 'rgba(60,90,50,0.16)');
line([[0.0, 0.4], [0.18, 0.5], [0.1, 0.62], [0.0, 0.4]], { lw: 1.6 });
dot(0.0, 0.0, 0.04, '#c9a227');
},
mountain() {
line([[-0.7, 0.45], [0.7, 0.45]], { lw: 1.8 });
fill([[-0.6, 0.45], [-0.15, -0.5], [0.1, -0.2], [0.6, 0.45]], 'rgba(150,140,130,0.10)');
line([[-0.6, 0.45], [-0.15, -0.5], [0.1, -0.2], [0.6, 0.45]], { lw: 2.4 });
line([[-0.15, -0.5], [-0.15, -0.6]], { lw: 2 });
line([[-0.6, 0.45], [-0.35, 0.05], [-0.15, -0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[-0.15, -0.5], [0.0, -0.15], [0.1, -0.2]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[0.1, -0.2], [0.3, 0.05], [0.6, 0.45]], { lw: 1.4, color: INK_SOFT, passes: 1 });
circle(0.55, -0.5, 0.14, { lw: 2, passes: 1 });
line([[-0.55, 0.6], [-0.1, 0.6]], { lw: 1.2, color: INK_FAINT, passes: 1 });
},
mask() {
circle(0, 0, 0.6, { lw: 2.4 });
circle(-0.14, -0.1, 0.12, { lw: 1.8, passes: 1 });
circle(0.14, -0.1, 0.12, { lw: 1.8, passes: 1 });
line([[-0.32, -0.3], [-0.05, -0.18], [-0.05, -0.5], [-0.18, -0.5], [-0.18, -0.18]], { lw: 1.8 });
line([[0.32, -0.3], [0.05, -0.18], [0.05, -0.5], [0.18, -0.5], [0.18, -0.18]], { lw: 1.8 });
line([[-0.2, 0.25], [0.2, 0.25]], { lw: 2, passes: 1 });
line([[-0.1, 0.25], [0.0, 0.4], [0.1, 0.25]], { lw: 1.8, passes: 1 });
line([[-0.3, 0.42], [-0.5, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[0.3, 0.42], [0.5, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
hatch([[Math.cos(0.8) * 0.6, Math.sin(0.8) * 0.6], [Math.cos(2.3) * 0.6, Math.sin(2.3) * 0.6], [Math.cos(4) * 0.6, Math.sin(4) * 0.6]], { spacing: 0.04, angle: 45, color: INK_FAINT, lw: 1 });
},
lyre() {
line([[-0.5, 0.5], [-0.3, -0.1], [-0.1, -0.5]], { lw: 2.2 });
line([[0.5, 0.5], [0.3, -0.1], [0.1, -0.5]], { lw: 2.2 });
line([[-0.5, 0.5], [0.5, 0.5]], { lw: 2.2 });
fill([[-0.3, -0.1], [0.3, -0.1], [0.15, 0.35], [-0.15, 0.35]], 'rgba(200,180,140,0.14)');
line([[-0.3, -0.1], [0.3, -0.1], [0.15, 0.35], [-0.15, 0.35], [-0.3, -0.1]], { lw: 1.8 });
for (let i = 0; i < 4; i++) { const y = -0.0 + i * 0.1; line([[-0.28 + i * 0.02, y], [0.28 - i * 0.02, y]], { lw: 1.2, color: INK_SOFT, passes: 1 }); }
line([[-0.1, -0.5], [0.0, -0.6], [0.1, -0.5]], { lw: 1.8 });
},
coin() {
circle(0, 0, 0.6, { lw: 2.6 });
circle(0, 0, 0.5, { lw: 1.4, color: INK_SOFT, passes: 1 });
circle(0, 0, 0.32, { lw: 1.8, passes: 1 });
// 侧面像(简化)
fill([[0.0, -0.2], [0.02, -0.02], [0.06, 0.1], [0.02, 0.2], [-0.08, 0.22], [-0.1, 0.1], [-0.08, -0.02], [-0.02, -0.2]], 'rgba(150,140,130,0.10)');
line([[0.0, -0.2], [0.02, -0.02], [0.06, 0.1], [0.02, 0.2], [-0.08, 0.22], [-0.1, 0.1], [-0.08, -0.02], [-0.02, -0.2], [0.0, -0.2]], { lw: 1.8 });
line([[-0.45, 0.05], [0.45, 0.05]], { lw: 1.2, color: INK_FAINT, passes: 1 });
line([[0.0, -0.5], [0.0, 0.5]], { lw: 1.2, color: INK_FAINT, passes: 1 });
},
key() {
circle(0.5, 0.2, 0.22, { lw: 2.4 });
circle(0.5, 0.2, 0.1, { lw: 1.6, passes: 1 });
line([[-0.55, -0.1], [0.28, 0.1]], { lw: 2.6 });
line([[0.28, 0.1], [0.5, 0.16]], { lw: 2.4 });
line([[-0.55, -0.1], [-0.55, 0.1]], { lw: 1.8 });
line([[-0.4, -0.05], [-0.4, 0.05]], { lw: 1.8 });
line([[-0.25, -0.02], [-0.25, 0.05]], { lw: 1.8 });
dot(-0.55, 0.0, 0.03, INK_SOFT);
},
hourglass() {
line([[-0.4, -0.6], [0.4, -0.6]], { lw: 2 });
line([[-0.4, 0.6], [0.4, 0.6]], { lw: 2 });
line([[-0.4, -0.6], [-0.4, 0.6]], { lw: 2.2 });
line([[0.4, -0.6], [0.4, 0.6]], { lw: 2.2 });
line([[-0.4, -0.5], [-0.1, 0.0], [-0.4, 0.5]], { lw: 2.2 });
line([[0.4, -0.5], [0.1, 0.0], [0.4, 0.5]], { lw: 2.2 });
fill([[-0.1, -0.1], [0.1, -0.1], [0.0, 0.3], [-0.1, 0.3]], 'rgba(160,120,50,0.14)');
line([[-0.1, -0.1], [0.1, -0.1], [0.1, 0.3], [-0.1, 0.3], [-0.1, -0.1]], { lw: 1.4, color: INK_SOFT, passes: 1 });
for (let i = 0; i < 5; i++) { dot(-0.03 + (i % 2) * 0.05, -0.05 + i * 0.07, 0.015, INK_SOFT); }
},
compass() {
circle(0, 0, 0.6, { lw: 2.6 });
circle(0, 0, 0.5, { lw: 1.2, color: INK_FAINT, passes: 1 });
line([[0, -0.5], [0, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
line([[-0.5, 0], [0.5, 0]], { lw: 1.4, color: INK_SOFT, passes: 1 });
fill([[0, 0.35], [0.35, 0], [0, -0.2]], 'rgba(150,60,40,0.12)');
fill([[0, -0.35], [-0.35, 0], [0, 0.2]], 'rgba(150,150,160,0.12)');
line([[0, 0.35], [0.35, 0], [0, -0.2], [0, 0.35]], { lw: 1.8 });
line([[0, -0.35], [-0.35, 0], [0, 0.2], [0, -0.35]], { lw: 1.8 });
dot(0, 0, 0.05, '#a03a2a');
dot(0, 0.5, 0.03, INK_SOFT);
dot(0, -0.5, 0.03, INK_SOFT);
},
anchor() {
circle(0, -0.5, 0.1, { lw: 2.4, passes: 1 });
line([[0, -0.4], [0, 0.5]], { lw: 2.6 });
line([[-0.45, -0.2], [0.45, -0.2]], { lw: 2.4 });
line([[-0.45, -0.2], [-0.45, 0.1]], { lw: 2.2 });
line([[0.45, -0.2], [0.45, 0.1]], { lw: 2.2 });
line([[-0.5, 0.35], [0.0, 0.5], [0.5, 0.35]], { lw: 2.2 });
line([[-0.45, 0.1], [-0.62, 0.42]], { lw: 2 });
line([[0.45, 0.1], [0.62, 0.42]], { lw: 2 });
line([[-0.7, 0.5], [0.7, 0.5]], { lw: 1.2, color: INK_FAINT, passes: 1, wobble: 0.008 });
}, },
}; };
+126
View File
@@ -0,0 +1,126 @@
import * as THREE from 'three';
// OS1 加载环(参考 Her - OS1 Loading):白色扭曲环在陶土橙红背景上旋转,
// 按下加速转向正面、淡出并显露完成圆环。
const BG = '#14100d';
class OS1Curve extends THREE.Curve {
constructor(length, radius) {
super();
this.length = length;
this.radius = radius;
this.pi2 = Math.PI * 2;
}
getPoint(percent) {
const { length, radius, pi2 } = this;
let t = (percent % 0.25) / 0.25;
t = (percent % 0.25) - (2 * (1 - t) * t * -0.0185 + t * t * 0.25);
if (Math.floor(percent / 0.25) === 0 || Math.floor(percent / 0.25) === 2) t *= -1;
const x = length * Math.sin(pi2 * percent);
const y = radius * Math.cos(pi2 * 3 * percent);
const z = radius * Math.sin(pi2 * 2 * (percent - t));
return new THREE.Vector3(x, y, z);
}
}
export function initLoading() {
const wrap = document.getElementById('loading-canvas');
if (!wrap) return { dispose() {} };
const canvassize = 500;
const length = 30;
const radius = 5.6;
const rotatevalue = 0.035;
let acceleration = 0;
let animatestep = 0;
let toend = false;
let disposed = false;
let rafId = 0;
const group = new THREE.Group();
const scene = new THREE.Scene();
scene.add(group);
const camera = new THREE.PerspectiveCamera(65, 1, 1, 10000);
camera.position.z = 150;
// 白色扭曲环
const mesh = new THREE.Mesh(
new THREE.TubeGeometry(new OS1Curve(length, radius), 120, 1.1, 2, true),
new THREE.MeshBasicMaterial({ color: 0xe0c46a, transparent: true }),
);
group.add(mesh);
// 缺口遮挡(背景色)
const ringcover = new THREE.Mesh(
new THREE.PlaneGeometry(50, 15, 1),
new THREE.MeshBasicMaterial({ color: 0x14100d, transparent: true, opacity: 0 }),
);
ringcover.position.x = length + 1;
ringcover.rotation.y = Math.PI / 2;
group.add(ringcover);
// 完成圆环
const ring = new THREE.Mesh(
new THREE.RingGeometry(4.3, 5.55, 32),
new THREE.MeshBasicMaterial({ color: 0xe0c46a, transparent: true, opacity: 0 }),
);
ring.position.x = length + 1.1;
ring.rotation.y = Math.PI / 2;
group.add(ring);
const renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.setSize(canvassize, canvassize);
renderer.setClearColor(BG);
wrap.appendChild(renderer.domElement);
function easing(t, b, c, d) {
if ((t /= d / 2) < 1) return (c / 2) * t * t + b;
return (c / 2) * ((t -= 2) * t * t + 2) + b;
}
function render() {
animatestep = Math.max(0, Math.min(240, toend ? animatestep + 1 : animatestep - 4));
acceleration = easing(animatestep, 0, 1, 240);
if (acceleration > 0.35) {
let progress = (acceleration - 0.35) / 0.65;
group.rotation.y = (-Math.PI / 2) * progress;
group.position.z = 50 * progress;
progress = Math.max(0, (acceleration - 0.97) / 0.03);
mesh.material.opacity = 1 - progress;
ringcover.material.opacity = ring.material.opacity = progress;
ring.scale.x = ring.scale.y = 0.9 + 0.1 * progress;
}
renderer.render(scene, camera);
}
function animate() {
mesh.rotation.x += rotatevalue + acceleration;
render();
if (!disposed) rafId = requestAnimationFrame(animate);
}
animate();
const press = () => { toend = true; };
const release = () => { toend = false; };
document.body.addEventListener('mousedown', press);
document.body.addEventListener('touchstart', press);
document.body.addEventListener('mouseup', release);
document.body.addEventListener('touchend', release);
return {
dispose() {
disposed = true;
cancelAnimationFrame(rafId);
document.body.removeEventListener('mousedown', press);
document.body.removeEventListener('touchstart', press);
document.body.removeEventListener('mouseup', release);
document.body.removeEventListener('touchend', release);
renderer.dispose();
if (renderer.domElement.parentNode === wrap) wrap.removeChild(renderer.domElement);
},
};
}
+37 -7
View File
@@ -7,9 +7,12 @@ export class Overlay {
this.onPrev = onPrev; this.onPrev = onPrev;
this.onNext = onNext; this.onNext = onNext;
this.onFlip = onFlip; this.onFlip = onFlip;
this.view = 'binder';
this.globeReady = false;
this.els = { this.els = {
iris: document.getElementById('iris'), iris: document.getElementById('iris'),
btnView: document.getElementById('btn-view'),
pageNum: document.getElementById('page-num'), pageNum: document.getElementById('page-num'),
pageTotal: document.getElementById('page-total'), pageTotal: document.getElementById('page-total'),
pageName: document.getElementById('page-name'), pageName: document.getElementById('page-name'),
@@ -27,22 +30,31 @@ export class Overlay {
} }
_wire() { _wire() {
document.querySelectorAll('.view-btn').forEach((btn) => { this.els.btnView.addEventListener('click', () => this._cycleView());
btn.addEventListener('click', () => this.onView(btn.dataset.view));
});
document.getElementById('btn-prev').addEventListener('click', () => this.onPrev()); document.getElementById('btn-prev').addEventListener('click', () => this.onPrev());
document.getElementById('btn-next').addEventListener('click', () => this.onNext()); document.getElementById('btn-next').addEventListener('click', () => this.onNext());
document.getElementById('btn-flip')?.addEventListener('click', () => this.onFlip()); document.getElementById('btn-flip')?.addEventListener('click', () => this.onFlip());
} }
// 视图循环切换:活页本 → 时间轴 → 地理 → 活页本(地理未就绪时跳过)
_cycleView() {
const order = ['binder', 'timeline', 'globe'];
let next = order[(order.indexOf(this.view) + 1) % order.length];
if (next === 'globe' && !this.globeReady) next = 'binder';
this.onView(next);
}
setGlobeReady(ready) {
this.globeReady = ready;
}
setTotal(n) { setTotal(n) {
this.els.pageTotal.textContent = String(n).padStart(2, '0'); this.els.pageTotal.textContent = String(n).padStart(2, '0');
} }
setView(view) { setView(view) {
document.querySelectorAll('.view-btn').forEach((btn) => { this.view = view;
btn.classList.toggle('active', btn.dataset.view === view); this.els.btnView.dataset.view = view;
});
const HINTS = { const HINTS = {
binder: '拖动旋转 · 滚轮缩放 · 点击书页选中 · ← → 翻页(翻面查看素描)', binder: '拖动旋转 · 滚轮缩放 · 点击书页选中 · ← → 翻页(翻面查看素描)',
timeline: '拖拽左右滑动 · 滚轮缩放 · 点击卡片查看详情', timeline: '拖拽左右滑动 · 滚轮缩放 · 点击卡片查看详情',
@@ -52,15 +64,33 @@ export class Overlay {
} }
setEntry(entry, index, total) { setEntry(entry, index, total) {
this.els.detailCard.classList.remove('hidden');
this.els.pageNum.textContent = String(index + 1).padStart(2, '0'); this.els.pageNum.textContent = String(index + 1).padStart(2, '0');
this.els.pageName.textContent = entry.name; this.els.pageName.textContent = entry.name;
this.els.detailNo.textContent = entry.id; this.els.detailNo.textContent = 'NO.' + (entry.code || entry.id);
this.els.detailName.textContent = entry.name; this.els.detailName.textContent = entry.name;
this.els.detailTime.textContent = entry.time; this.els.detailTime.textContent = entry.time;
this.els.detailRegion.textContent = entry.region; this.els.detailRegion.textContent = entry.region;
this.els.detailDesc.textContent = entry.description; this.els.detailDesc.textContent = entry.description;
// 素描缩略图(等比放大填满,裁掉上下多余部分) // 素描缩略图(等比放大填满,裁掉上下多余部分)
this._drawSketch(entry);
}
// END 页:展示最后一页背面(末页素描)与结束标记
setEnd(entry) {
this.els.detailCard.classList.remove('hidden');
this.els.pageNum.textContent = 'END';
this.els.pageName.textContent = '完 · 人类文明里程碑';
this.els.detailNo.textContent = 'END';
this.els.detailName.textContent = '完 · The End';
this.els.detailTime.textContent = '—';
this.els.detailRegion.textContent = '—';
this.els.detailDesc.textContent = '人类文明里程碑 · 活页编年史 · 全书完';
this._drawSketch(entry);
}
_drawSketch(entry) {
const c = this.els.detailSketch; const c = this.els.detailSketch;
const ctx = c.getContext('2d'); const ctx = c.getContext('2d');
ctx.clearRect(0, 0, c.width, c.height); ctx.clearRect(0, 0, c.width, c.height);
+1 -1
View File
@@ -1,7 +1,7 @@
import * as THREE from 'three'; import * as THREE from 'three';
// 超采样倍数:文字以更高分辨率栅格化,拉近/放大后依然清晰 // 超采样倍数:文字以更高分辨率栅格化,拉近/放大后依然清晰
const SS = 4; const SS = 2;
// 折行:尽量均分两行(标题较短),均分不满足时逐字折行 // 折行:尽量均分两行(标题较短),均分不满足时逐字折行
function wrapText(ctx, text, maxW) { function wrapText(ctx, text, maxW) {