Compare commits
96
Commits
32bb48cc0b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7498409c7d | ||
|
|
db8b14705d | ||
|
|
f11dcb0ea5 | ||
|
|
6d46d51685 | ||
|
|
4cfeb7cba0 | ||
|
|
929e55c32f | ||
|
|
847d361431 | ||
|
|
30e2f562bc | ||
|
|
46c6d2f34a | ||
|
|
d327ab5bc1 | ||
|
|
0215fa71d4 | ||
|
|
d0732a9ac3 | ||
|
|
52b09f5049 | ||
|
|
63777b83ce | ||
|
|
46f6681b6a | ||
|
|
3522ece7b3 | ||
|
|
1fd386b6cf | ||
|
|
10b2b77887 | ||
|
|
d4e0999506 | ||
|
|
0e150f09df | ||
|
|
5b71877ae3 | ||
|
|
89d51a7f59 | ||
|
|
9df646c91c | ||
|
|
d02e7da220 | ||
|
|
6256098a94 | ||
|
|
174f162565 | ||
|
|
906e53f1d5 | ||
|
|
e3fd805de2 | ||
|
|
adfe4fca29 | ||
|
|
b135971e7f | ||
|
|
11eecf2a42 | ||
|
|
2041004bfd | ||
|
|
bd1618e226 | ||
|
|
db1a30cac2 | ||
|
|
166833338e | ||
|
|
53e7b551d0 | ||
|
|
9f1de616ea | ||
|
|
c04efa849a | ||
|
|
7ffcbbcfee | ||
|
|
7f6855d9ae | ||
|
|
2fe14ae09f | ||
|
|
8ca2e9ebe3 | ||
|
|
0cd4018fcf | ||
|
|
114cf32597 | ||
|
|
57747e38c7 | ||
|
|
60988c5df8 | ||
|
|
13f8023621 | ||
|
|
34e6ce6d5c | ||
|
|
b0e68f3f32 | ||
|
|
91999e25e5 | ||
|
|
0cb6e46b92 | ||
|
|
2dae2d2ff2 | ||
|
|
7a99756fc6 | ||
|
|
0357bb0fb1 | ||
|
|
ccd3efde17 | ||
|
|
66fac3771d | ||
|
|
e8745fa45f | ||
|
|
1126f9a79e | ||
|
|
0dd279f2b5 | ||
|
|
67ad4f29af | ||
|
|
018d5787da | ||
|
|
5ab9bce49d | ||
|
|
656f535467 | ||
|
|
3bbfedeefa | ||
|
|
0715b38b81 | ||
|
|
86ee03f4a6 | ||
|
|
b94abe32fa | ||
|
|
37b7923849 | ||
|
|
199ef8f892 | ||
|
|
dddf622d95 | ||
|
|
b33ffd3b2d | ||
|
|
83de329750 | ||
|
|
f4ef046c9a | ||
|
|
e05143ad73 | ||
|
|
39d4c728b0 | ||
|
|
e10dfed93d | ||
|
|
6204b17db3 | ||
|
|
4eb61b7aa5 | ||
|
|
f701862e5a | ||
|
|
0c7990a9a8 | ||
|
|
dac7aefeb4 | ||
|
|
eb165d03c5 | ||
|
|
2a173833d0 | ||
|
|
213b2fe7a1 | ||
|
|
159b98d232 | ||
|
|
2687e5fd95 | ||
|
|
fade9eedc2 | ||
|
|
41d68e087a | ||
|
|
91d82d27bf | ||
|
|
18a2077369 | ||
|
|
ed7d377eb6 | ||
|
|
52111b6cce | ||
|
|
14162ba7d0 | ||
|
|
d4f27f3857 | ||
|
|
b63fc980f9 | ||
|
|
e3a55bfc01 |
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
|
||||
@@ -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` 供旋转/缩放。
|
||||
- **程序化纹理**:纸张、皮革、活页孔、登记卡、素描全部由 Canvas 2D 即时生成,无需外部图片。
|
||||
- **程序化素描**:18 种手绘风格速写(头骨、泥板、金字塔、人像、神庙、王冠、斗兽场、卷轴、骑士、帆船、地球仪、苹果、蒸汽机、自由钟、进化树、原子、和平鸽、火箭),带抖动线条与排线阴影,同一词条每次渲染结果确定一致。
|
||||
- **程序化素描**:53 种手绘风格速写(化石、泥板、金字塔、人像、神庙、王冠、斗兽场、卷轴、骑士、帆船、地球仪、苹果、蒸汽机、自由钟、进化树、原子、和平鸽、火箭、书、桥、旗帜、天平、剑、头盔、齿轮……),带抖动线条与排线阴影,同一词条每次渲染结果确定一致。
|
||||
- **双面活页**:正面用 `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,27 +61,95 @@ npm run build # 生产构建(dist/)
|
||||
npm run preview # 预览生产构建
|
||||
```
|
||||
|
||||
生成 / 切换词条数据(跑完重新 `npm run build`):
|
||||
|
||||
```bash
|
||||
python scripts/gen_entries.py # 182 条真实词条(含坐标去重、编目号、素描分配)
|
||||
python scripts/gen_entries_stress.py # 1000 条压力测试数据
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
### 1. 构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 2. 部署到 Web 服务器(hist.fukun.net)
|
||||
|
||||
站点托管在腾讯云 `62.234.90.54`(Ubuntu 24.04),静态文件放在 `hugo` 用户的 `~/hist` 目录,Nginx 指向该目录。
|
||||
|
||||
通过 SSH 打包上传(密钥见 `C:\Code\Claw\keys\tx-ubuntu-hugo.pem`):
|
||||
|
||||
```bash
|
||||
# Windows (Git Bash) / Linux
|
||||
SSHKEY="C:/Code/Claw/keys/tx-ubuntu-hugo.pem"
|
||||
tar czf - -C dist . | ssh -i "$SSHKEY" -o StrictHostKeyChecking=accept-new hugo@62.234.90.54 \
|
||||
'rm -rf ~/hist/* && tar xzf - -C ~/hist && echo 部署完成'
|
||||
```
|
||||
|
||||
> Nginx 配置与 SSL 证书已就绪:
|
||||
> - 站点配置:`/etc/nginx/sites-available/hist.fukun.net`(root `/home/hugo/hist`)
|
||||
> - SSL 证书:`/etc/nginx/ssl/hist.fukun.net_bundle.crt` + `hist.fukun.net.key`(TrustAsia)
|
||||
> - 证书本地备份:`C:\Code\Claw\nginx\hist.fukun.net\`
|
||||
|
||||
### 3. 推送代码到 Git
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
远程仓库:`git@git.fukun.net:fukunee/Hist.git`(SSH)。
|
||||
|
||||
### 4. 验证
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://hist.fukun.net/
|
||||
# 应输出 200
|
||||
```
|
||||
|
||||
## 操作
|
||||
|
||||
- **拖动** 旋转视角,**滚轮** 缩放
|
||||
- **← →** 或底部按钮 翻页(翻面查看背面素描)
|
||||
- 点击 活页 / 时间轴卡片 / 地球标记 选中词条
|
||||
- 顶部按钮或按键 **1 / 2 / 3** 切换 活页本 / 时间轴 / 地理 视图
|
||||
- **活页本**:拖动旋转、滚轮缩放;点击书页右侧向前/左侧向后(松开触发);**← →** / 底部按钮翻页;第一页再向后 / 最后一页再向前回卷到 END 页
|
||||
- **时间轴**:拖拽左右滑动、滚轮缩放;点击卡片 / **← →** / 底部按钮 选中并居中 + 翻面
|
||||
- **地理**:左键拖拽旋转、滚轮缩放(右键 tilt、中键回正);点击标记选中
|
||||
- 底部导航栏按钮(或按键 **1 / 2 / 3**)切换 活页本 / 时间轴 / 地理 视图
|
||||
- 右下角登记卡在各视图间保持显示
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
main.js 入口
|
||||
core/App.js 编排器:渲染、灯光、相机、视图切换、拾取
|
||||
scene/Binder.js 活页本(封面、环、双面活页、翻页动画)
|
||||
scene/Timeline.js 时间轴视图
|
||||
scene/Globe.js 地理视图(点阵地球、弧线、标记)
|
||||
textures/pages.js 纸张 + 登记卡/素描两面纹理
|
||||
textures/sketch.js 程序化素描生成器
|
||||
data/entries.js 历史词条数据
|
||||
data/worldMap.js 简化大陆轮廓
|
||||
ui/overlay.js HTML 覆盖层
|
||||
utils/ 缓动 / 文字精灵
|
||||
scripts/ 截图与验证辅助(开发用)
|
||||
main.js 入口
|
||||
core/App.js 编排器:渲染、灯光、相机、视图切换、拾取
|
||||
scene/binder.js 活页本(封面、环、双面活页、翻页动画)
|
||||
scene/timeline.js 时间轴视图
|
||||
scene/CesiumGlobe.js 地理视图(Cesium 地球、国界、城市、词条便签)
|
||||
textures/pages.js 纸张 + 登记卡/素描两面纹理
|
||||
textures/sketch.js 程序化素描生成器(53 种)
|
||||
data/entries.js 历史词条数据(182 条)
|
||||
ui/overlay.js HTML 覆盖层
|
||||
ui/loading.js 加载遮罩
|
||||
utils/ 缓动 / 文字精灵
|
||||
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
@@ -30,17 +30,17 @@
|
||||
|
||||
<!-- 底部:导航 -->
|
||||
<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">
|
||||
<span id="page-num">01</span><span class="sep">/</span><span id="page-total">20</span>
|
||||
<span class="page-name" id="page-name">露西 · 南方古猿</span>
|
||||
</div>
|
||||
<button class="nav-btn" id="btn-next" title="下一页 (→)">▶</button>
|
||||
<div class="view-switch" aria-label="视图切换">
|
||||
<button class="view-btn active" data-view="binder">活页本</button>
|
||||
<button class="view-btn" data-view="timeline">时间轴</button>
|
||||
<button class="view-btn" data-view="globe">地理</button>
|
||||
</div>
|
||||
<button class="nav-btn" id="btn-next" aria-label="下一页">▶</button>
|
||||
<button class="view-btn" id="btn-view" data-view="binder" aria-label="切换视图">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<!-- 右侧:登记卡详情 -->
|
||||
@@ -63,12 +63,10 @@
|
||||
<div class="hud hint" id="hint">拖动旋转 · 滚轮缩放 · 时间轴视图拖拽左右滑动 · 点击选中 · ← → 翻页</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载界面 -->
|
||||
<!-- 加载界面:OS1 3D 加载环 -->
|
||||
<div id="loading" aria-hidden="true">
|
||||
<div class="loading-box">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text">载入中…</div>
|
||||
</div>
|
||||
<div id="loading-canvas"></div>
|
||||
<div class="loading-text">加载中,约等待六秒…</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,6 +5,8 @@
|
||||
|
||||
import math
|
||||
|
||||
from region_codes import make_code
|
||||
|
||||
DATA = [
|
||||
# ===================== 史前 =====================
|
||||
("~ BC 3,200,000", -3200000, "东非 · 埃塞俄比亚", "露西 · 南方古猿", "人", "fossil", 11.2, 40.5, "1974年在埃塞俄比亚哈达尔发现的一具约40%完整的南方古猿阿法种骨架,是直立行走的最早证据之一,被亲切地称为“人类祖母”。"),
|
||||
@@ -360,6 +362,44 @@ def dedupe(entries, km=DEDUP_KM):
|
||||
return out
|
||||
|
||||
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)")
|
||||
|
||||
def esc(s):
|
||||
@@ -397,6 +437,7 @@ lines = ["// 世界历史里程碑条目数据",
|
||||
for i, (time, tn, region, name, cat, sketch, lat, lng, desc) in enumerate(DEDUPED, 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(normalize_time(time, tn))}',")
|
||||
lines.append(f" timeNumeric: {tn},")
|
||||
lines.append(f" region: '{esc(region)}',")
|
||||
|
||||
@@ -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)")
|
||||
@@ -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}"
|
||||
+29
-51
@@ -3,69 +3,47 @@ import puppeteer from 'puppeteer-core';
|
||||
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
||||
const URL = process.argv[2] || 'http://localhost:5199/';
|
||||
|
||||
// 轻量冒烟测试:不做完整渲染流程 / 不触发 Cesium,
|
||||
// 只检查「加载无 JS 报错 + loading 环出现 + 6 秒后淡出 + App 就绪」。
|
||||
// 用真实 GPU(不强制 swiftshader 软件渲染)。
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME,
|
||||
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();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
const errors = [];
|
||||
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 连接失败是正常现象,过滤掉
|
||||
if (m.type() === 'error' && !/WebSocket|ERR_CONNECTION/i.test(m.text())) {
|
||||
errors.push('console: ' + m.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await new Promise((r) => setTimeout(r, 6000));
|
||||
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
|
||||
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 new Promise((r) => setTimeout(r, 2000));
|
||||
const loading = await page.evaluate(() => ({
|
||||
hasCanvas: !!document.querySelector('#loading-canvas canvas'),
|
||||
loadingShown: !document.getElementById('loading').classList.contains('hidden'),
|
||||
appReady: !!window.__app,
|
||||
}));
|
||||
|
||||
await snapshot('binder ');
|
||||
|
||||
await page.click('.view-btn[data-view="timeline"]');
|
||||
await new Promise((r) => setTimeout(r, 2400));
|
||||
await snapshot('timeline');
|
||||
|
||||
await page.click('.view-btn[data-view="globe"]');
|
||||
await new Promise((r) => setTimeout(r, 2400));
|
||||
await snapshot('globe ');
|
||||
|
||||
await page.click('.view-btn[data-view="binder"]');
|
||||
await new Promise((r) => setTimeout(r, 2400));
|
||||
await page.click('#btn-next');
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
await snapshot('flipped ');
|
||||
|
||||
await page.click('#btn-prev');
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
await snapshot('back ');
|
||||
// 阶段二:约 9 秒后,loading 应已淡出、App 应已就绪
|
||||
await new Promise((r) => setTimeout(r, 7000));
|
||||
const done = await page.evaluate(() => ({
|
||||
loadingHidden: document.getElementById('loading').classList.contains('hidden'),
|
||||
appReady: !!window.__app,
|
||||
view: window.__app?.view,
|
||||
pages: window.__app?.binder?.pages?.length,
|
||||
timelineCards: window.__app?.timeline?.cards?.length,
|
||||
}));
|
||||
|
||||
console.log('阶段一(加载中):', JSON.stringify(loading));
|
||||
console.log('阶段二(加载后):', JSON.stringify(done));
|
||||
console.log('ERRORS:', errors.length ? errors.join('\n') : '(none)');
|
||||
await browser.close();
|
||||
|
||||
+186
-59
@@ -7,12 +7,14 @@ import { CesiumGlobe } from '../scene/CesiumGlobe.js';
|
||||
import { Overlay } from '../ui/overlay.js';
|
||||
import { clamp, easeInOutCubic } from '../utils/easing.js';
|
||||
|
||||
// Three.js 相机的初始位(仅活页本/时间轴;地理视图用 Cesium 自己的相机)
|
||||
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 },
|
||||
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 {
|
||||
constructor() {
|
||||
this.view = 'binder';
|
||||
@@ -21,6 +23,7 @@ export class App {
|
||||
this.timelineSlide = null;
|
||||
this.zoomTarget = null;
|
||||
this.cameraMemory = {};
|
||||
this._needsRender = true; // 按需渲染:首帧必渲染
|
||||
|
||||
this._initRenderer();
|
||||
this._initLights();
|
||||
@@ -28,14 +31,22 @@ export class App {
|
||||
|
||||
this.overlay = new Overlay({
|
||||
onView: (v) => this.switchView(v),
|
||||
onPrev: () => this._step(-1),
|
||||
onNext: () => this._step(1),
|
||||
onPrev: () => this._stepByView(-1),
|
||||
onNext: () => this._stepByView(1),
|
||||
onFlip: () => this._step(1),
|
||||
});
|
||||
this.overlay.setTotal(ENTRIES.length);
|
||||
|
||||
this._initEvents();
|
||||
this._applyRotationLimits(CAMERA_POSES.binder);
|
||||
// 默认新打开时:活页本放到最小(相机沿默认视角拉到最远)
|
||||
{
|
||||
const bp = CAMERA_POSES.binder;
|
||||
const dir = bp.pos.clone().sub(bp.target).normalize();
|
||||
this.camera.position.copy(bp.target).addScaledVector(dir, bp.max);
|
||||
this.controls.target.copy(bp.target);
|
||||
this.controls.update();
|
||||
}
|
||||
this._select(0, { syncBinder: false });
|
||||
this.binder.syncTo(0);
|
||||
|
||||
@@ -43,12 +54,21 @@ export class App {
|
||||
this.cameraTween = null;
|
||||
this._animate = this._animate.bind(this);
|
||||
requestAnimationFrame(this._animate);
|
||||
|
||||
// 首屏渲染后,分帧生成时间轴文字标签(避免阻塞首屏加载环)
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => this.timeline.initLabels()));
|
||||
}
|
||||
|
||||
// 首屏加载完成后由 main.js 调用:后台预载地理视图,就绪后允许循环切换到地理
|
||||
preloadGlobe() {
|
||||
this.globe.viewerReady.then(() => this.overlay.setGlobeReady(true));
|
||||
this.globe.ensureCreated();
|
||||
}
|
||||
|
||||
_initRenderer() {
|
||||
const canvas = document.getElementById('scene');
|
||||
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.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
@@ -74,6 +94,7 @@ export class App {
|
||||
this.controls.mouseButtons.MIDDLE = -1;
|
||||
this.controls.mouseButtons.RIGHT = -1;
|
||||
this.controls.rotateSpeed = 0.4; // 整体降速(无边缘减速)
|
||||
this.controls.addEventListener('change', () => { this._needsRender = true; });
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
@@ -84,30 +105,34 @@ export class App {
|
||||
const key = new THREE.DirectionalLight(0xffe6c0, 2.4);
|
||||
key.position.set(3.2, 4.4, 3.6);
|
||||
key.castShadow = true;
|
||||
key.shadow.mapSize.set(1024, 1024);
|
||||
key.shadow.mapSize.set(2048, 2048);
|
||||
key.shadow.camera.near = 0.5;
|
||||
key.shadow.camera.far = 30;
|
||||
key.shadow.camera.left = -5;
|
||||
key.shadow.camera.right = 5;
|
||||
key.shadow.camera.top = 6;
|
||||
key.shadow.camera.bottom = -6;
|
||||
key.shadow.bias = -0.0002;
|
||||
key.shadow.camera.left = -3.5;
|
||||
key.shadow.camera.right = 3.5;
|
||||
key.shadow.camera.top = 4;
|
||||
key.shadow.camera.bottom = -4;
|
||||
key.shadow.bias = -0.0005;
|
||||
this.scene.add(key);
|
||||
this.scene.add(key.target);
|
||||
this.keyLight = key;
|
||||
|
||||
const rim = new THREE.DirectionalLight(0x8fa0d0, 0.7);
|
||||
rim.position.set(-2.5, 1.2, -3.5);
|
||||
this.scene.add(rim);
|
||||
this.rimLight = rim;
|
||||
|
||||
// 低角度掠射光:横扫皮面,凸显皮革纹理凹凸
|
||||
const graze = new THREE.DirectionalLight(0xffd9a8, 1.1);
|
||||
graze.position.set(-4.5, 0.7, 2.6);
|
||||
this.scene.add(graze);
|
||||
this.grazeLight = graze;
|
||||
|
||||
// 背面补光:让外侧牛皮从背面看也可见
|
||||
const backLight = new THREE.DirectionalLight(0xffd9a8, 0.55);
|
||||
backLight.position.set(0.5, 1.2, -3.5);
|
||||
this.scene.add(backLight);
|
||||
this.backLight = backLight;
|
||||
}
|
||||
|
||||
_initScene() {
|
||||
@@ -116,7 +141,7 @@ export class App {
|
||||
|
||||
this.timeline = new Timeline();
|
||||
this.globe = new CesiumGlobe({ onPick: (i) => this._pickGlobe(i) });
|
||||
this.globe.ensureCreated(); // 打开页面时就渲染缓存好三个视图
|
||||
// Cesium 懒加载:首次进入“地理”视图时才真正创建 viewer
|
||||
|
||||
this.scene.add(this.binder.group);
|
||||
this.scene.add(this.timeline.group);
|
||||
@@ -165,7 +190,7 @@ export class App {
|
||||
this.interactives.binder.push(m);
|
||||
});
|
||||
});
|
||||
this.timeline.cards.forEach((c) => this.interactives.timeline.push(c.front, c.back));
|
||||
this.timeline.cards.forEach((c) => this.interactives.timeline.push(c.mesh));
|
||||
// 地理视图由 Cesium 自行拾取,不参与 Three.js 射线检测
|
||||
}
|
||||
|
||||
@@ -173,6 +198,7 @@ export class App {
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.pointer = new THREE.Vector2();
|
||||
this._drag = null;
|
||||
this._hoverDirty = false;
|
||||
const canvas = this.renderer.domElement;
|
||||
|
||||
const setPointer = (e) => {
|
||||
@@ -187,10 +213,12 @@ export class App {
|
||||
if (this.view === 'timeline') {
|
||||
// 时间轴:按下开始拖拽左右滑动;未移动的抬起才视为点击选中
|
||||
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);
|
||||
} else {
|
||||
this._pick();
|
||||
} else if (this.view === 'binder') {
|
||||
// 活页本:按下仅记录,松开且未拖拽(<4px)时才翻页
|
||||
this._drag = { active: true, view: 'binder', startX: e.clientX, startY: e.clientY, moved: false };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -202,37 +230,47 @@ export class App {
|
||||
if (!this._drag.moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
|
||||
this._drag.moved = true;
|
||||
}
|
||||
if (this._drag.moved) {
|
||||
if (this._drag.view === 'timeline' && this._drag.moved) {
|
||||
const limit = TIMELINE_SPREAD + 0.6;
|
||||
this.timelineOffset = clamp(this._drag.offset + dx * this._worldPerPixel(), -limit, limit);
|
||||
this.timeline.group.position.x = this.timelineOffset;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
this._needsRender = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const hit = this._hitTest();
|
||||
canvas.style.cursor = hit ? 'pointer' : 'grab';
|
||||
this._hoverDirty = true; // 射线检测移到 _animate 中每帧至多执行一次
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointerup', (e) => {
|
||||
if (this._drag && this._drag.active) {
|
||||
const wasClick = !this._drag.moved;
|
||||
const view = this._drag.view;
|
||||
this._drag.active = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
if (wasClick) this._pick();
|
||||
if (view === 'timeline') {
|
||||
this.timeline.setPausedGeneration(false); // 拖拽结束,恢复纹理生成
|
||||
if (wasClick) this._pick();
|
||||
} else if (view === 'binder' && wasClick) {
|
||||
setPointer(e); // 用松开位置做射线,松开时触发翻页
|
||||
this._pick();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointercancel', () => {
|
||||
if (this._drag) this._drag.active = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
this.timeline.setPausedGeneration(false);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => this._onResize());
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowRight') this._step(1);
|
||||
else if (e.key === 'ArrowLeft') this._step(-1);
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
const dir = e.key === 'ArrowRight' ? 1 : -1;
|
||||
this._stepByView(dir);
|
||||
}
|
||||
else if (e.key === '1') this.switchView('binder');
|
||||
else if (e.key === '2') this.switchView('timeline');
|
||||
else if (e.key === '3') this.switchView('globe');
|
||||
@@ -258,9 +296,34 @@ export class App {
|
||||
return (2 * Math.tan(halfFov) * dist * aspect) / canvas.clientWidth;
|
||||
}
|
||||
|
||||
// 时间轴:按当前相机可视区域刷新纹理窗口(拖拽/缩放/居中动画时连续跟随)
|
||||
_updateTimelineTextures() {
|
||||
if (this.view !== 'timeline') return;
|
||||
const dist = this.camera.position.distanceTo(this.controls.target);
|
||||
const halfFov = THREE.MathUtils.degToRad(this.camera.fov / 2);
|
||||
const el = this.renderer.domElement;
|
||||
const aspect = el.clientWidth / el.clientHeight;
|
||||
const halfW = Math.tan(halfFov) * dist * aspect;
|
||||
// 时间轴相机始终在 z 轴看向原点(旋转已锁定),可见世界 x ∈ [-halfW, halfW]
|
||||
this.timeline.setVisibleWorldRange(-halfW, halfW);
|
||||
}
|
||||
|
||||
_hitTest() {
|
||||
const list = this.interactives[this.view];
|
||||
if (!list || !list.length) return null;
|
||||
let list;
|
||||
if (this.view === 'binder') {
|
||||
// 只检测顶层可点击页(左右顶页 + 正反翻页缓冲),避免对全部页求交
|
||||
list = [];
|
||||
const cur = this.binder.current;
|
||||
for (let d = -2; d <= 1; d++) {
|
||||
const i = cur + d;
|
||||
if (i >= 0 && i < this.binder.pages.length) list.push(this.binder.pages[i].mesh);
|
||||
}
|
||||
} else if (this.view === 'timeline') {
|
||||
list = this.timeline.pickableMeshes();
|
||||
} else {
|
||||
return null; // 地理视图由 Cesium 自行拾取
|
||||
}
|
||||
if (!list.length) return null;
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
const hits = this.raycaster.intersectObjects(list, false);
|
||||
return hits.length ? hits[0] : null;
|
||||
@@ -278,13 +341,11 @@ export class App {
|
||||
if (!hit) return;
|
||||
const { view, index } = hit.object.userData;
|
||||
if (view === 'binder') {
|
||||
const cur = this.binder.current;
|
||||
if (index === cur) {
|
||||
this.binder.flipForward(); // 点击当前右页:翻到下一页
|
||||
} else if (index === cur - 1) {
|
||||
this.binder.flipBackward(); // 点击左页:翻回上一页
|
||||
// 按点击位置的空间左右侧判断翻页方向(翻页过程中索引与左右侧会错位,不能按索引)
|
||||
if (hit.point.x >= 0) {
|
||||
this.binder.flipForward(); // 点了右侧 → 向前翻
|
||||
} else {
|
||||
this._select(index, { syncBinder: true });
|
||||
this.binder.flipBackward(); // 点了左侧 → 向后翻
|
||||
}
|
||||
} else {
|
||||
this._select(index, { syncBinder: false });
|
||||
@@ -311,18 +372,40 @@ export class App {
|
||||
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 } = {}) {
|
||||
const isEnd = index >= ENTRIES.length;
|
||||
this.selected = clamp(index, 0, ENTRIES.length - 1);
|
||||
const entry = ENTRIES[this.selected];
|
||||
this.overlay.setEntry(entry, this.selected, ENTRIES.length);
|
||||
if (isEnd) {
|
||||
this.overlay.setEnd(ENTRIES[this.selected]);
|
||||
} else {
|
||||
this.overlay.setEntry(ENTRIES[this.selected], this.selected, ENTRIES.length);
|
||||
}
|
||||
this.timeline.setSelected(this.selected);
|
||||
this.globe.setSelected(this.selected);
|
||||
if (this.view === 'globe') this.globe.flyToEntry(this.selected); // 地球页切换时自动居中
|
||||
if (syncBinder) this.binder.syncTo(this.selected);
|
||||
this._needsRender = true;
|
||||
}
|
||||
|
||||
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 (this.view !== 'globe') {
|
||||
this.cameraMemory[this.view] = {
|
||||
@@ -334,10 +417,15 @@ export class App {
|
||||
iris.style.opacity = '1';
|
||||
const doSwap = () => {
|
||||
this._setView(view);
|
||||
// 等新视图渲染一两帧后再淡出,避免半透明时看到切换瞬间
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => { iris.style.opacity = '0'; });
|
||||
});
|
||||
if (view === 'globe') {
|
||||
// 地理视图:等影像/地形瓦片加载完成再淡出,让球体与标记/标签一起出现
|
||||
this.globe.waitUntilViewReady(4000).then(() => { iris.style.opacity = '0'; });
|
||||
} else {
|
||||
// 等新视图渲染一两帧后再淡出,避免半透明时看到切换瞬间
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => { iris.style.opacity = '0'; });
|
||||
});
|
||||
}
|
||||
};
|
||||
setTimeout(doSwap, 200); // 与 0.18s 的淡入匹配,完全变黑后再切换
|
||||
}
|
||||
@@ -351,6 +439,15 @@ export class App {
|
||||
this.binder.group.visible = view === 'binder';
|
||||
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 画布
|
||||
document.getElementById('scene').style.display = isGlobe ? 'none' : 'block';
|
||||
document.getElementById('cesium-container').classList.toggle('visible', isGlobe);
|
||||
@@ -370,27 +467,20 @@ export class App {
|
||||
// 登记卡详情在视图切换时保持显示,不隐藏
|
||||
document.getElementById('detail-card').classList.remove('hidden');
|
||||
|
||||
const pose = CAMERA_POSES[view];
|
||||
this.controls.minDistance = pose.min;
|
||||
this.controls.maxDistance = pose.max;
|
||||
// 时间轴:锁定旋转/平移;滚轮缩放;拖拽左右滑动
|
||||
const locked = view === 'timeline';
|
||||
this.controls.enableRotate = !locked;
|
||||
this.controls.enablePan = !locked;
|
||||
this.controls.enableZoom = false; // 滚轮缩放改为自定义渐变动画
|
||||
this.controls.autoRotate = false;
|
||||
|
||||
// 左键旋转限制:以各视图初始视角为中心,各方向 ±10°
|
||||
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;
|
||||
}
|
||||
|
||||
if (!isGlobe) {
|
||||
const pose = CAMERA_POSES[view];
|
||||
this.controls.minDistance = pose.min;
|
||||
this.controls.maxDistance = pose.max;
|
||||
// 时间轴:锁定旋转/平移;滚轮缩放;拖拽左右滑动
|
||||
const locked = view === 'timeline';
|
||||
this.controls.enableRotate = !locked;
|
||||
this.controls.enablePan = !locked;
|
||||
this.controls.enableZoom = false; // 滚轮缩放改为自定义渐变动画
|
||||
this.controls.autoRotate = false;
|
||||
|
||||
// 左键旋转限制:以各视图初始视角为中心,各方向 ±10°
|
||||
this._applyRotationLimits(pose);
|
||||
|
||||
// 切换视图:直接定位(有记忆则恢复上次的相机位置)
|
||||
this.cameraTween = null;
|
||||
this.zoomTarget = null;
|
||||
@@ -403,10 +493,19 @@ export class App {
|
||||
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;
|
||||
}
|
||||
|
||||
_applyRotationLimits(pose) {
|
||||
const LIMIT = (20 * Math.PI) / 180; // ±20°
|
||||
const LIMIT = (10 * Math.PI) / 180; // ±10°
|
||||
const dx = pose.pos.x - pose.target.x;
|
||||
const dy = pose.pos.y - pose.target.y;
|
||||
const dz = pose.pos.z - pose.target.z;
|
||||
@@ -436,14 +535,30 @@ export class App {
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
if (this.globe) this.globe.resize();
|
||||
this._needsRender = true;
|
||||
}
|
||||
|
||||
_animate() {
|
||||
requestAnimationFrame(this._animate);
|
||||
if (this.view === 'globe') return; // 地理视图由 Cesium 渲染,跳过 Three.js 每帧逻辑
|
||||
const dt = Math.min(this.clock.getDelta(), 0.05);
|
||||
|
||||
// 是否有进行中的动画(决定本帧是否需要重绘)
|
||||
const animating =
|
||||
this.binder.flip !== null ||
|
||||
this.binder.resetTween !== null ||
|
||||
this.binder.isSettling() ||
|
||||
this.timeline.hasActiveFlip() ||
|
||||
this.cameraTween !== null ||
|
||||
this.zoomTarget !== null ||
|
||||
this.timelineSlide !== null;
|
||||
if (animating) this._needsRender = true;
|
||||
|
||||
this.binder.update(dt);
|
||||
this.timeline.update(dt);
|
||||
this.timeline.drainPending();
|
||||
this._updateTimelineTextures();
|
||||
if (this.timeline.consumeDirty()) this._needsRender = true;
|
||||
|
||||
if (this.cameraTween) {
|
||||
const t = this.cameraTween;
|
||||
@@ -477,7 +592,19 @@ export class App {
|
||||
if (k >= 1) this.timelineSlide = null;
|
||||
}
|
||||
|
||||
// 鼠标悬停检测:每帧至多一次(pointermove 高频事件被合帧)
|
||||
if (this._hoverDirty) {
|
||||
this._hoverDirty = false;
|
||||
const hit = this._hitTest();
|
||||
this.renderer.domElement.style.cursor = hit ? 'pointer' : 'grab';
|
||||
}
|
||||
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
|
||||
// 按需渲染:仅在状态/动画/交互变化时重绘,静止时停止渲染
|
||||
if (this._needsRender) {
|
||||
this._needsRender = false;
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+311
-129
File diff suppressed because it is too large
Load Diff
+20
-11
@@ -1,23 +1,32 @@
|
||||
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 禁止选中)
|
||||
for (const ev of ['copy', 'cut', 'contextmenu']) {
|
||||
document.addEventListener(ev, (e) => e.preventDefault());
|
||||
}
|
||||
|
||||
// OS1 加载环动画
|
||||
const loadingAnim = initLoading();
|
||||
|
||||
// 启动应用
|
||||
const app = new App();
|
||||
window.__app = app; // 便于调试
|
||||
|
||||
// 加载界面:等三个视图初始化完成(最少展示 1 秒,最多等 8 秒)后淡出
|
||||
const t0 = Date.now();
|
||||
const MIN_DISPLAY = 1000;
|
||||
Promise.race([
|
||||
app.globe.ready,
|
||||
new Promise((r) => setTimeout(r, 8000)),
|
||||
]).then(() => {
|
||||
const wait = Math.max(0, MIN_DISPLAY - (Date.now() - t0));
|
||||
setTimeout(() => {
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
}, wait);
|
||||
// 首帧渲染后立即后台预载地理视图(与 loading 并行)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
app.preloadGlobe();
|
||||
});
|
||||
});
|
||||
|
||||
// 强制 6 秒后淡出加载界面
|
||||
const wait = Math.max(0, MIN_DISPLAY - (Date.now() - t0));
|
||||
setTimeout(() => {
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
loadingAnim.dispose();
|
||||
}, wait);
|
||||
|
||||
+438
-134
@@ -1,15 +1,31 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { ENTRIES } from '../data/entries.js';
|
||||
|
||||
// Cesium 懒加载:脚本首次进入“地理”视图时才加载;用 Proxy 惰性访问 window.Cesium,
|
||||
// 使下方 50 余处 Cesium.xxx 引用无需逐个改动。
|
||||
const Cesium = new Proxy({}, {
|
||||
get(_t, prop) { return window.Cesium[prop]; },
|
||||
});
|
||||
|
||||
const CESIUM_BASE = '/cesium';
|
||||
|
||||
function loadCesium() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.Cesium) return resolve();
|
||||
window.CESIUM_BASE_URL = CESIUM_BASE;
|
||||
const s = document.createElement('script');
|
||||
s.src = `${CESIUM_BASE}/Cesium.js`;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error('Cesium 脚本加载失败'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
// 免费、无需 Ion 令牌的影像与地形源
|
||||
const IMAGERY_URL =
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
|
||||
const TERRAIN_URL =
|
||||
'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer';
|
||||
|
||||
// 点击标记后飞到的目标高度(米,与初始视图一致,保证左键拖拽始终是“整球旋转”)
|
||||
const FLY_TO_HEIGHT = 7.2e6;
|
||||
|
||||
// 计算多边形集合的球面面积(km²),用于按国土面积分级国家名字号
|
||||
function sphericalAreaKm2(polys) {
|
||||
let area = 0;
|
||||
@@ -36,29 +52,73 @@ function sphericalAreaKm2(polys) {
|
||||
return area * R * R;
|
||||
}
|
||||
|
||||
// 生成圆角黑色背景 canvas(尺寸按文字实际测量宽度)
|
||||
function makeRoundedBg(text, fontSize) {
|
||||
const padX = 12, padY = 7, radius = 10;
|
||||
// 生成便签 canvas:黄底圆角 + 左侧箭头 + 黑色加粗文字(整张图,箭头尖端指向坐标)
|
||||
// 选中态:先描粗边再填充,得到一条干净的 2px 外描边(四周预留 B 的 padding 防止裁切)
|
||||
function makeStickyNote(text, fontSize, selected = false) {
|
||||
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 m = document.createElement('canvas');
|
||||
const mctx = m.getContext('2d');
|
||||
mctx.font = `${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`;
|
||||
mctx.font = font;
|
||||
const textW = mctx.measureText(text).width;
|
||||
const w = Math.max(1, Math.ceil(textW + padX * 2));
|
||||
const bodyW = Math.max(1, Math.ceil(textW + padX * 2));
|
||||
const w = arrowW + bodyW;
|
||||
const h = Math.ceil(fontSize + padY * 2);
|
||||
const cw = w + B * 2;
|
||||
const ch = h + B * 2;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
c.width = cw * S;
|
||||
c.height = ch * S;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.scale(S, S);
|
||||
|
||||
const ox = B, oy = B; // 形状整体内缩 B,给黑边留空间
|
||||
|
||||
// 箭头 + 圆角矩形一体化路径(箭头尖端在 (ox, oy + h/2),指向坐标)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(radius, 0);
|
||||
ctx.arcTo(w, 0, w, h, radius);
|
||||
ctx.arcTo(w, h, 0, h, radius);
|
||||
ctx.arcTo(0, h, 0, 0, radius);
|
||||
ctx.arcTo(0, 0, w, 0, radius);
|
||||
ctx.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();
|
||||
ctx.fillStyle = 'rgba(20, 16, 13, 0.88)';
|
||||
ctx.fill();
|
||||
return { canvas: c, textW };
|
||||
|
||||
if (selected) {
|
||||
// 选中:黑底金字,外圈 2px 金边(先描边再填充,外露一半)
|
||||
ctx.strokeStyle = '#e0c46a';
|
||||
ctx.lineWidth = B * 2;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#14100d';
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#e0c46a';
|
||||
} else {
|
||||
// 非选中:金底黑字 + 1px 黑边(描 2px,填充盖掉内侧 1px)+ 柔和投影
|
||||
ctx.strokeStyle = '#14100d';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.20)';
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.shadowOffsetY = 2;
|
||||
ctx.fillStyle = '#e0c46a';
|
||||
ctx.fill();
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.fillStyle = '#14100d';
|
||||
}
|
||||
|
||||
// 文字
|
||||
ctx.font = font;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(text, ox + arrowW + bodyW / 2 - 2, oy + h / 2 + 1);
|
||||
|
||||
return { canvas: c, scale: 1 / S, width: cw, height: ch, tipX: B, tipY: B + h / 2 };
|
||||
}
|
||||
|
||||
export class CesiumGlobe {
|
||||
@@ -71,12 +131,51 @@ export class CesiumGlobe {
|
||||
this._visible = false;
|
||||
this._pendingData = 2; // 国家边界 + 城市数据
|
||||
this.ready = new Promise((resolve) => { this._resolveReady = resolve; });
|
||||
this.viewerReady = new Promise((resolve) => { this._resolveViewerReady = resolve; });
|
||||
}
|
||||
|
||||
ensureCreated() {
|
||||
async ensureCreated() {
|
||||
if (this._created) return;
|
||||
this._created = true;
|
||||
|
||||
try {
|
||||
await loadCesium();
|
||||
} catch (err) {
|
||||
this._created = false; // 失败可重试
|
||||
console.warn('[CesiumGlobe] Cesium 脚本加载失败:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
// 调整实体渲染顺序:先渲染标签(城市/国家名),再渲染 Billboard(标记便签),
|
||||
// 让词条标记始终显示在城市/国家名的上层。
|
||||
Cesium.DataSourceDisplay.defaultVisualizersCallback = function (
|
||||
scene,
|
||||
entityCluster,
|
||||
dataSource,
|
||||
) {
|
||||
const entities = dataSource.entities;
|
||||
return [
|
||||
new Cesium.LabelVisualizer(entityCluster, entities),
|
||||
new Cesium.BillboardVisualizer(entityCluster, entities),
|
||||
new Cesium.GeometryVisualizer(
|
||||
scene,
|
||||
entities,
|
||||
dataSource._primitives,
|
||||
dataSource._groundPrimitives,
|
||||
),
|
||||
new Cesium.ModelVisualizer(scene, entities),
|
||||
new Cesium.Cesium3DTilesetVisualizer(scene, entities),
|
||||
new Cesium.PointVisualizer(entityCluster, entities),
|
||||
new Cesium.PathVisualizer(scene, entities),
|
||||
new Cesium.PolylineVisualizer(
|
||||
scene,
|
||||
entities,
|
||||
dataSource._primitives,
|
||||
dataSource._groundPrimitives,
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const container = document.getElementById('cesium-container');
|
||||
|
||||
this.viewer = new Cesium.Viewer(container, {
|
||||
@@ -105,12 +204,12 @@ export class CesiumGlobe {
|
||||
scene.moon.show = false;
|
||||
scene.globe.enableLighting = false;
|
||||
scene.globe.showGroundAtmosphere = false;
|
||||
scene.globe.baseColor = Cesium.Color.fromCssColorString('#0a1626');
|
||||
scene.globe.baseColor = Cesium.Color.fromCssColorString('#d9e6f0'); // 冰盖浅色:影像/地形未覆盖的南北极区域显示为冰盖,而非黑洞
|
||||
|
||||
// 相机交互:限制滚轮缩放范围,避免过近/过远
|
||||
const controller = scene.screenSpaceCameraController;
|
||||
controller.enableCollisionDetection = true; // 必须开启:缩放距离限制在关闭时会被忽略
|
||||
controller.minimumZoomDistance = 10000; // 最近约 10 km(区域尺度)
|
||||
controller.minimumZoomDistance = 500; // 最近约 500 m(街区尺度)
|
||||
controller.maximumZoomDistance = 8e6; // 最远约 8000 km
|
||||
|
||||
// 限制鼠标按钮:只保留左键旋转 + 滚轮缩放;禁用中键倾斜、右键缩放
|
||||
@@ -120,6 +219,7 @@ export class CesiumGlobe {
|
||||
Cesium.CameraEventType.PINCH,
|
||||
];
|
||||
controller.tiltEventTypes = [
|
||||
Cesium.CameraEventType.RIGHT_DRAG, // 右键 = 原生 tilt(传统环视)
|
||||
Cesium.CameraEventType.PINCH,
|
||||
{
|
||||
eventType: Cesium.CameraEventType.LEFT_DRAG,
|
||||
@@ -127,23 +227,64 @@ export class CesiumGlobe {
|
||||
},
|
||||
];
|
||||
|
||||
// 阻止右键弹出浏览器菜单
|
||||
// 阻止右键弹出浏览器菜单;阻止中键触发浏览器自动滚动
|
||||
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.fxaa = false; // 关闭抗锯齿后处理,省一个全屏通道
|
||||
viewer.resolutionScale = 1.5; // 1.5 倍超采样,替代 FXAA 提供更锐利的抗锯齿
|
||||
scene.fxaa = true; // 后处理抗锯齿(MSAA 默认已 4x)
|
||||
viewer.useBrowserRecommendedResolution = false; // 按设备像素比渲染,高分屏清晰
|
||||
viewer.resolutionScale = 1.0;
|
||||
scene.maximumRenderTimeChange = Infinity; // 配合 requestRenderMode:静止时停止重绘,大幅省电省帧
|
||||
|
||||
// 卫星影像
|
||||
// 检测 ESRI "Map data not yet available" 灰色错误瓦片并丢弃,自动回退父级放大
|
||||
const discardNotAvailableTile = {
|
||||
_canvas: document.createElement('canvas'),
|
||||
_ctx: null,
|
||||
isReady() {
|
||||
return true;
|
||||
},
|
||||
shouldDiscardImage(image) {
|
||||
try {
|
||||
if (!this._ctx) {
|
||||
this._ctx = this._canvas.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
this._canvas.width = 8;
|
||||
this._canvas.height = 8;
|
||||
this._ctx.drawImage(image, 0, 0, 8, 8);
|
||||
const d = this._ctx.getImageData(0, 0, 8, 8).data;
|
||||
let gray = 0;
|
||||
let total = 0;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const r = d[i];
|
||||
const g = d[i + 1];
|
||||
const b = d[i + 2];
|
||||
const mx = Math.max(r, g, b);
|
||||
const mn = Math.min(r, g, b);
|
||||
if (mx - mn < 15 && r > 110 && r < 240) gray++;
|
||||
total++;
|
||||
}
|
||||
return total > 0 && gray / total > 0.92;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const imagery = new Cesium.UrlTemplateImageryProvider({
|
||||
url: IMAGERY_URL,
|
||||
credit: 'Imagery © Esri, Maxar, Earthstar Geographics',
|
||||
maximumLevel: 19,
|
||||
tileDiscardPolicy: discardNotAvailableTile,
|
||||
});
|
||||
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;失败则保持默认椭球)
|
||||
const terrain = new Cesium.Terrain(
|
||||
@@ -157,87 +298,219 @@ export class CesiumGlobe {
|
||||
viewer.clock.shouldAnimate = false;
|
||||
|
||||
this._buildMarkers();
|
||||
this._clampMarkersToTerrain();
|
||||
this._setupPick();
|
||||
this._setupRightRestore();
|
||||
this._loadBoundaries();
|
||||
this._buildCityLabels();
|
||||
|
||||
viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(35, 12, 7.2e6),
|
||||
});
|
||||
|
||||
// 创建完成:同步当前选中/可见/相机状态(首次进入时这些调用可能因 viewer 未建而跳过)
|
||||
this.setSelected(this.selected);
|
||||
this.setVisible(this._visible);
|
||||
this.resize();
|
||||
this.flyToEntry(this.selected);
|
||||
|
||||
// 地球已就绪(影像/地形/城市数据继续后台加载),通知 App 隐藏加载遮罩
|
||||
if (this._resolveViewerReady) {
|
||||
this._resolveViewerReady();
|
||||
this._resolveViewerReady = null;
|
||||
}
|
||||
}
|
||||
|
||||
_buildMarkers() {
|
||||
const viewer = this.viewer;
|
||||
const gold = Cesium.Color.fromCssColorString('#e0c46a');
|
||||
const ivory = Cesium.Color.fromCssColorString('#e8d9b4');
|
||||
const ink = Cesium.Color.fromCssColorString('#14100d');
|
||||
|
||||
ENTRIES.forEach((entry, i) => {
|
||||
const entity = viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat),
|
||||
point: {
|
||||
pixelSize: 10, // 标记宽一点
|
||||
color: gold, // 金色圆形
|
||||
outlineColor: ink, // 外侧 1px 黑边
|
||||
outlineWidth: 1,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
|
||||
},
|
||||
label: {
|
||||
text: entry.name,
|
||||
font: '19px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif',
|
||||
fillColor: gold,
|
||||
outlineColor: ink,
|
||||
outlineWidth: 3,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.LEFT,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
pixelOffset: new Cesium.Cartesian2(10, 0),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
|
||||
show: true,
|
||||
},
|
||||
const position = Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat);
|
||||
const noteNormal = makeStickyNote(entry.name, 14, false);
|
||||
const noteSelected = makeStickyNote(entry.name, 14, true);
|
||||
this.markers.push({
|
||||
lng: entry.lng,
|
||||
lat: entry.lat,
|
||||
position,
|
||||
r2: Cesium.Cartesian3.magnitudeSquared(position),
|
||||
noteNormal,
|
||||
noteSelected,
|
||||
index: i,
|
||||
});
|
||||
|
||||
entity.entryIndex = i;
|
||||
|
||||
// 选中时显示的圆角黑色背景
|
||||
const bg = makeRoundedBg(entry.name, 19);
|
||||
const bgEntity = viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat),
|
||||
billboard: {
|
||||
image: bg.canvas,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
pixelOffset: new Cesium.Cartesian2(10 + bg.textW / 2, 0),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
show: false,
|
||||
},
|
||||
});
|
||||
|
||||
this.markers.push({ entity, bg: bgEntity, index: i });
|
||||
});
|
||||
this._initMarkerLayer();
|
||||
}
|
||||
|
||||
// 把便签锚点贴到地形表面(否则在椭球面高度 0 上,放大旋转时会飘)
|
||||
async _clampMarkersToTerrain() {
|
||||
const scene = this.viewer.scene;
|
||||
if (!scene) return;
|
||||
|
||||
// 等待地形 provider 就绪(setTerrain 是异步的,调用时 provider 可能还没准备好)
|
||||
let tp = scene.terrainProvider;
|
||||
if (!tp) {
|
||||
tp = await new Promise((resolve) => {
|
||||
const started = Date.now();
|
||||
const poll = () => {
|
||||
const p = scene.terrainProvider;
|
||||
if (p) { resolve(p); return; }
|
||||
if (Date.now() - started > 30000) { resolve(undefined); return; }
|
||||
setTimeout(poll, 500);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
if (!tp) {
|
||||
console.warn('[CesiumGlobe] 地形 provider 未就绪,便签保持椭球面高度');
|
||||
return;
|
||||
}
|
||||
|
||||
const cartos = this.markers.map((m) =>
|
||||
Cesium.Cartographic.fromDegrees(m.lng, m.lat),
|
||||
);
|
||||
try {
|
||||
// 固定层级 15(约 30m):ArcGIS 高程服务无 availability,不用 sampleTerrainMostDetailed
|
||||
await Cesium.sampleTerrain(tp, 15, cartos);
|
||||
} catch (err) {
|
||||
console.warn('[CesiumGlobe] 地形高度采样失败:', err);
|
||||
}
|
||||
|
||||
// 失败的点逐级降级重试(更粗的层级更容易成功)
|
||||
for (const level of [12, 8, 4]) {
|
||||
const retry = cartos.filter((c) => c.height == null || !isFinite(c.height));
|
||||
if (retry.length === 0) break;
|
||||
try {
|
||||
await Cesium.sampleTerrain(tp, level, retry);
|
||||
} catch (err) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let clamped = 0;
|
||||
for (let i = 0; i < this.markers.length; i++) {
|
||||
const h = cartos[i] && cartos[i].height;
|
||||
if (h != null && isFinite(h)) {
|
||||
const m = this.markers[i];
|
||||
m.position = Cesium.Cartesian3.fromDegrees(m.lng, m.lat, h);
|
||||
m.r2 = Cesium.Cartesian3.magnitudeSquared(m.position);
|
||||
clamped++;
|
||||
}
|
||||
}
|
||||
console.log(`[CesiumGlobe] 便签已贴附地形:${clamped}/${this.markers.length}`);
|
||||
}
|
||||
|
||||
// 便签用 DOM 覆盖层渲染:始终在城市/国家名上层,且缩放到最近也不会漂移
|
||||
_initMarkerLayer() {
|
||||
const container = document.getElementById('cesium-container');
|
||||
this._markerLayer = document.createElement('div');
|
||||
this._markerLayer.id = 'marker-layer';
|
||||
container.appendChild(this._markerLayer);
|
||||
|
||||
for (const m of this.markers) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'marker-note';
|
||||
el.style.width = `${m.noteNormal.width}px`;
|
||||
el.style.height = `${m.noteNormal.height}px`;
|
||||
el.dataset.index = m.index;
|
||||
m.el = el;
|
||||
m.tipX = m.noteNormal.tipX; // 箭头尖相对元素左上角的偏移
|
||||
m.tipY = m.noteNormal.tipY;
|
||||
m.normalUrl = m.noteNormal.canvas.toDataURL();
|
||||
m.selectedUrl = m.noteSelected.canvas.toDataURL();
|
||||
m.screenX = -1e9;
|
||||
m.screenY = -1e9;
|
||||
el.style.backgroundImage = `url("${m.normalUrl}")`;
|
||||
el.style.transform = 'rotate(-45deg)';
|
||||
el.style.transformOrigin = `${m.tipX}px ${m.tipY}px`;
|
||||
this._markerLayer.appendChild(el);
|
||||
}
|
||||
|
||||
// 用 postUpdate(每帧都触发,即使 requestRenderMode 下不真正渲染)更新便签位置,
|
||||
// 避免拖拽时 postRender 不触发导致的闪烁/消失。
|
||||
this.viewer.scene.postUpdate.addEventListener(() => this._updateMarkerLayer());
|
||||
}
|
||||
|
||||
// 每帧更新便签屏幕位置(相机移动/缩放/飞行时跟随)
|
||||
_updateMarkerLayer() {
|
||||
const scene = this.viewer.scene;
|
||||
const camera = scene.camera;
|
||||
const camPos = camera.positionWC; // 每帧只取一次相机位置
|
||||
|
||||
for (const m of this.markers) {
|
||||
const el = m.el;
|
||||
if (!el) continue;
|
||||
|
||||
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, m.position);
|
||||
if (!win) { el.style.display = 'none'; continue; }
|
||||
|
||||
// 前/后半球:dot(P, C) > |P|²;背面隐藏(不渐变淡出)
|
||||
const frontness = Cesium.Cartesian3.dot(m.position, camPos) - m.r2;
|
||||
if (frontness <= 0) { el.style.display = 'none'; continue; }
|
||||
|
||||
el.style.display = 'block';
|
||||
el.style.opacity = '1';
|
||||
m.screenX = win.x - m.tipX;
|
||||
m.screenY = win.y - m.tipY;
|
||||
el.style.left = `${m.screenX}px`;
|
||||
el.style.top = `${m.screenY}px`;
|
||||
}
|
||||
}
|
||||
|
||||
_setupPick() {
|
||||
const viewer = this.viewer;
|
||||
// 移除 Viewer 默认的单击选中与双击“追踪实体”:否则双击后会锁定相机(定住)
|
||||
// 移除 Viewer 默认的单击选中与双击“追踪实体”
|
||||
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||||
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction((movement) => {
|
||||
const picked = viewer.scene.pick(movement.position);
|
||||
if (Cesium.defined(picked) && picked.id && picked.id.entryIndex !== undefined) {
|
||||
const index = picked.id.entryIndex;
|
||||
this.onPick?.(index);
|
||||
this.flyToEntry(index);
|
||||
// 便签层 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);
|
||||
this._handler = handler;
|
||||
}
|
||||
|
||||
// 右键原生 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;
|
||||
}
|
||||
|
||||
// 相机平滑飞至某个标记上空并居中:回到与初始一致的高度与北朝上方向,左键拖拽手感不变
|
||||
@@ -247,8 +520,10 @@ export class CesiumGlobe {
|
||||
const now = Date.now();
|
||||
if (this._lastFly && now - this._lastFly < 400) return; // 双击/连点去抖,避免重复飞
|
||||
this._lastFly = now;
|
||||
this.viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat, FLY_TO_HEIGHT),
|
||||
const camera = this.viewer.camera;
|
||||
const height = camera.positionCartographic.height; // 保持当前缩放,平移过去
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat, height),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(-90),
|
||||
@@ -266,6 +541,10 @@ export class CesiumGlobe {
|
||||
const textGold = Cesium.Color.fromCssColorString('#e0c46a');
|
||||
const black = Cesium.Color.fromCssColorString('#14100d');
|
||||
|
||||
// 国家名用 LabelCollection(避免大量 entity 开销)
|
||||
const countryLabels = new Cesium.LabelCollection({ scene: this.viewer.scene });
|
||||
this.viewer.scene.primitives.add(countryLabels);
|
||||
|
||||
const lines = [];
|
||||
for (const feature of geojson.features || []) {
|
||||
const props = feature.properties || {};
|
||||
@@ -275,21 +554,23 @@ export class CesiumGlobe {
|
||||
const areaKm2 = isPoly ? sphericalAreaKm2(polys) : 0;
|
||||
|
||||
// 国家名(优先中文),落在 NATURAL EARTH 提供的标注点上,字号按国土面积分级
|
||||
const cname = props.NAME_ZH || props.NAME;
|
||||
let cname = props.NAME_ZH || props.NAME;
|
||||
if (cname === '中华民国') cname = '中国台湾';
|
||||
if (cname && props.LABEL_X != null && props.LABEL_Y != null) {
|
||||
const fontSize = areaKm2 > 5e6 ? 30 : areaKm2 > 1.5e6 ? 19 : areaKm2 > 3e5 ? 15 : 12;
|
||||
this.viewer.entities.add({
|
||||
const fontSize = areaKm2 > 5e6 ? 28 : areaKm2 > 1.5e6 ? 21 : areaKm2 > 3e5 ? 18 : 16;
|
||||
countryLabels.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(props.LABEL_X, props.LABEL_Y),
|
||||
label: {
|
||||
text: cname,
|
||||
font: `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`,
|
||||
fillColor: black,
|
||||
style: Cesium.LabelStyle.FILL,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(1e6, 1e7),
|
||||
scaleByDistance: new Cesium.NearFarScalar(1e4, 20 / 15, 6e6, 1.0),
|
||||
translucencyByDistance: new Cesium.NearFarScalar(9e6, 1.0, 1e7, 0.0),
|
||||
},
|
||||
text: cname,
|
||||
font: `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`,
|
||||
fillColor: textGold,
|
||||
outlineColor: black,
|
||||
outlineWidth: 2,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: 1000, // 1km 内不深度测试(近处不消失),远处仍遮挡背面
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
|
||||
scaleByDistance: new Cesium.NearFarScalar(1e4, 20 / 15, 6e6, 1.0),
|
||||
translucencyByDistance: new Cesium.NearFarScalar(9e6, 1.0, 1e7, 0.0),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -301,7 +582,7 @@ export class CesiumGlobe {
|
||||
for (const [lng, lat] of ring) flat.push(lng, lat);
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(flat);
|
||||
// 行政区划:一根黑线
|
||||
lines.push(new Cesium.GeometryInstance({ geometry: new Cesium.GroundPolylineGeometry({ positions, width: 2.0 }) }));
|
||||
lines.push(new Cesium.GeometryInstance({ geometry: new Cesium.GroundPolylineGeometry({ positions, width: 2.6 }) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,11 +606,11 @@ export class CesiumGlobe {
|
||||
|
||||
// 全部城市:Natural Earth 10m 人口稠密点(7342 个),中文名直接用数据自带的 name_zh
|
||||
async _buildCityLabels() {
|
||||
const viewer = this.viewer;
|
||||
const scene = this.viewer.scene;
|
||||
const textGold = Cesium.Color.fromCssColorString('#e0c46a');
|
||||
const black = Cesium.Color.fromCssColorString('#14100d');
|
||||
const ddc = new Cesium.DistanceDisplayCondition(0, 4e6);
|
||||
const tdc = new Cesium.NearFarScalar(2e6, 1.0, 4e6, 0.0);
|
||||
const ddc = new Cesium.DistanceDisplayCondition(0, 2.5e6);
|
||||
const tdc = new Cesium.NearFarScalar(1.25e6, 1.0, 2.5e6, 0.0);
|
||||
|
||||
let cities = [];
|
||||
try {
|
||||
@@ -340,30 +621,37 @@ export class CesiumGlobe {
|
||||
cities = [];
|
||||
}
|
||||
|
||||
// 用 primitive 集合替代 7342 个 entity,大幅降低 Cesium 实体系统开销
|
||||
const points = new Cesium.PointPrimitiveCollection();
|
||||
const labels = new Cesium.LabelCollection({ scene });
|
||||
scene.primitives.add(points);
|
||||
scene.primitives.add(labels);
|
||||
|
||||
for (const c of cities) {
|
||||
viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(c.lng, c.lat),
|
||||
point: {
|
||||
pixelSize: 3,
|
||||
color: textGold,
|
||||
outlineColor: black,
|
||||
outlineWidth: 1,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
distanceDisplayCondition: ddc,
|
||||
translucencyByDistance: tdc,
|
||||
},
|
||||
label: {
|
||||
text: c.name,
|
||||
font: 'bold 14px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif',
|
||||
fillColor: black,
|
||||
style: Cesium.LabelStyle.FILL,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -6),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
distanceDisplayCondition: ddc,
|
||||
translucencyByDistance: tdc,
|
||||
},
|
||||
const position = Cesium.Cartesian3.fromDegrees(c.lng, c.lat);
|
||||
points.add({
|
||||
position,
|
||||
pixelSize: 3,
|
||||
color: textGold,
|
||||
outlineColor: black,
|
||||
outlineWidth: 1,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
});
|
||||
labels.add({
|
||||
position,
|
||||
text: c.name,
|
||||
font: 'bold 17px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif',
|
||||
fillColor: textGold,
|
||||
outlineColor: black,
|
||||
outlineWidth: 2,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -6),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: 1000, // 1km 内不深度测试(近处不消失),远处仍遮挡背面
|
||||
distanceDisplayCondition: ddc,
|
||||
translucencyByDistance: tdc,
|
||||
});
|
||||
}
|
||||
this._dataReady();
|
||||
@@ -380,12 +668,10 @@ export class CesiumGlobe {
|
||||
setSelected(index) {
|
||||
this.selected = index;
|
||||
if (!this.viewer) return;
|
||||
this.markers.forEach(({ entity, bg, index: i }) => {
|
||||
const on = i === index;
|
||||
entity.point.outlineWidth = on ? 3 : 1; // 选中加粗黑圈,不放大
|
||||
if (bg) bg.show = on;
|
||||
this.markers.forEach((m) => {
|
||||
const on = m.index === index;
|
||||
if (m.el) m.el.style.backgroundImage = `url("${on ? m.selectedUrl : m.normalUrl}")`;
|
||||
});
|
||||
this.viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
setVisible(visible) {
|
||||
@@ -393,6 +679,24 @@ export class CesiumGlobe {
|
||||
if (visible && this.viewer) this.viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
// 等待当前视图的影像/地形瓦片加载完成(超时兜底),用于切换时“一起出现”
|
||||
waitUntilViewReady(timeout = 4000) {
|
||||
return new Promise((resolve) => {
|
||||
const globe = this.viewer.scene.globe;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
globe.tileLoadProgressEvent.removeEventListener(onProgress);
|
||||
resolve();
|
||||
};
|
||||
const onProgress = (remaining) => { if (remaining <= 0) finish(); };
|
||||
globe.tileLoadProgressEvent.addEventListener(onProgress);
|
||||
if (globe.tilesLoaded) finish();
|
||||
setTimeout(finish, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (this.viewer) this.viewer.resize();
|
||||
}
|
||||
|
||||
+200
-75
@@ -1,11 +1,13 @@
|
||||
import * as THREE from 'three';
|
||||
import { ENTRIES } from '../data/entries.js';
|
||||
import { PAGE_W, PAGE_H, HOLE_U, getPageTextures } from '../textures/pages.js';
|
||||
import { clamp, easeOutCubic } from '../utils/easing.js';
|
||||
import { PAGE_W, PAGE_H, HOLE_U, acquirePageTextures, releasePageTextures, getPlaceholderTextures, prefetchPageTextures, makeTwoSidedMaterial } from '../textures/pages.js';
|
||||
import { clamp, easeOutCubic, easeInOutCubic } from '../utils/easing.js';
|
||||
|
||||
const RING_R = 0.085;
|
||||
const RING_TUBE = 0.0115;
|
||||
const STACK_GAP = 0.004;
|
||||
const TOP_LIFT = 0.02; // 顶层左右两页高出栈内的距离
|
||||
const FLIP_LIFT = 0.028; // 翻页过程中的最大抬高(略高于顶层静止高度)
|
||||
const PAGE_GAP = 0.02; // 页内缘离书脊的小空隙
|
||||
|
||||
function makeLeatherTexture({ emblem = false } = {}) {
|
||||
@@ -85,7 +87,7 @@ function makeSuedeTexture() {
|
||||
}
|
||||
|
||||
// 圆角矩形轮廓(直线段 + 圆弧段都用 lineTo 细分,正反面/皮边共享同一轮廓)
|
||||
function roundedRectShape(w, h, r, edgeSeg = 40, arcSeg = 12) {
|
||||
function roundedRectShape(w, h, r, edgeSeg = 16, arcSeg = 10) {
|
||||
const hw = w / 2, hh = h / 2;
|
||||
const s = new THREE.Shape();
|
||||
s.moveTo(-hw + r, hh);
|
||||
@@ -112,7 +114,7 @@ function roundedRectShape(w, h, r, edgeSeg = 40, arcSeg = 12) {
|
||||
// 软皮曲面:ShapeGeometry 三角化(圆角正确、无缺口)+ 书脊处弧度弯曲
|
||||
function makeLeatherSurface(w, h, crXY, curve, flip) {
|
||||
// 规则网格(含内部顶点,曲面平滑无对角褶痕)+ 角区收缩到圆弧
|
||||
const segX = 128, segY = 96;
|
||||
const segX = 64, segY = 48;
|
||||
const hw = w / 2, hh = h / 2;
|
||||
const bx = hw - crXY, by = hh - crXY;
|
||||
const sigma = 0.5;
|
||||
@@ -232,7 +234,7 @@ function makeLeatherNormalMap() {
|
||||
|
||||
// 内侧绒面法线:密集细绒毛(高频噪点)
|
||||
function makeSuedeNormalMap() {
|
||||
const w = 1024, h = 1280;
|
||||
const w = 512, h = 640;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const ctx = c.getContext('2d', { willReadFrequently: true });
|
||||
@@ -276,10 +278,13 @@ export class Binder {
|
||||
this.pages = [];
|
||||
this.current = 0;
|
||||
this.flip = null; // { index, from, to, t, duration }
|
||||
this.resetTween = null; // 回卷重置动画
|
||||
this.onChange = null;
|
||||
|
||||
this.suedeTex = makeSuedeTexture();
|
||||
this.suedeNormalTex = makeSuedeNormalMap();
|
||||
this.placeholder = getPlaceholderTextures();
|
||||
this._textured = new Set();
|
||||
this._buildCovers();
|
||||
this._buildHingePlate();
|
||||
this._buildRings();
|
||||
@@ -335,7 +340,7 @@ export class Binder {
|
||||
plate.receiveShadow = true;
|
||||
|
||||
// 铆钉:把薄片钉在牛皮上(覆盖新的铁环范围 ±0.896)
|
||||
const rivetGeo = new THREE.SphereGeometry(0.013, 12, 10);
|
||||
const rivetGeo = new THREE.SphereGeometry(0.013, 8, 6);
|
||||
const rivets = [];
|
||||
for (const ry of [-0.78, -0.39, 0, 0.39, 0.78]) {
|
||||
const rivet = new THREE.Mesh(rivetGeo, mat);
|
||||
@@ -355,7 +360,7 @@ export class Binder {
|
||||
color: 0xd4d7dc, metalness: 0.95, roughness: 0.2,
|
||||
});
|
||||
// 更平滑的活页环
|
||||
const geo = new THREE.TorusGeometry(RING_R, RING_TUBE, 18, 56);
|
||||
const geo = new THREE.TorusGeometry(RING_R, RING_TUBE, 12, 24);
|
||||
this.rings = [];
|
||||
for (const u of HOLE_U) {
|
||||
const y = (0.5 - u) * PAGE_H;
|
||||
@@ -368,52 +373,46 @@ export class Binder {
|
||||
}
|
||||
}
|
||||
|
||||
_makePageGeometry(segX, segY) {
|
||||
// 纯平面页(无自然弧度):页内缘离书脊留出小空隙,左右页不紧贴
|
||||
const geo = new THREE.PlaneGeometry(PAGE_W, PAGE_H, segX, segY);
|
||||
geo.translate(PAGE_W / 2 + PAGE_GAP, 0, 0);
|
||||
return geo;
|
||||
}
|
||||
|
||||
_ensureHiGeo(p) {
|
||||
if (!p.hiGeo) {
|
||||
p.hiGeo = this._makePageGeometry(96, 8);
|
||||
p.hiBase = Float32Array.from(p.hiGeo.attributes.position.array);
|
||||
}
|
||||
}
|
||||
|
||||
_buildPages() {
|
||||
const segX = 96, segY = 8;
|
||||
// 共享极简几何(所有栈内页相同,只露边缘无需弯曲)
|
||||
this.loGeo = this._makePageGeometry(1, 1);
|
||||
|
||||
ENTRIES.forEach((entry, i) => {
|
||||
const geo = new THREE.PlaneGeometry(PAGE_W, PAGE_H, segX, segY);
|
||||
// 页内缘离书脊留出小空隙,左右页不紧贴
|
||||
geo.translate(PAGE_W / 2 + PAGE_GAP, 0, 0);
|
||||
// 纸张自然弧度:自由边略向正面翘起
|
||||
const pa = geo.attributes.position;
|
||||
for (let k = 0; k < pa.count; k++) {
|
||||
const x = pa.getX(k), y = pa.getY(k);
|
||||
const u = Math.min(1, Math.max(0, (x - PAGE_GAP) / PAGE_W));
|
||||
const yNorm = (y + PAGE_H / 2) / PAGE_H;
|
||||
pa.setZ(k, pa.getZ(k) + 0.05 * Math.sin((Math.PI * u) / 2) * (0.8 + 0.2 * yNorm));
|
||||
}
|
||||
pa.needsUpdate = true;
|
||||
const base = Float32Array.from(geo.attributes.position.array);
|
||||
const { front, back } = getPageTextures(entry);
|
||||
|
||||
const frontMat = new THREE.MeshStandardMaterial({
|
||||
map: front, roughness: 0.88, metalness: 0.0, side: THREE.FrontSide,
|
||||
transparent: true, alphaTest: 0.5,
|
||||
// 单 mesh + 双面材质:正面登记卡、背面素描(gl_FrontFacing 自动选择)
|
||||
// 活页孔用 alphaTest 直接丢弃透明像素(不启用 transparent,避免透明+双面拆两次 draw call)
|
||||
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, {
|
||||
alphaTest: 0.5,
|
||||
});
|
||||
const backMat = new THREE.MeshStandardMaterial({
|
||||
map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide,
|
||||
transparent: true, alphaTest: 0.5,
|
||||
});
|
||||
|
||||
const frontMesh = new THREE.Mesh(geo, frontMat);
|
||||
frontMesh.position.z = 0.0008;
|
||||
frontMesh.castShadow = true;
|
||||
const backMesh = new THREE.Mesh(geo, backMat);
|
||||
backMesh.position.z = -0.0008;
|
||||
backMesh.castShadow = false;
|
||||
const mesh = new THREE.Mesh(this.loGeo, mat); // 初始为栈内页,用极简几何
|
||||
mesh.castShadow = false; // 由 _restackState 依据是否为顶层页控制
|
||||
|
||||
const g = new THREE.Group();
|
||||
g.add(frontMesh, backMesh);
|
||||
g.add(mesh);
|
||||
g.userData.index = i;
|
||||
g.userData.entry = entry;
|
||||
|
||||
this.pages.push({
|
||||
group: g,
|
||||
geometry: geo,
|
||||
base,
|
||||
frontMat,
|
||||
backMat,
|
||||
hiGeo: null, // 高分段几何延迟创建(成为顶层页时才需要弯曲)
|
||||
hiBase: null,
|
||||
loGeo: this.loGeo,
|
||||
material: mat,
|
||||
mesh,
|
||||
isPlaceholder: true,
|
||||
angle: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
@@ -432,34 +431,96 @@ export class Binder {
|
||||
const angle = j < this.current ? -Math.PI : 0;
|
||||
p.angle = angle;
|
||||
p.group.rotation.y = angle;
|
||||
const depth = j < this.current ? (this.current - 1 - j) : (j - this.current);
|
||||
p.targetZ = -depth * STACK_GAP;
|
||||
});
|
||||
this._restackState(this.current, instant);
|
||||
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;
|
||||
if (instant) {
|
||||
p.z = p.targetZ;
|
||||
p.group.position.z = p.z;
|
||||
}
|
||||
p.group.position.y = 0;
|
||||
p.group.position.x = 0;
|
||||
// 下层页略暗
|
||||
const dim = 1 - Math.min(depth * 0.06, 0.22);
|
||||
p.frontMat.color.setScalar(dim);
|
||||
p.backMat.color.setScalar(dim);
|
||||
p.material.color.setScalar(dim);
|
||||
p.mesh.castShadow = onTop;
|
||||
if (onTop) {
|
||||
this._ensureHiGeo(p);
|
||||
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();
|
||||
}
|
||||
|
||||
// 需要真实纹理的窗口:左右可见页 + 翻页时会露出的下一层页(按需,首屏最小)
|
||||
_pageWindow() {
|
||||
const len = this.pages.length;
|
||||
const win = new Set();
|
||||
// 左页 + 右页(当前)
|
||||
for (let d = -1; d <= 0; d++) {
|
||||
const i = this.current + d;
|
||||
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;
|
||||
}
|
||||
|
||||
// 懒加载:窗口内页生成真实纹理,窗口外释放回占位纹理
|
||||
_ensureTextures() {
|
||||
const win = this._pageWindow();
|
||||
const prev = this._textured;
|
||||
for (const i of win) {
|
||||
if (prev.has(i)) continue;
|
||||
const p = this.pages[i];
|
||||
const { front, back } = acquirePageTextures(ENTRIES[i]);
|
||||
p.material.setPageMaps(front, back);
|
||||
p.isPlaceholder = false;
|
||||
prev.add(i);
|
||||
}
|
||||
for (const i of [...prev]) {
|
||||
if (win.has(i)) continue;
|
||||
const p = this.pages[i];
|
||||
releasePageTextures(ENTRIES[i]);
|
||||
p.material.setPageMaps(this.placeholder.front, this.placeholder.back);
|
||||
p.isPlaceholder = true;
|
||||
prev.delete(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 预生成即将进入纹理窗口的页:推迟到下一帧,避免占用点击这一帧造成卡顿
|
||||
_prefetchForFlip(index) {
|
||||
if (index < 0 || index >= ENTRIES.length) return;
|
||||
requestAnimationFrame(() => prefetchPageTextures(ENTRIES[index]));
|
||||
}
|
||||
|
||||
_updateCurvature(index = null) {
|
||||
// 翻页时整页柔软弧弯:自由边最高、中段有“腹”、书脊侧贴环;顶边略强于底边;静止时完全平整
|
||||
const pages = index === null ? this.pages : [this.pages[index]];
|
||||
pages.forEach((p) => {
|
||||
const pos = p.geometry.attributes.position;
|
||||
if (!p.hiGeo) return; // 栈内页未创建高分段几何,跳过
|
||||
// 弯曲只作用于高分段几何(顶层/翻页页);栈内极简几何不弯曲
|
||||
const pos = p.hiGeo.attributes.position;
|
||||
const arr = pos.array;
|
||||
const theta = Math.abs(p.angle); // 0..π
|
||||
const c = Math.sin(theta); // 0 静止,1 翻页中途
|
||||
const A = 0.58 * c; // 抬升幅度(整页弧)
|
||||
for (let i = 0; i < arr.length; i += 3) {
|
||||
const x = p.base[i];
|
||||
const y = p.base[i + 1];
|
||||
const x = p.hiBase[i];
|
||||
const y = p.hiBase[i + 1];
|
||||
const s = Math.max(0, x - PAGE_GAP); // 距页内缘 0..W(防浮点误差产生负值→NaN)
|
||||
const u = Math.min(1, s / PAGE_W); // 0 内缘 .. 1 自由边
|
||||
const yNorm = (y + PAGE_H / 2) / PAGE_H; // 0 底边 .. 1 顶边
|
||||
@@ -467,7 +528,7 @@ export class Binder {
|
||||
// 主抬升 + 中段“腹”的次级谐波,曲线更饱满、更柔软
|
||||
const belly = 0.85 * Math.sin((Math.PI * u) / 2) + 0.15 * Math.sin(Math.PI * u);
|
||||
const z = A * twist * belly;
|
||||
arr[i + 2] = p.base[i + 2] + z;
|
||||
arr[i + 2] = p.hiBase[i + 2] + z;
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
});
|
||||
@@ -476,32 +537,96 @@ export class Binder {
|
||||
syncTo(index) {
|
||||
this.current = clamp(index, 0, this.pages.length - 1);
|
||||
this.flip = null;
|
||||
this.resetTween = null; // 外部跳转时终止回卷/快进动画,避免旧状态残留
|
||||
this._restack(true);
|
||||
if (this.onChange) this.onChange(this.current);
|
||||
}
|
||||
|
||||
canFlipForward() { return !this.flip; }
|
||||
canFlipBackward() { return !this.flip && this.current > 0; }
|
||||
canFlipForward() { return !this.flip && !this.resetTween; }
|
||||
canFlipBackward() { return !this.flip && !this.resetTween; }
|
||||
|
||||
// 页面 z 是否仍在向目标位置过渡(用于按需渲染:过渡期间需要持续重绘)
|
||||
isSettling() {
|
||||
return this.pages.some((p) => Math.abs(p.z - p.targetZ) > 0.0001);
|
||||
}
|
||||
|
||||
_finalizeFlip() {
|
||||
// 立即把正在翻的页收到目标位置,好让下一页马上开始翻
|
||||
if (!this.flip) return;
|
||||
const f = this.flip;
|
||||
const p = this.pages[f.index];
|
||||
p.angle = f.to;
|
||||
p.group.rotation.y = f.to;
|
||||
p.z = TOP_LIFT;
|
||||
p.group.position.z = TOP_LIFT;
|
||||
this.current = f.nextCurrent;
|
||||
this.flip = null;
|
||||
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() {
|
||||
if (this.flip) this._finalizeFlip(); // 前一页没翻完也能继续翻
|
||||
if (!this.canFlipForward()) return;
|
||||
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.6 };
|
||||
const p = this.pages[this.current];
|
||||
p.z = 0.006; p.targetZ = 0.006; p.group.position.z = 0.006;
|
||||
// END 页(所有页都已翻到左侧,current = pages.length):向前 → 回卷到第一页
|
||||
if (this.current >= this.pages.length) {
|
||||
this._startWrap(0);
|
||||
return;
|
||||
}
|
||||
// 最后一页翻完即进入 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() {
|
||||
if (this.flip) this._finalizeFlip();
|
||||
if (!this.canFlipBackward()) return;
|
||||
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.6 };
|
||||
const p = this.pages[this.current - 1];
|
||||
p.z = 0.006; p.targetZ = 0.006; p.group.position.z = 0.006;
|
||||
// 优先更新底部导航与信息卡
|
||||
if (this.onChange) this.onChange(this.current - 1);
|
||||
// 第一页向后:快进到 END 页(所有未翻的右页都翻到左侧,展示末页背面)
|
||||
if (this.current === 0) {
|
||||
this._startWrap(this.pages.length);
|
||||
return;
|
||||
}
|
||||
const nextCurrent = 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) {
|
||||
// 回卷重置动画:所有页角度从各自起点平滑回到 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) {
|
||||
const f = this.flip;
|
||||
f.t += dt / f.duration;
|
||||
@@ -512,33 +637,33 @@ export class Binder {
|
||||
const p = this.pages[f.index];
|
||||
p.angle = angle;
|
||||
p.group.rotation.y = angle;
|
||||
// 翻页时 z 随进度同步起伏:起止停在顶层高度,中途抬得更高,不归零不瞬跳
|
||||
p.z = TOP_LIFT + (FLIP_LIFT - TOP_LIFT) * Math.sin(Math.PI * k);
|
||||
p.group.position.z = p.z;
|
||||
this._updateCurvature(f.index);
|
||||
|
||||
if (k >= 1) {
|
||||
if (f.to === -Math.PI) {
|
||||
// 最后一页翻完后循环回第一页
|
||||
this.current = (this.current + 1) % this.pages.length;
|
||||
} else this.current -= 1;
|
||||
this.current = f.nextCurrent;
|
||||
this.flip = null;
|
||||
this._restack(false);
|
||||
this._updateCurvature();
|
||||
this._ensureTextures();
|
||||
}
|
||||
}
|
||||
|
||||
// 页面堆叠进退动画:z 平滑过渡到目标
|
||||
// 页面堆叠进退动画:z 平滑过渡到目标(翻页中的那一页由上面进度直接驱动)
|
||||
const lerpK = Math.min(1, dt * 14);
|
||||
this.pages.forEach((p) => {
|
||||
this.pages.forEach((p, i) => {
|
||||
if (this.flip && i === this.flip.index) return;
|
||||
p.z += (p.targetZ - p.z) * lerpK;
|
||||
p.group.position.z = p.z;
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.loGeo) this.loGeo.dispose();
|
||||
this.pages.forEach((p) => {
|
||||
p.geometry.dispose();
|
||||
p.frontMat.map.dispose();
|
||||
p.frontMat.dispose();
|
||||
p.backMat.map.dispose();
|
||||
p.backMat.dispose();
|
||||
if (p.hiGeo) p.hiGeo.dispose();
|
||||
p.material.dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+161
-31
@@ -1,6 +1,6 @@
|
||||
import * as THREE from 'three';
|
||||
import { ENTRIES } from '../data/entries.js';
|
||||
import { getPageTextures } from '../textures/pages.js';
|
||||
import { acquirePageTexturesLow, releasePageTexturesLow, getPlaceholderTexturesLow, makeTwoSidedMaterial } from '../textures/pages.js';
|
||||
import { makeTextSprite } from '../utils/labels.js';
|
||||
import { clamp, easeInOutCubic } from '../utils/easing.js';
|
||||
|
||||
@@ -22,6 +22,13 @@ export class Timeline {
|
||||
this.group = new THREE.Group();
|
||||
this.cards = [];
|
||||
this.selected = -1;
|
||||
this.placeholder = getPlaceholderTexturesLow();
|
||||
this._textured = new Set();
|
||||
this._visRange = null;
|
||||
this._ready = false;
|
||||
this._pending = [];
|
||||
this._dirty = false;
|
||||
this._paused = false;
|
||||
|
||||
this._buildLine();
|
||||
this._buildCards();
|
||||
@@ -38,15 +45,16 @@ export class Timeline {
|
||||
]);
|
||||
this.group.add(new THREE.Line(lineGeo, lineMat));
|
||||
|
||||
// 刻度:向上指向卡片、向下指向时间标签
|
||||
// 刻度:合并为单个 LineSegments,一次绘制,避免上百次 draw call
|
||||
const tickMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.5 });
|
||||
const tickPts = [];
|
||||
ENTRIES.forEach((e, i) => {
|
||||
const x = cardX(i, n);
|
||||
const g = new THREE.BufferGeometry().setFromPoints([
|
||||
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));
|
||||
tickPts.push(x, AXIS_Y - 0.14, 0, x, AXIS_Y + 0.12, 0);
|
||||
});
|
||||
const tickGeo = new THREE.BufferGeometry();
|
||||
tickGeo.setAttribute('position', new THREE.Float32BufferAttribute(tickPts, 3));
|
||||
this.group.add(new THREE.LineSegments(tickGeo, tickMat));
|
||||
}
|
||||
|
||||
_buildCards() {
|
||||
@@ -54,57 +62,179 @@ export class Timeline {
|
||||
|
||||
ENTRIES.forEach((entry, i) => {
|
||||
const x = cardX(i, n);
|
||||
const { front, back } = getPageTextures(entry);
|
||||
|
||||
// 每张卡片独立几何(带分段,便于翻面时弯曲出纸感)
|
||||
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);
|
||||
|
||||
// 翻转容器:正面登记卡 + 背面素描
|
||||
// 翻转容器:单 mesh + 双面材质(翻面时 gl_FrontFacing 自动切换正/背)
|
||||
const flipGroup = new THREE.Group();
|
||||
flipGroup.position.set(x, 0, 0);
|
||||
|
||||
const frontMat = new THREE.MeshStandardMaterial({ map: front, roughness: 0.85, metalness: 0.0, side: THREE.FrontSide });
|
||||
const frontMesh = new THREE.Mesh(cardGeo, frontMat);
|
||||
frontMesh.userData = { view: 'timeline', index: i };
|
||||
frontMesh.castShadow = true;
|
||||
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, { roughness: 0.86 });
|
||||
const cardMesh = new THREE.Mesh(cardGeo, mat);
|
||||
cardMesh.userData = { view: 'timeline', index: i };
|
||||
cardMesh.castShadow = true;
|
||||
|
||||
const backMat = new THREE.MeshStandardMaterial({ map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide });
|
||||
const backMesh = new THREE.Mesh(cardGeo, backMat);
|
||||
backMesh.position.z = -0.001;
|
||||
backMesh.userData = { view: 'timeline', index: i };
|
||||
flipGroup.add(cardMesh);
|
||||
|
||||
flipGroup.add(frontMesh, backMesh);
|
||||
|
||||
// 活页上方:主题(常显,随选中上浮,超宽居中换行)
|
||||
const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 });
|
||||
name.position.set(x, NAME_Y, 0);
|
||||
|
||||
// 轴下方:时间(常显,固定不动)
|
||||
const time = makeTextSprite(entry.time, { fontSize: 13, color: '#c9a227', scale: 0.11 });
|
||||
time.position.set(x, TIME_Y, 0);
|
||||
|
||||
this.group.add(flipGroup, name, time);
|
||||
this.cards.push({ card: flipGroup, front: frontMesh, back: backMesh, geo: cardGeo, base, name, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null });
|
||||
// 文字标签(名称/时间)延迟到首屏后由 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 });
|
||||
name.position.set(x, NAME_Y, 0);
|
||||
const time = makeTextSprite(entry.time, { fontSize: 13, color: '#c9a227', scale: 0.11 });
|
||||
time.position.set(x, TIME_Y, 0);
|
||||
this.group.add(name, time);
|
||||
c.name = name;
|
||||
c.time = time;
|
||||
if (this.selected === i) name.position.y = c.nameBaseY + CARD_FLOAT;
|
||||
}
|
||||
if (i < n) next();
|
||||
};
|
||||
next();
|
||||
}
|
||||
|
||||
setSelected(index) {
|
||||
this.selected = index;
|
||||
this.cards.forEach((c) => {
|
||||
const on = c.index === index;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// 首次进入时间轴视图时由 App 调用,此后由 App 每帧按可视区域驱动窗口
|
||||
refreshTextures() {
|
||||
this._ready = true;
|
||||
}
|
||||
|
||||
// App 每帧根据可视区域调用:minI/maxI 为可见卡片索引范围(可为小数)
|
||||
setVisibleRange(minI, maxI) {
|
||||
const len = this.cards.length;
|
||||
const PAD = 6; // 缓冲:可视范围外多生成几页,拖拽时提前就绪
|
||||
const lo = Math.max(0, Math.floor(minI) - PAD);
|
||||
const hi = Math.min(len - 1, Math.ceil(maxI) + PAD);
|
||||
if (this._visRange && this._visRange.min === lo && this._visRange.max === hi) return;
|
||||
this._visRange = { min: lo, max: hi };
|
||||
this._refreshTextures();
|
||||
}
|
||||
|
||||
// 世界坐标可视范围 → 索引范围(内部减去 group 偏移;卡片组内坐标 = cardX(i))
|
||||
setVisibleWorldRange(minX, maxX) {
|
||||
const n = this.cards.length;
|
||||
const offset = this.group.position.x;
|
||||
const lo = (minX - offset) / CARD_GAP + (n - 1) / 2;
|
||||
const hi = (maxX - offset) / CARD_GAP + (n - 1) / 2;
|
||||
this.setVisibleRange(lo, hi);
|
||||
}
|
||||
|
||||
_window() {
|
||||
const win = new Set();
|
||||
if (!this._visRange) return win;
|
||||
for (let i = this._visRange.min; i <= this._visRange.max; i++) win.add(i);
|
||||
return win;
|
||||
}
|
||||
|
||||
_refreshTextures() {
|
||||
if (!this._ready) return;
|
||||
const win = this._window();
|
||||
|
||||
// 离开窗口:释放真实纹理,换回占位
|
||||
for (const i of [...this._textured]) {
|
||||
if (win.has(i)) continue;
|
||||
const c = this.cards[i];
|
||||
releasePageTexturesLow(ENTRIES[i]);
|
||||
c.material.setPageMaps(this.placeholder.front, this.placeholder.back);
|
||||
c.isPlaceholder = true;
|
||||
this._textured.delete(i);
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
// 进入窗口:分帧异步生成,避免一次性生成 25 页阻塞主线程
|
||||
const next = [...win].filter((i) => !this._textured.has(i) && this.cards[i].isPlaceholder);
|
||||
if (next.length) this._dirty = true;
|
||||
this._pending = next;
|
||||
this.drainPending();
|
||||
}
|
||||
|
||||
// 拖拽期间暂停纹理生成,避免主线程同步绘制导致掉帧
|
||||
setPausedGeneration(paused) {
|
||||
this._paused = paused;
|
||||
if (!paused) this.drainPending(); // 恢复时立即补生成
|
||||
}
|
||||
|
||||
// 每帧由 App._animate 驱动,逐批生成窗口内纹理(避免阻塞)
|
||||
drainPending() {
|
||||
if (this._paused) return false;
|
||||
if (!this._pending || !this._pending.length) return false;
|
||||
const budget = 2; // 每帧最多生成 2 页
|
||||
let did = false;
|
||||
for (let k = 0; k < budget && this._pending.length; k++) {
|
||||
const i = this._pending.shift();
|
||||
const c = this.cards[i];
|
||||
if (!c || this._textured.has(i)) continue;
|
||||
const { front, back } = acquirePageTexturesLow(ENTRIES[i]);
|
||||
c.material.setPageMaps(front, back);
|
||||
c.isPlaceholder = false;
|
||||
this._textured.add(i);
|
||||
this._dirty = true;
|
||||
did = true;
|
||||
}
|
||||
return did;
|
||||
}
|
||||
|
||||
hasActiveFlip() {
|
||||
return this.cards.some((c) => c.flipAnim);
|
||||
}
|
||||
|
||||
// App 每帧消费“纹理窗口变化”标志,用于按需渲染
|
||||
consumeDirty() {
|
||||
const d = this._dirty;
|
||||
this._dirty = false;
|
||||
return d;
|
||||
}
|
||||
|
||||
// 返回某张卡片在时间轴组内的横向坐标(用于点击后居中)
|
||||
getCardX(index) {
|
||||
return cardX(index, ENTRIES.length);
|
||||
}
|
||||
|
||||
// 点击翻转:每次点击切换正/反面,带旋转动画
|
||||
// 射线检测用:只返回可视窗口内的卡片 mesh(减少求交对象)
|
||||
pickableMeshes() {
|
||||
if (!this._visRange) return this.cards.map((c) => c.mesh);
|
||||
const list = [];
|
||||
for (let i = this._visRange.min; i <= this._visRange.max; i++) {
|
||||
if (this.cards[i]) list.push(this.cards[i].mesh);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// 点击翻转:每次点击切换正/反面;选中另一张时,其它翻到背面的卡片先翻回正面
|
||||
flip(index) {
|
||||
this.cards.forEach((o) => {
|
||||
if (o.index !== index && o.flipped) {
|
||||
o.flipped = false;
|
||||
o.flipAnim = { from: o.card.rotation.y, to: 0, t: 0, dur: 0.6 };
|
||||
}
|
||||
});
|
||||
|
||||
const c = this.cards[index];
|
||||
if (!c) return;
|
||||
c.flipped = !c.flipped;
|
||||
|
||||
+52
-39
@@ -37,11 +37,22 @@ html, body {
|
||||
/* 隐藏左下角全部归属信息(Cesium ion logo + 影像文字) */
|
||||
#cesium-container .cesium-widget-credits { display: none !important; }
|
||||
|
||||
/* 标记便签 DOM 覆盖层(在 Cesium 画布之上) */
|
||||
#marker-layer {
|
||||
position: absolute; inset: 0; z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
.marker-note {
|
||||
position: absolute;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
/* 气氛遮罩 */
|
||||
.vignette {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background: radial-gradient(ellipse at 50% 42%, transparent 40%, rgba(8, 5, 3, 0.55) 100%);
|
||||
mix-blend-mode: multiply;
|
||||
/* 不启用 mix-blend-mode,避免每帧全屏合成开销(深色半透明覆盖即可) */
|
||||
}
|
||||
.grain {
|
||||
position: absolute; inset: 0; pointer-events: none; opacity: 0.06;
|
||||
@@ -52,31 +63,29 @@ html, body {
|
||||
background: #0c0906; opacity: 0; transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
/* 加载界面 */
|
||||
/* 加载界面:OS1 3D 加载环 */
|
||||
#loading {
|
||||
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;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
#loading.hidden { opacity: 0; pointer-events: none; }
|
||||
.loading-box { display: flex; flex-direction: column; align-items: center; gap: 18px; }
|
||||
.spinner {
|
||||
width: 54px; height: 54px;
|
||||
border: 3px solid rgba(201, 162, 39, 0.22);
|
||||
border-top-color: #c9a227;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
#loading-canvas {
|
||||
width: 500px; height: 500px;
|
||||
max-width: 88vw; max-height: 88vw;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#loading-canvas canvas { width: 100%; height: 100%; display: block; }
|
||||
.loading-text {
|
||||
font-family: var(--serif);
|
||||
font-size: 14px; letter-spacing: 0.3em;
|
||||
color: rgba(230, 216, 182, 0.7);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.2em;
|
||||
color: rgba(224, 196, 106, 0.8);
|
||||
}
|
||||
|
||||
/* 顶部标题 */
|
||||
.hud { position: absolute; z-index: 5; }
|
||||
.hud { position: absolute; z-index: 40; }
|
||||
.top-left { top: 22px; left: 26px; }
|
||||
.title {
|
||||
font-size: clamp(20px, 2.4vw, 34px);
|
||||
@@ -88,34 +97,35 @@ html, body {
|
||||
color: rgba(230,216,182,0.65); font-family: var(--serif);
|
||||
}
|
||||
|
||||
/* 视图切换(位于底部导航栏内) */
|
||||
.view-switch {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
/* 视图切换(单按钮,图标循环切换) */
|
||||
.view-btn {
|
||||
font-family: var(--serif); font-size: 14px; letter-spacing: 0.1em;
|
||||
padding: 8px 16px; color: var(--paper); cursor: pointer;
|
||||
background: rgba(20,14,10,0.55); border: 1px solid rgba(201,162,39,0.35);
|
||||
border-radius: 3px; backdrop-filter: blur(4px);
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.view-btn:hover { border-color: var(--gold); color: #fff; }
|
||||
.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;
|
||||
flex-shrink: 0;
|
||||
width: 32px; height: 32px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--gold-soft); cursor: pointer;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(201,162,39,0.4);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.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 {
|
||||
left: 50%; bottom: 22px; transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 10px 18px; border-radius: 4px;
|
||||
left: 50%; bottom: 26px; transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 5px 14px; border-radius: 4px;
|
||||
background: rgba(20,14,10,0.6); border: 1px solid rgba(201,162,39,0.25);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.nav-btn {
|
||||
font-family: var(--serif); font-size: 16px; line-height: 1;
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(201,162,39,0.4);
|
||||
transition: all 0.2s ease;
|
||||
@@ -123,13 +133,13 @@ html, body {
|
||||
.nav-btn:hover { background: rgba(201,162,39,0.25); color: #fff; transform: scale(1.06); }
|
||||
.nav-btn:active { transform: scale(0.96); }
|
||||
.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-num { font-size: 24px; color: var(--gold-soft); font-family: var(--serif); }
|
||||
.page-indicator { display: flex; align-items: center; gap: 6px; min-width: 0; justify-content: center; }
|
||||
#page-num { font-size: 22px; color: var(--gold-soft); font-family: var(--serif); }
|
||||
.sep { color: rgba(230,216,182,0.5); }
|
||||
#page-total { color: rgba(230,216,182,0.6); font-family: var(--serif); }
|
||||
.page-name {
|
||||
font-size: 14px; color: var(--paper); letter-spacing: 0.05em; margin-left: 10px;
|
||||
flex: 0 0 160px; /* 固定宽度,容纳多数词条名,避免翻页时布局抖动 */
|
||||
flex: 0 0 160px; /* 固定宽度,导航条不随词条名长短变化 */
|
||||
text-align: center;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
@@ -137,6 +147,7 @@ html, body {
|
||||
/* 登记卡详情 */
|
||||
.detail-card {
|
||||
right: 24px; bottom: 22px; width: min(320px, 82vw);
|
||||
z-index: 40; /* 始终置顶,盖在标记便签层之上 */
|
||||
padding: 18px 20px; border-radius: 4px;
|
||||
background: linear-gradient(180deg, rgba(232,216,182,0.96), rgba(214,196,156,0.96));
|
||||
color: var(--ink); box-shadow: 0 18px 50px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(90,61,43,0.4);
|
||||
@@ -144,7 +155,7 @@ html, body {
|
||||
}
|
||||
.detail-card.hidden { opacity: 0; pointer-events: none; transform: translateY(14px); }
|
||||
.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 {
|
||||
font-size: 12px; padding: 2px 10px; border-radius: 2px;
|
||||
background: var(--leather); color: var(--gold-soft); letter-spacing: 0.2em;
|
||||
@@ -165,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 canvas { width: 100%; height: 170px; object-fit: contain; border-radius: 2px; opacity: 0.94; }
|
||||
|
||||
/* 提示 */
|
||||
/* 提示(导航条下方) */
|
||||
.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);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
|
||||
+232
-76
@@ -7,9 +7,6 @@ export const PAGE_W = 1.6; // 世界单位:页宽
|
||||
export const PAGE_H = 2.24; // 世界单位:页高
|
||||
export const TEX_W = 768;
|
||||
export const TEX_H = 1075;
|
||||
// 输出纹理与绘制分辨率一致,保证文字清晰
|
||||
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_X = 0.065; // 孔中心相对页内缘(环前交点 0.085 − 页缘空隙 0.02)
|
||||
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 KAI = '"Kaiti SC","STKaiti","KaiTi","Noto Serif SC",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'; // 与时间轴标题字体一致
|
||||
|
||||
// 版面边距(左边缘避开活页孔,右/上/下为常规边距)
|
||||
@@ -69,28 +67,6 @@ export function makePaper(w, h, { tone = 0, ruled = false, margin = false, seed
|
||||
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);
|
||||
edge.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
@@ -200,63 +176,56 @@ export function renderFront(entry) {
|
||||
drawBorder(ctx, w, h);
|
||||
drawHoles(ctx, w, h);
|
||||
|
||||
// 页眉:编号(去掉右上角印章标记)
|
||||
text(ctx, '编 号 · ' + entry.id, CONTENT_L, 92, { font: NUM, size: 26, color: '#5a432c' });
|
||||
// 页眉:编目号(档案式编号,Consolas 等宽字体)
|
||||
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.beginPath(); ctx.moveTo(CONTENT_L, 160); ctx.lineTo(CONTENT_R, 160); ctx.stroke();
|
||||
|
||||
// 名称(大字,居中,标题字体与时间轴一致)
|
||||
// 名称(大字,居中)
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `700 ${fitFont(ctx, entry.name, CONTENT_W - 40, 36, 86, TITLE_FONT)}px ${TITLE_FONT}`;
|
||||
ctx.fillStyle = '#33271a';
|
||||
ctx.fillText(entry.name, w / 2, 248);
|
||||
// 名称下划线装饰
|
||||
ctx.strokeStyle = info.color;
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.beginPath(); ctx.moveTo(w / 2 - 220, 314); ctx.lineTo(w / 2 + 220, 314); ctx.stroke();
|
||||
ctx.fillStyle = info.color;
|
||||
ctx.beginPath(); ctx.arc(w / 2, 314, 4, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillText(entry.name, w / 2, 210);
|
||||
|
||||
// —— 时间 / 地区(左对齐,字体同标题)——
|
||||
const gridTop = 276, rowH = 64;
|
||||
const gLeft = CONTENT_L, gRight = CONTENT_R;
|
||||
const gBottom = gridTop + rowH * 2;
|
||||
|
||||
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 = [
|
||||
['时间', entry.time],
|
||||
['地区', entry.region],
|
||||
['时 间', entry.time],
|
||||
['地 区', entry.region],
|
||||
];
|
||||
fields.forEach(([label, value], i) => {
|
||||
const y = fy + i * rowH;
|
||||
text(ctx, label, labelX, y, { font: SERIF, size: 26, color: '#7a5c38', align: 'right' });
|
||||
text(ctx, ':', colonX, y, { font: SERIF, size: 26, color: '#7a5c38' });
|
||||
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 cy = gridTop + i * rowH + rowH / 2;
|
||||
text(ctx, label, gLeft, cy, { font: TITLE_FONT, size: 26, color: '#7a5c38', baseline: 'middle' });
|
||||
text(ctx, value, gLeft + 96, cy, { font: TITLE_FONT, size: 26, color: '#33271a', baseline: 'middle' });
|
||||
});
|
||||
|
||||
// 描述区
|
||||
const dy = 600;
|
||||
text(ctx, '描 述', CONTENT_L, dy, { font: SERIF, size: 26, color: '#7a5c38' });
|
||||
ctx.strokeStyle = 'rgba(120,95,60,0.3)';
|
||||
ctx.lineWidth = 0.9;
|
||||
ctx.beginPath(); ctx.moveTo(CONTENT_L, dy + 18); ctx.lineTo(CONTENT_R, dy + 18); ctx.stroke();
|
||||
// —— 描述区 ——
|
||||
const dy = 482;
|
||||
text(ctx, '描 述', CONTENT_L, dy, { font: TITLE_FONT, size: 26, color: '#7a5c38' });
|
||||
ctx.strokeStyle = 'rgba(120,95,60,0.45)';
|
||||
ctx.lineWidth = 1;
|
||||
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.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
const maxW = CONTENT_W;
|
||||
let line = '', yy = dy + 40;
|
||||
let line = '', yy = dy + 44;
|
||||
for (const ch of entry.description) {
|
||||
const test = line + ch;
|
||||
if (ctx.measureText(test).width > maxW && line) {
|
||||
@@ -323,19 +292,206 @@ function downscale(src) {
|
||||
return c;
|
||||
}
|
||||
|
||||
// 生成页面的两个纹理
|
||||
const texCache = new Map();
|
||||
export function getPageTextures(entry) {
|
||||
if (texCache.has(entry.id)) return texCache.get(entry.id);
|
||||
const front = new THREE.CanvasTexture(downscale(renderFront(entry)));
|
||||
front.colorSpace = THREE.SRGBColorSpace;
|
||||
front.anisotropy = 8;
|
||||
// —— 页面纹理池:懒生成 + 引用计数 + LRU 释放 ——
|
||||
// 启动时只为“当前可见窗口”生成真实纹理,其余页面用共享占位纹理;
|
||||
// 浏览中按需生成,离开窗口的纹理自动释放,控制显存占用。
|
||||
|
||||
const back = new THREE.CanvasTexture(downscale(renderBack(entry)));
|
||||
back.colorSpace = THREE.SRGBColorSpace;
|
||||
back.anisotropy = 8;
|
||||
const MAX_CACHED_PAGES = 40; // 最多缓存 40 页(80 张)真实纹理
|
||||
const texCache = new Map(); // id -> { entry, front, back, refs, lastUsed }
|
||||
let placeholder = null;
|
||||
|
||||
const pair = { front, back };
|
||||
texCache.set(entry.id, pair);
|
||||
return pair;
|
||||
function makeTexture(canvas) {
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.anisotropy = 4; // 倾斜视角已够用,降低采样带宽
|
||||
tex.generateMipmaps = false; // 页面距离变化小,关 mipmap 省 ~33% 显存
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
return tex;
|
||||
}
|
||||
|
||||
// 空白活页纸占位纹理(全局共享一份)
|
||||
function renderPlaceholder() {
|
||||
const w = TEX_W, h = TEX_H;
|
||||
const { canvas, ctx } = makePaper(w, h, { seed: 42 });
|
||||
drawBorder(ctx, w, h);
|
||||
drawHoles(ctx, w, h);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function getPlaceholderTextures() {
|
||||
if (!placeholder) {
|
||||
const front = makeTexture(renderPlaceholder());
|
||||
const back = makeTexture(renderPlaceholder());
|
||||
placeholder = { front, back };
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
function generatePair(entry) {
|
||||
const front = makeTexture(renderFront(entry));
|
||||
const back = makeTexture(renderBack(entry));
|
||||
return { front, back };
|
||||
}
|
||||
|
||||
function evictCache(cache, maxPages) {
|
||||
if (cache.size <= maxPages) return;
|
||||
const candidates = [...cache.values()]
|
||||
.filter((r) => r.refs === 0)
|
||||
.sort((a, b) => a.lastUsed - b.lastUsed);
|
||||
while (cache.size > maxPages && candidates.length) {
|
||||
const r = candidates.shift();
|
||||
r.front.dispose();
|
||||
r.back.dispose();
|
||||
cache.delete(r.entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
function evictIfNeeded() {
|
||||
evictCache(texCache, MAX_CACHED_PAGES);
|
||||
}
|
||||
|
||||
export function acquirePageTextures(entry) {
|
||||
let rec = texCache.get(entry.id);
|
||||
if (!rec) {
|
||||
rec = { entry, front: null, back: null, refs: 0, lastUsed: 0 };
|
||||
texCache.set(entry.id, rec);
|
||||
}
|
||||
rec.refs++;
|
||||
rec.lastUsed = performance.now();
|
||||
if (!rec.front) {
|
||||
const pair = generatePair(entry);
|
||||
rec.front = pair.front;
|
||||
rec.back = pair.back;
|
||||
evictIfNeeded();
|
||||
}
|
||||
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) {
|
||||
const rec = texCache.get(entry.id);
|
||||
if (!rec) return;
|
||||
rec.refs = Math.max(0, rec.refs - 1);
|
||||
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 在正/反面显示不同纹理,
|
||||
// 替代原先 front/back 两个 mesh,把每页 draw call 减半。
|
||||
export function makeTwoSidedMaterial(front, back, {
|
||||
roughness = 0.88,
|
||||
metalness = 0.0,
|
||||
transparent = false,
|
||||
alphaTest = 0,
|
||||
} = {}) {
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
map: front, // 仅用于激活 USE_MAP / UV;实际采样由注入代码接管
|
||||
roughness,
|
||||
metalness,
|
||||
side: THREE.DoubleSide,
|
||||
transparent,
|
||||
alphaTest,
|
||||
});
|
||||
|
||||
mat.userData.pageMaps = { front, back };
|
||||
let compiled = null;
|
||||
|
||||
mat.onBeforeCompile = (shader) => {
|
||||
compiled = shader;
|
||||
shader.uniforms.frontMap = { value: mat.userData.pageMaps.front };
|
||||
shader.uniforms.backMap = { value: mat.userData.pageMaps.back };
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace(
|
||||
'#include <common>',
|
||||
'#include <common>\nuniform sampler2D frontMap;\nuniform sampler2D backMap;',
|
||||
)
|
||||
.replace(
|
||||
'#include <map_fragment>',
|
||||
`#ifdef USE_MAP
|
||||
vec4 _sideColor = gl_FrontFacing ? texture2D( frontMap, vMapUv ) : texture2D( backMap, vMapUv );
|
||||
diffuseColor *= _sideColor;
|
||||
#endif`,
|
||||
);
|
||||
};
|
||||
|
||||
mat.setPageMaps = (f, b) => {
|
||||
mat.userData.pageMaps = { front: f, back: b };
|
||||
mat.map = f;
|
||||
if (compiled) {
|
||||
compiled.uniforms.frontMap.value = f;
|
||||
compiled.uniforms.backMap.value = b;
|
||||
}
|
||||
};
|
||||
|
||||
return mat;
|
||||
}
|
||||
|
||||
+513
-47
@@ -581,68 +581,534 @@ export function renderSketch(entry, size = 760) {
|
||||
},
|
||||
|
||||
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)');
|
||||
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.0, -0.2], [0.0, 0.5]], { lw: 2 });
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const y = -0.05 + i * 0.14;
|
||||
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 });
|
||||
// 封面(略大一圈,深色底)
|
||||
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.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 });
|
||||
// 摊开的书页
|
||||
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)');
|
||||
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.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() {
|
||||
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 });
|
||||
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.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 });
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const x = -0.45 + i * 0.15;
|
||||
line([[x, 0.28], [x, 0.4]], { lw: 1.2, passes: 1, color: INK_SOFT });
|
||||
// 水面波纹
|
||||
line([[-0.72, 0.45], [0.72, 0.45]], { lw: 1.6, color: INK_SOFT, passes: 1 });
|
||||
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 });
|
||||
// 桥体拱形
|
||||
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)');
|
||||
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 });
|
||||
// 拱券石缝
|
||||
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() {
|
||||
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.4, -0.65], [-0.1, -0.6], [0.05, -0.45], [-0.25, -0.5], [-0.4, -0.65]], { lw: 2.2 });
|
||||
line([[-0.32, -0.55], [0.0, -0.48]], { lw: 1.4, color: INK_SOFT, passes: 1 });
|
||||
line([[-0.25, -0.5], [-0.15, -0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
|
||||
line([[-0.6, 0.62], [0.6, 0.62]], { lw: 1.6, color: INK_FAINT });
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const a = -0.5 + i * 0.3;
|
||||
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.62, 0.6], [0.62, 0.6]], { lw: 1.6, color: INK_FAINT });
|
||||
line([[-0.42, 0.6], [-0.42, -0.74]], { lw: 2.6 });
|
||||
line([[-0.47, 0.6], [-0.47, -0.74]], { lw: 1.2, color: INK_SOFT, passes: 1 });
|
||||
circle(-0.42, -0.77, 0.04, { lw: 1.8, passes: 1 });
|
||||
// 飘动的旗面(S 形两褶)
|
||||
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)');
|
||||
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.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() {
|
||||
line([[0.0, 0.6], [0.0, -0.15]], { lw: 2.4 });
|
||||
line([[-0.55, -0.05], [0.55, -0.05]], { lw: 2.2 });
|
||||
circle(0.0, -0.1, 0.06, { lw: 1.8, passes: 1 });
|
||||
line([[-0.55, -0.05], [-0.55, 0.2]], { lw: 1.6, passes: 1 });
|
||||
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.38, 0.2], [0.72, 0.2]], { lw: 2 });
|
||||
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 });
|
||||
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.2, 0.68], [0.2, 0.68]], { lw: 2 });
|
||||
// 三级底座
|
||||
line([[-0.34, 0.6], [0.34, 0.6]], { lw: 2.6 });
|
||||
line([[-0.26, 0.66], [0.26, 0.66]], { lw: 2.2 });
|
||||
line([[-0.18, 0.72], [0.18, 0.72]], { lw: 1.8, color: INK_SOFT });
|
||||
// 立柱与柱头
|
||||
line([[-0.05, 0.6], [-0.05, -0.06]], { lw: 2.4 });
|
||||
line([[0.05, 0.6], [0.05, -0.06]], { lw: 1.2, color: INK_SOFT, passes: 1 });
|
||||
circle(0.0, -0.09, 0.06, { lw: 2, passes: 1 });
|
||||
// 横梁与支点
|
||||
line([[-0.6, -0.02], [0.6, -0.02]], { lw: 2.4 });
|
||||
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() {
|
||||
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.1, -0.6], [0.0, -0.05], [0.05, -0.05]], 'rgba(150,150,160,0.10)');
|
||||
line([[-0.25, 0.05], [0.22, -0.02]], { lw: 3 });
|
||||
line([[-0.02, 0.15], [0.05, 0.42]], { lw: 2.4 });
|
||||
circle(0.02, 0.45, 0.05, { lw: 1.6, passes: 1 });
|
||||
line([[-0.5, 0.6], [0.5, 0.6]], { lw: 1.4, color: INK_FAINT, passes: 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.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)');
|
||||
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.0, -0.2], [0.0, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
|
||||
line([[-0.35, 0.15], [0.35, 0.15]], { lw: 1.4, color: INK_SOFT, 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 });
|
||||
// 剑身(竖直插于盾前)
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -7,9 +7,12 @@ export class Overlay {
|
||||
this.onPrev = onPrev;
|
||||
this.onNext = onNext;
|
||||
this.onFlip = onFlip;
|
||||
this.view = 'binder';
|
||||
this.globeReady = false;
|
||||
|
||||
this.els = {
|
||||
iris: document.getElementById('iris'),
|
||||
btnView: document.getElementById('btn-view'),
|
||||
pageNum: document.getElementById('page-num'),
|
||||
pageTotal: document.getElementById('page-total'),
|
||||
pageName: document.getElementById('page-name'),
|
||||
@@ -27,22 +30,31 @@ export class Overlay {
|
||||
}
|
||||
|
||||
_wire() {
|
||||
document.querySelectorAll('.view-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => this.onView(btn.dataset.view));
|
||||
});
|
||||
this.els.btnView.addEventListener('click', () => this._cycleView());
|
||||
document.getElementById('btn-prev').addEventListener('click', () => this.onPrev());
|
||||
document.getElementById('btn-next').addEventListener('click', () => this.onNext());
|
||||
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) {
|
||||
this.els.pageTotal.textContent = String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
setView(view) {
|
||||
document.querySelectorAll('.view-btn').forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset.view === view);
|
||||
});
|
||||
this.view = view;
|
||||
this.els.btnView.dataset.view = view;
|
||||
const HINTS = {
|
||||
binder: '拖动旋转 · 滚轮缩放 · 点击书页选中 · ← → 翻页(翻面查看素描)',
|
||||
timeline: '拖拽左右滑动 · 滚轮缩放 · 点击卡片查看详情',
|
||||
@@ -52,15 +64,33 @@ export class Overlay {
|
||||
}
|
||||
|
||||
setEntry(entry, index, total) {
|
||||
this.els.detailCard.classList.remove('hidden');
|
||||
this.els.pageNum.textContent = String(index + 1).padStart(2, '0');
|
||||
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.detailTime.textContent = entry.time;
|
||||
this.els.detailRegion.textContent = entry.region;
|
||||
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 ctx = c.getContext('2d');
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// 超采样倍数:文字以更高分辨率栅格化,拉近/放大后依然清晰
|
||||
const SS = 4;
|
||||
const SS = 2;
|
||||
|
||||
// 折行:尽量均分两行(标题较短),均分不满足时逐字折行
|
||||
function wrapText(ctx, text, maxW) {
|
||||
|
||||
+38
-2
@@ -1,6 +1,42 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import cesium from 'vite-plugin-cesium';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import serveStatic from 'serve-static';
|
||||
|
||||
// Cesium 懒加载:复制静态资源、注入 CSS,但不注入 6MB 的 Cesium.js 脚本——
|
||||
// 脚本由 CesiumGlobe 首次进入“地理”视图时动态加载,避免阻塞首屏。
|
||||
const CESIUM_URL = '/cesium/';
|
||||
|
||||
function cesiumLazy() {
|
||||
let isBuild = false;
|
||||
return {
|
||||
name: 'cesium-lazy',
|
||||
config(_c, { command }) {
|
||||
isBuild = command === 'build';
|
||||
},
|
||||
configureServer({ middlewares }) {
|
||||
// dev:直接静态服务 Cesium 资源(未压缩版便于调试)
|
||||
const dir = path.join(process.cwd(), 'node_modules/cesium/Build/CesiumUnminified');
|
||||
middlewares.use(CESIUM_URL, serveStatic(dir));
|
||||
},
|
||||
async closeBundle() {
|
||||
if (!isBuild) return;
|
||||
const src = path.join(process.cwd(), 'node_modules/cesium/Build/Cesium');
|
||||
const out = path.join(process.cwd(), 'dist', 'cesium');
|
||||
for (const d of ['Assets', 'ThirdParty', 'Workers', 'Widgets']) {
|
||||
await fs.cp(path.join(src, d), path.join(out, d), { recursive: true });
|
||||
}
|
||||
await fs.copyFile(path.join(src, 'Cesium.js'), path.join(out, 'Cesium.js'));
|
||||
},
|
||||
transformIndexHtml() {
|
||||
return [{
|
||||
tag: 'link',
|
||||
attrs: { rel: 'stylesheet', href: `${CESIUM_URL}Widgets/widgets.css` },
|
||||
}];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [cesium()],
|
||||
plugins: [cesiumLazy()],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user