Compare commits

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