diff --git a/.gitignore b/.gitignore
index dd6e803..cbcf2f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ node_modules/
dist/
*.log
.DS_Store
+__pycache__/
diff --git a/index.html b/index.html
index f51f8d6..c8dea4f 100644
--- a/index.html
+++ b/index.html
@@ -30,17 +30,17 @@
@@ -63,12 +63,10 @@
拖动旋转 · 滚轮缩放 · 时间轴视图拖拽左右滑动 · 点击选中 · ← → 翻页
-
+
diff --git a/scripts/gen_entries.py b/scripts/gen_entries.py
index d1a7857..47e09f0 100644
--- a/scripts/gen_entries.py
+++ b/scripts/gen_entries.py
@@ -5,6 +5,8 @@
import math
+from region_codes import make_code
+
DATA = [
# ===================== 史前 =====================
("~ BC 3,200,000", -3200000, "东非 · 埃塞俄比亚", "露西 · 南方古猿", "人", "fossil", 11.2, 40.5, "1974年在埃塞俄比亚哈达尔发现的一具约40%完整的南方古猿阿法种骨架,是直立行走的最早证据之一,被亲切地称为“人类祖母”。"),
@@ -360,6 +362,44 @@ def dedupe(entries, km=DEDUP_KM):
return out
DEDUPED = dedupe(DATA)
+
+# —— 素描多样分配:同类主题轮换到不同画风,避免同一张素描重复出现 ——
+SKETCH_POOLS = {
+ 'sword': ['sword', 'helmet', 'cannon', 'fortress', 'bow', 'shield'],
+ 'crown': ['crown', 'throne', 'scepter', 'palace', 'laurel'],
+ 'machine': ['machine', 'gear', 'anvil', 'locomotive', 'factory'],
+ 'atom': ['atom', 'microscope', 'telescope', 'flask', 'lightbulb'],
+ 'scale': ['scale', 'gavel', 'columns', 'document'],
+ 'ship': ['ship', 'anchor', 'compass', 'globe'],
+ 'fossil': ['fossil', 'dinosaur', 'mountain'],
+ 'tablet': ['tablet', 'coin', 'document', 'hourglass'],
+ 'scroll': ['scroll', 'book', 'document', 'lyre'],
+ 'temple': ['temple', 'columns', 'palace', 'pyramid'],
+ 'colosseum': ['colosseum', 'fortress', 'helmet'],
+ 'figure': ['figure', 'mask', 'lyre'],
+ 'flag': ['flag', 'laurel', 'mountain', 'dove'],
+ 'bell': ['bell', 'gavel', 'document'],
+ 'rocket': ['rocket', 'telescope', 'lightbulb', 'compass'],
+ 'pyramid': ['pyramid', 'temple', 'palace', 'mountain'],
+ 'apple': ['apple', 'flask', 'tree', 'flower'],
+ 'book': ['book', 'scroll', 'document', 'key'],
+ 'tree': ['tree', 'flower', 'dinosaur'],
+ 'dove': ['dove', 'laurel', 'flag'],
+ 'globe': ['globe', 'compass', 'anchor', 'ship'],
+ 'horseman': ['horseman', 'bow', 'fortress', 'helmet'],
+}
+_pool_idx = {}
+def _reassign(e):
+ pool = SKETCH_POOLS.get(e[5])
+ if not pool:
+ return e
+ k = e[5]
+ idx = _pool_idx.get(k, 0)
+ s = pool[idx % len(pool)]
+ _pool_idx[k] = idx + 1
+ return (e[0], e[1], e[2], e[3], e[4], s, e[6], e[7], e[8])
+
+DEDUPED = [_reassign(e) for e in DEDUPED]
print(f"坐标去重:{len(DATA)} -> {len(DEDUPED)} 条(阈值 {DEDUP_KM} km)")
def esc(s):
@@ -397,6 +437,7 @@ lines = ["// 世界历史里程碑条目数据",
for i, (time, tn, region, name, cat, sketch, lat, lng, desc) in enumerate(DEDUPED, 1):
lines.append(" {")
lines.append(f" id: 'No.{i:03d}',")
+ lines.append(f" code: '{esc(make_code(tn, region, lat, lng, i, name, cat))}',")
lines.append(f" time: '{esc(normalize_time(time, tn))}',")
lines.append(f" timeNumeric: {tn},")
lines.append(f" region: '{esc(region)}',")
diff --git a/scripts/gen_entries_stress.py b/scripts/gen_entries_stress.py
index 0d754f9..efda919 100644
--- a/scripts/gen_entries_stress.py
+++ b/scripts/gen_entries_stress.py
@@ -7,6 +7,8 @@
import random
import math
+from region_codes import make_code
+
N = 1000
random.seed(20240101)
@@ -90,6 +92,7 @@ lines = ["// 压力测试用合成词条数据(1000 条)",
for i, (time, tn, region, name, cat, sketch, lat, lng, desc) in enumerate(entries, 1):
lines.append(" {")
lines.append(f" id: 'No.{i:03d}',")
+ lines.append(f" code: '{esc(make_code(tn, region, lat, lng, i, name, cat))}',")
lines.append(f" time: '{esc(time)}',")
lines.append(f" timeNumeric: {tn},")
lines.append(f" region: '{esc(region)}',")
diff --git a/scripts/region_codes.py b/scripts/region_codes.py
new file mode 100644
index 0000000..ab5ab30
--- /dev/null
+++ b/scripts/region_codes.py
@@ -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}"
diff --git a/scripts/verify.mjs b/scripts/verify.mjs
index bbe85bd..b1a6459 100644
--- a/scripts/verify.mjs
+++ b/scripts/verify.mjs
@@ -3,69 +3,47 @@ import puppeteer from 'puppeteer-core';
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const URL = process.argv[2] || 'http://localhost:5199/';
+// 轻量冒烟测试:不做完整渲染流程 / 不触发 Cesium,
+// 只检查「加载无 JS 报错 + loading 环出现 + 6 秒后淡出 + App 就绪」。
+// 用真实 GPU(不强制 swiftshader 软件渲染)。
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: 'new',
- args: ['--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--window-size=1440,900'],
+ args: ['--no-sandbox', '--window-size=1440,900'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
-page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
+page.on('console', (m) => {
+ // preview 模式无 HMR,WebSocket 连接失败是正常现象,过滤掉
+ if (m.type() === 'error' && !/WebSocket|ERR_CONNECTION/i.test(m.text())) {
+ errors.push('console: ' + m.text());
+ }
+});
-await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
-await new Promise((r) => setTimeout(r, 6000));
+await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
-async function snapshot(label) {
- const info = await page.evaluate(() => {
- const app = window.__app;
- const gl = app.renderer.getContext();
- const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
- const buf = new Uint8Array(w * h * 4);
- gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
- let sum = 0, n = 0, bright = 0;
- for (let i = 0; i < buf.length; i += 4) {
- const lum = 0.299 * buf[i] + 0.587 * buf[i + 1] + 0.114 * buf[i + 2];
- sum += lum; n++;
- if (lum > 40) bright++;
- }
- return {
- view: app.view,
- selected: app.selected,
- binderCurrent: app.binder.current,
- pages: app.binder.pages.length,
- timelineCards: app.timeline.cards.length,
- globeMarkers: app.globe.markers.length,
- detailName: document.getElementById('detail-name')?.textContent,
- detailNo: document.getElementById('detail-no')?.textContent,
- avgLum: +(sum / n).toFixed(2),
- brightPct: +((bright / n) * 100).toFixed(2),
- };
- });
- console.log(label, JSON.stringify(info, null, 0));
-}
+// 阶段一:加载环应已出现且正在展示
+await new Promise((r) => setTimeout(r, 2000));
+const loading = await page.evaluate(() => ({
+ hasCanvas: !!document.querySelector('#loading-canvas canvas'),
+ loadingShown: !document.getElementById('loading').classList.contains('hidden'),
+ appReady: !!window.__app,
+}));
-await snapshot('binder ');
-
-await page.click('.view-btn[data-view="timeline"]');
-await new Promise((r) => setTimeout(r, 2400));
-await snapshot('timeline');
-
-await page.click('.view-btn[data-view="globe"]');
-await new Promise((r) => setTimeout(r, 2400));
-await snapshot('globe ');
-
-await page.click('.view-btn[data-view="binder"]');
-await new Promise((r) => setTimeout(r, 2400));
-await page.click('#btn-next');
-await new Promise((r) => setTimeout(r, 1500));
-await snapshot('flipped ');
-
-await page.click('#btn-prev');
-await new Promise((r) => setTimeout(r, 1500));
-await snapshot('back ');
+// 阶段二:约 9 秒后,loading 应已淡出、App 应已就绪
+await new Promise((r) => setTimeout(r, 7000));
+const done = await page.evaluate(() => ({
+ loadingHidden: document.getElementById('loading').classList.contains('hidden'),
+ appReady: !!window.__app,
+ view: window.__app?.view,
+ pages: window.__app?.binder?.pages?.length,
+ timelineCards: window.__app?.timeline?.cards?.length,
+}));
+console.log('阶段一(加载中):', JSON.stringify(loading));
+console.log('阶段二(加载后):', JSON.stringify(done));
console.log('ERRORS:', errors.length ? errors.join('\n') : '(none)');
await browser.close();
diff --git a/src/core/App.js b/src/core/App.js
index 0b5be5c..06110a8 100644
--- a/src/core/App.js
+++ b/src/core/App.js
@@ -31,8 +31,8 @@ export class App {
this.overlay = new Overlay({
onView: (v) => this.switchView(v),
- onPrev: () => this._step(-1),
- onNext: () => this._step(1),
+ onPrev: () => this._stepByView(-1),
+ onNext: () => this._stepByView(1),
onFlip: () => this._step(1),
});
this.overlay.setTotal(ENTRIES.length);
@@ -59,17 +59,16 @@ export class App {
requestAnimationFrame(() => requestAnimationFrame(() => this.timeline.initLabels()));
}
- // 首屏加载完成后由 main.js 调用:后台预载地理视图,就绪后激活“地理”按钮
+ // 首屏加载完成后由 main.js 调用:后台预载地理视图,就绪后允许循环切换到地理
preloadGlobe() {
- const btn = document.querySelector('.view-btn[data-view="globe"]');
- this.globe.viewerReady.then(() => { if (btn) btn.disabled = false; });
+ this.globe.viewerReady.then(() => this.overlay.setGlobeReady(true));
this.globe.ensureCreated();
}
_initRenderer() {
const canvas = document.getElementById('scene');
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
- this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 恢复高分辨率渲染,避免糊
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
@@ -121,16 +120,19 @@ export class App {
const rim = new THREE.DirectionalLight(0x8fa0d0, 0.7);
rim.position.set(-2.5, 1.2, -3.5);
this.scene.add(rim);
+ this.rimLight = rim;
// 低角度掠射光:横扫皮面,凸显皮革纹理凹凸
const graze = new THREE.DirectionalLight(0xffd9a8, 1.1);
graze.position.set(-4.5, 0.7, 2.6);
this.scene.add(graze);
+ this.grazeLight = graze;
// 背面补光:让外侧牛皮从背面看也可见
const backLight = new THREE.DirectionalLight(0xffd9a8, 0.55);
backLight.position.set(0.5, 1.2, -3.5);
this.scene.add(backLight);
+ this.backLight = backLight;
}
_initScene() {
@@ -212,10 +214,11 @@ export class App {
// 时间轴:按下开始拖拽左右滑动;未移动的抬起才视为点击选中
this.timelineSlide = null; // 手动拖拽时取消居中动画
this.timeline.setPausedGeneration(true); // 拖拽期间暂停纹理生成,保持流畅
- this._drag = { active: true, startX: e.clientX, startY: e.clientY, moved: false, offset: this.timelineOffset };
+ this._drag = { active: true, view: 'timeline', startX: e.clientX, startY: e.clientY, moved: false, offset: this.timelineOffset };
canvas.setPointerCapture(e.pointerId);
- } else {
- this._pick();
+ } else if (this.view === 'binder') {
+ // 活页本:按下仅记录,松开且未拖拽(<4px)时才翻页
+ this._drag = { active: true, view: 'binder', startX: e.clientX, startY: e.clientY, moved: false };
}
});
@@ -227,14 +230,14 @@ export class App {
if (!this._drag.moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
this._drag.moved = true;
}
- if (this._drag.moved) {
+ if (this._drag.view === 'timeline' && this._drag.moved) {
const limit = TIMELINE_SPREAD + 0.6;
this.timelineOffset = clamp(this._drag.offset + dx * this._worldPerPixel(), -limit, limit);
this.timeline.group.position.x = this.timelineOffset;
canvas.style.cursor = 'grabbing';
this._needsRender = true;
+ return;
}
- return;
}
this._hoverDirty = true; // 射线检测移到 _animate 中每帧至多执行一次
});
@@ -242,10 +245,16 @@ export class App {
canvas.addEventListener('pointerup', (e) => {
if (this._drag && this._drag.active) {
const wasClick = !this._drag.moved;
+ const view = this._drag.view;
this._drag.active = false;
canvas.style.cursor = 'grab';
- this.timeline.setPausedGeneration(false); // 拖拽结束,恢复纹理生成
- if (wasClick) this._pick();
+ if (view === 'timeline') {
+ this.timeline.setPausedGeneration(false); // 拖拽结束,恢复纹理生成
+ if (wasClick) this._pick();
+ } else if (view === 'binder' && wasClick) {
+ setPointer(e); // 用松开位置做射线,松开时触发翻页
+ this._pick();
+ }
}
});
@@ -260,8 +269,7 @@ export class App {
window.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
const dir = e.key === 'ArrowRight' ? 1 : -1;
- if (this.view === 'timeline') this._stepTimeline(dir);
- else this._step(dir);
+ this._stepByView(dir);
}
else if (e.key === '1') this.switchView('binder');
else if (e.key === '2') this.switchView('timeline');
@@ -303,10 +311,10 @@ export class App {
_hitTest() {
let list;
if (this.view === 'binder') {
- // 只检测顶层可点击页(左右页 + 翻页缓冲),避免对全部 182 页求交
+ // 只检测顶层可点击页(左右顶页 + 正反翻页缓冲),避免对全部页求交
list = [];
const cur = this.binder.current;
- for (let d = -1; d <= 1; d++) {
+ for (let d = -2; d <= 1; d++) {
const i = cur + d;
if (i >= 0 && i < this.binder.pages.length) list.push(this.binder.pages[i].mesh);
}
@@ -333,15 +341,11 @@ export class App {
if (!hit) return;
const { view, index } = hit.object.userData;
if (view === 'binder') {
- const cur = this.binder.current;
- const f = this.binder.flip;
- // 翻页进行中:把“正在翻的页”与“即将露出的下一页”都视为向前翻的目标,点击不阻断
- if (index === cur || (f && index === f.nextCurrent)) {
- this.binder.flipForward();
- } else if (index === cur - 1) {
- this.binder.flipBackward();
+ // 按点击位置的空间左右侧判断翻页方向(翻页过程中索引与左右侧会错位,不能按索引)
+ if (hit.point.x >= 0) {
+ this.binder.flipForward(); // 点了右侧 → 向前翻
} else {
- this._select(index, { syncBinder: true });
+ this.binder.flipBackward(); // 点了左侧 → 向后翻
}
} else {
this._select(index, { syncBinder: false });
@@ -368,6 +372,12 @@ export class App {
else this.binder.flipBackward();
}
+ // 上一页/下一页(底部按钮与左右键共用):时间轴为选择+居中+翻面,其它为活页本翻页
+ _stepByView(dir) {
+ if (this.view === 'timeline') this._stepTimeline(dir);
+ else this._step(dir);
+ }
+
// 时间轴:左右键选择上一张/下一张,动画与点击一致(居中 + 翻面)
_stepTimeline(dir) {
const next = clamp(this.selected + dir, 0, ENTRIES.length - 1);
@@ -378,9 +388,13 @@ export class App {
}
_select(index, { syncBinder = false } = {}) {
+ const isEnd = index >= ENTRIES.length;
this.selected = clamp(index, 0, ENTRIES.length - 1);
- const entry = ENTRIES[this.selected];
- this.overlay.setEntry(entry, this.selected, ENTRIES.length);
+ if (isEnd) {
+ this.overlay.setEnd(ENTRIES[this.selected]);
+ } else {
+ this.overlay.setEntry(ENTRIES[this.selected], this.selected, ENTRIES.length);
+ }
this.timeline.setSelected(this.selected);
this.globe.setSelected(this.selected);
if (this.view === 'globe') this.globe.flyToEntry(this.selected); // 地球页切换时自动居中
@@ -428,6 +442,12 @@ export class App {
// 阴影仅在活页本视图启用:时间轴/地理无投影,降低滑动时阴影贴图重绘开销
this.renderer.shadowMap.enabled = view === 'binder';
+ // 灯光:时间轴只用半球光 + 主方向光;活页本保留全部灯光(减少 PBR 每像素光照计算)
+ const fullLights = view === 'binder';
+ this.rimLight.visible = fullLights;
+ this.grazeLight.visible = fullLights;
+ this.backLight.visible = fullLights;
+
// Cesium 地球:地理视图显示 Cesium 画布,其余视图显示 Three.js 画布
document.getElementById('scene').style.display = isGlobe ? 'none' : 'block';
document.getElementById('cesium-container').classList.toggle('visible', isGlobe);
@@ -526,6 +546,7 @@ export class App {
// 是否有进行中的动画(决定本帧是否需要重绘)
const animating =
this.binder.flip !== null ||
+ this.binder.resetTween !== null ||
this.binder.isSettling() ||
this.timeline.hasActiveFlip() ||
this.cameraTween !== null ||
diff --git a/src/data/entries.js b/src/data/entries.js
index d50d546..a3c5d05 100644
--- a/src/data/entries.js
+++ b/src/data/entries.js
@@ -10,6 +10,7 @@ export const CATEGORY_INFO = {
export const ENTRIES = [
{
id: 'No.001',
+ code: 'BCE3-AFETNE-I0001',
time: '~ BC 3,200,000',
timeNumeric: -3200000,
region: '东非 · 埃塞俄比亚',
@@ -21,6 +22,7 @@ export const ENTRIES = [
},
{
id: 'No.002',
+ code: 'BCE3-AFKENW-I0002',
time: '~ BC 3,300,000',
timeNumeric: -3300000,
region: '东非 · 肯尼亚',
@@ -32,61 +34,67 @@ export const ENTRIES = [
},
{
id: 'No.003',
+ code: 'BCE3-AFTZNO-I0003',
time: '~ BC 2,600,000',
timeNumeric: -2600000,
region: '东非 · 坦桑尼亚',
name: '奥杜威砍砸器',
category: '物',
- sketch: 'tablet',
+ sketch: 'coin',
lat: -2.98, lng: 35.35,
description: '奥杜威峡谷出土的早期石器,标志着能人开始系统打制工具,开启了旧石器时代的奥杜威文化。',
},
{
id: 'No.004',
+ code: 'BCE3-AFKESW-I0004',
time: '~ BC 1,700,000',
timeNumeric: -1700000,
region: '东非 · 肯尼亚',
name: '阿舍利手斧',
category: '物',
- sketch: 'tablet',
+ sketch: 'document',
lat: -0.3, lng: 36.0,
description: '对称打制的泪滴状手斧,是阿舍利文化的标志,体现早期人类对工具形制的规划能力。',
},
{
id: 'No.005',
+ code: 'BCE3-ASCNNE-E0005',
time: '~ BC 1,000,000',
timeNumeric: -1000000,
region: '东亚 · 中国周口店',
name: '北京猿人用火',
category: '事',
- sketch: 'fossil',
+ sketch: 'dinosaur',
lat: 39.7, lng: 115.9,
description: '周口店遗址的灰烬层表明北京猿人已能控制并保存火种,火的使用深刻改变了人类的饮食与生存方式。',
},
{
id: 'No.006',
+ code: 'BCE2-AFMAWE-I0006',
time: '~300,000 BC',
timeNumeric: -300000,
region: '北非 · 摩洛哥',
name: '杰贝尔依罗智人化石',
category: '人',
- sketch: 'fossil',
+ sketch: 'mountain',
lat: 31.9, lng: -8.7,
description: '摩洛哥杰贝尔依罗遗址发现的智人化石距今约30万年,将现代人类的起源时间大幅提前。',
},
{
id: 'No.007',
+ code: 'BCE2-ASILNO-I0007',
time: '~100,000 BC',
timeNumeric: -100000,
region: '西亚 · 以色列',
name: '最早的象征符号',
category: '物',
- sketch: 'tablet',
+ sketch: 'hourglass',
lat: 32.7, lng: 34.9,
description: '以色列卡夫扎等地出土的贝壳串珠与赭石,是最早的象征性装饰与颜料使用证据之一。',
},
{
id: 'No.008',
+ code: 'BCE1-ASIQNO-E0008',
time: '~60,000 BC',
timeNumeric: -60000,
region: '西亚 · 伊拉克',
@@ -98,28 +106,31 @@ export const ENTRIES = [
},
{
id: 'No.009',
+ code: 'BCE1-EUDESO-I0009',
time: '~40,000 BC',
timeNumeric: -40000,
region: '欧洲 · 德国',
name: '狮子人雕像',
category: '物',
- sketch: 'fossil',
+ sketch: 'dinosaur',
lat: 48.4, lng: 9.9,
description: '德国霍伦斯坦-施塔德尔出土的猛犸象牙雕“狮子人”,是已知最早的具象艺术与想象造物。',
},
{
id: 'No.010',
+ code: 'BCE1-EUATNE-I0010',
time: '~25,000 BC',
timeNumeric: -25000,
region: '欧洲 · 奥地利',
name: '维伦多夫维纳斯',
category: '物',
- sketch: 'fossil',
+ sketch: 'mountain',
lat: 48.3, lng: 15.6,
description: '这尊石灰岩小雕像夸张母性特征,是旧石器时代丰产崇拜与早期雕塑艺术的代表。',
},
{
id: 'No.011',
+ code: 'BCE1-EUFRXX-I0011',
time: '~17,000 BC',
timeNumeric: -17000,
region: '欧洲 · 法国',
@@ -131,6 +142,7 @@ export const ENTRIES = [
},
{
id: 'No.012',
+ code: 'BCE1-ASJPEA-I0012',
time: '~12,000 BC',
timeNumeric: -12000,
region: '日本 · 本州',
@@ -142,6 +154,7 @@ export const ENTRIES = [
},
{
id: 'No.013',
+ code: 'BCE1-ASIQNO-E0013',
time: '~10,000 BC',
timeNumeric: -10000,
region: '西亚 · 两河流域',
@@ -153,6 +166,7 @@ export const ENTRIES = [
},
{
id: 'No.014',
+ code: 'BC95-GLXXXX-B0014',
time: '~9500 BC',
timeNumeric: -9500,
region: '西亚 · 土耳其',
@@ -164,6 +178,7 @@ export const ENTRIES = [
},
{
id: 'No.015',
+ code: 'BC70-GLXXXX-B0015',
time: '~7000 BC',
timeNumeric: -7000,
region: '西亚 · 土耳其',
@@ -175,83 +190,91 @@ export const ENTRIES = [
},
{
id: 'No.016',
+ code: 'BC35-ASPKSO-E0016',
time: '~3500 BC',
timeNumeric: -3500,
region: '南亚 · 印度河谷',
name: '印度河文明兴起',
category: '事',
- sketch: 'pyramid',
+ sketch: 'temple',
lat: 27.3, lng: 68.1,
description: '摩亨佐-达罗与哈拉帕等城市兴起,印度河文明拥有完善的下水道与未解的文字系统。',
},
{
id: 'No.017',
+ code: 'BC32-ASIQSE-I0017',
time: '~3200 BC',
timeNumeric: -3200,
region: '美索不达米亚 · 两河流域',
name: '楔形文字',
category: '物',
- sketch: 'tablet',
+ sketch: 'coin',
lat: 31.3, lng: 45.6,
description: '苏美尔人用削尖的芦苇杆在湿泥板上压刻楔形符号,是人类已知最早的文字系统,也是文明记录的开端。',
},
{
id: 'No.018',
+ code: 'BC31-AFEGNE-I0018',
time: '~3100 BC',
timeNumeric: -3100,
region: '古埃及 · 孟菲斯',
name: '那尔迈调色板',
category: '物',
- sketch: 'tablet',
+ sketch: 'document',
lat: 29.85, lng: 31.25,
description: '这块调色板记录那尔迈统一上下埃及,被视为法老时代与埃及王权确立的标志。',
},
{
id: 'No.019',
+ code: 'BC30-AFEGEA-I0019',
time: '~3000 BC',
timeNumeric: -3000,
region: '古埃及 · 阿拜多斯',
name: '古埃及象形文字',
category: '物',
- sketch: 'tablet',
+ sketch: 'hourglass',
lat: 26.2, lng: 31.9,
description: '古埃及人创制象形文字,与楔形文字并列为人类最早的书写系统之一,沿用三千余年。',
},
{
id: 'No.020',
+ code: 'BC26-AFEGNE-B0020',
time: '~2560 BC',
timeNumeric: -2560,
region: '古埃及 · 吉萨',
name: '胡夫金字塔',
category: '物',
- sketch: 'pyramid',
+ sketch: 'palace',
lat: 29.98, lng: 31.13,
description: '埃及第四王朝法老胡夫的陵墓,由约230万块巨石砌成,是古代世界七大奇迹中唯一留存至今的建筑。',
},
{
id: 'No.021',
+ code: 'BC25-EUGBSO-B0021',
time: '~2500 BC',
timeNumeric: -2500,
region: '不列颠 · 威尔特郡',
name: '巨石阵',
category: '物',
- sketch: 'temple',
+ sketch: 'columns',
lat: 51.18, lng: -1.83,
description: '巨石阵的巨大砂岩与蓝石环,历经千年营造,是史前天文观测与祭祀中心的未解之谜。',
},
{
id: 'No.022',
+ code: 'BC21-ASIQSE-B0022',
time: '~2100 BC',
timeNumeric: -2100,
region: '美索不达米亚 · 乌尔',
name: '乌尔金字形神塔',
category: '物',
- sketch: 'temple',
+ sketch: 'palace',
lat: 30.96, lng: 46.1,
description: '苏美尔人为月神南纳建造的阶梯神塔,是两河流域宗教建筑的典范。',
},
{
id: 'No.023',
+ code: 'BC18-ASIQSE-I0023',
time: '~1754 BC',
timeNumeric: -1754,
region: '美索不达米亚 · 巴比伦',
@@ -263,39 +286,43 @@ export const ENTRIES = [
},
{
id: 'No.024',
+ code: 'BC16-EUGRSE-B0024',
time: '~1600 BC',
timeNumeric: -1600,
region: '爱琴海 · 克里特岛',
name: '米诺斯宫殿',
category: '物',
- sketch: 'temple',
+ sketch: 'pyramid',
lat: 35.3, lng: 25.16,
description: '克里特岛克诺索斯的米诺斯王宫,廊柱交错、壁画绚丽,是爱琴文明的中心。',
},
{
id: 'No.025',
+ code: 'BC16-ASCNNE-I0025',
time: '~1600 BC',
timeNumeric: -1600,
region: '中国 · 河南安阳',
name: '甲骨文',
category: '物',
- sketch: 'tablet',
+ sketch: 'coin',
lat: 36.1, lng: 114.3,
description: '商代刻在龟甲兽骨上的占卜文字,是中国已发现最早的成体系文字,见证汉字起源。',
},
{
id: 'No.026',
+ code: 'BC15-ASINNW-I0026',
time: '~1500 BC',
timeNumeric: -1500,
region: '古印度 · 恒河流域',
name: '吠陀经典',
category: '物',
- sketch: 'scroll',
+ sketch: 'book',
lat: 29.0, lng: 78.0,
description: '印度雅利安人编纂的《梨俱吠陀》等经典,奠定印度教与种姓制度的思想源头。',
},
{
id: 'No.027',
+ code: 'BC14-AFEGEA-I0027',
time: '~1350 BC',
timeNumeric: -1350,
region: '古埃及 · 阿玛纳',
@@ -307,6 +334,7 @@ export const ENTRIES = [
},
{
id: 'No.028',
+ code: 'BC13-ASTRNO-T0028',
time: '~1250 BC',
timeNumeric: -1250,
region: '西亚 · 安纳托利亚',
@@ -318,6 +346,7 @@ export const ENTRIES = [
},
{
id: 'No.029',
+ code: 'BC12-ASLBSO-E0029',
time: '~1200 BC',
timeNumeric: -1200,
region: '地中海 · 东岸',
@@ -329,61 +358,67 @@ export const ENTRIES = [
},
{
id: 'No.030',
+ code: 'BC03-ASCNNE-E0030',
time: '221 BC',
timeNumeric: -221,
region: '中国 · 咸阳',
name: '秦始皇统一六国',
category: '事',
- sketch: 'crown',
+ sketch: 'throne',
lat: 34.34, lng: 108.94,
description: '嬴政扫灭六国,建立中国历史上第一个中央集权的大一统王朝,推行书同文、车同轨。',
},
{
id: 'No.031',
+ code: 'BC10-NAMXSE-I0031',
time: '~1000 BC',
timeNumeric: -1000,
region: '中南美 · 墨西哥湾',
name: '奥尔梅克巨石头像',
category: '物',
- sketch: 'fossil',
+ sketch: 'dinosaur',
lat: 18.1, lng: -94.4,
description: '奥尔梅克人雕凿的巨型玄武岩头像,是中美洲最早的文明与“母文化”的象征。',
},
{
id: 'No.032',
+ code: 'BC08-EUGRSO-E0032',
time: '~776 BC',
timeNumeric: -776,
region: '古希腊 · 奥林匹亚',
name: '第一届奥林匹克运动会',
category: '事',
- sketch: 'colosseum',
+ sketch: 'fortress',
lat: 37.6, lng: 21.6,
description: '古希腊人在奥林匹亚举办祭祀宙斯的竞技会,成为古代奥运会与体育竞技传统的起点。',
},
{
id: 'No.033',
+ code: 'AD01-EUITSE-B0033',
time: 'AD 80',
timeNumeric: 80,
region: '罗马帝国 · 罗马',
name: '罗马斗兽场',
category: '物',
- sketch: 'colosseum',
+ sketch: 'helmet',
lat: 41.89, lng: 12.49,
description: '古罗马最大的椭圆形竞技场,可容纳约五万名观众,是拱券结构与帝国工程的杰作。',
},
{
id: 'No.034',
+ code: 'BC07-EUGRSE-I0034',
time: '~700 BC',
timeNumeric: -700,
region: '古希腊 · 爱奥尼亚',
name: '荷马史诗',
category: '物',
- sketch: 'scroll',
+ sketch: 'document',
lat: 38.0, lng: 27.0,
description: '《伊利亚特》与《奥德赛》相传为盲诗人荷马所作,是西方文学的源头。',
},
{
id: 'No.035',
+ code: 'BC05-EUGRSE-B0035',
time: '5th century BC',
timeNumeric: -450,
region: '古希腊 · 雅典',
@@ -395,6 +430,7 @@ export const ENTRIES = [
},
{
id: 'No.036',
+ code: 'BC06-ASNPSO-P0036',
time: '~563 BC',
timeNumeric: -563,
region: '古印度 · 蓝毗尼',
@@ -406,17 +442,19 @@ export const ENTRIES = [
},
{
id: 'No.037',
+ code: 'BC06-ASCNNE-P0037',
time: '551 BC',
timeNumeric: -551,
region: '中国 · 鲁国曲阜',
name: '孔子',
category: '人',
- sketch: 'figure',
+ sketch: 'mask',
lat: 35.6, lng: 117.0,
description: '儒家学派创始人,倡导仁、礼与中庸之道,其思想深刻影响东亚两千余年,被尊为“至圣先师”。',
},
{
id: 'No.038',
+ code: 'BC05-EUGRSE-E0038',
time: '~490 BC',
timeNumeric: -490,
region: '古希腊 · 马拉松',
@@ -428,28 +466,31 @@ export const ENTRIES = [
},
{
id: 'No.039',
+ code: 'BC05-EUGRSE-E0039',
time: '~480 BC',
timeNumeric: -480,
region: '古希腊 · 温泉关',
name: '温泉关战役',
category: '事',
- sketch: 'colosseum',
+ sketch: 'fortress',
lat: 38.8, lng: 22.5,
description: '斯巴达王列奥尼达率三百勇士扼守温泉关,悲壮阻击波斯大军,成为勇气象征。',
},
{
id: 'No.040',
+ code: 'BC04-EUGRNE-P0040',
time: '~384 BC',
timeNumeric: -384,
region: '古希腊 · 斯塔基拉',
name: '亚里士多德',
category: '人',
- sketch: 'figure',
+ sketch: 'lyre',
lat: 40.5, lng: 23.8,
description: '古希腊百科全书式学者,创立逻辑学并系统研究自然,是亚历山大大帝的老师。',
},
{
id: 'No.041',
+ code: 'BC04-EUGRNE-P0041',
time: '334 BC',
timeNumeric: -334,
region: '马其顿 · 希腊半岛',
@@ -461,83 +502,91 @@ export const ENTRIES = [
},
{
id: 'No.042',
+ code: 'BC03-AFEGNO-B0042',
time: '~300 BC',
timeNumeric: -300,
region: '古埃及 · 亚历山大港',
name: '亚历山大图书馆',
category: '物',
- sketch: 'scroll',
+ sketch: 'lyre',
lat: 31.2, lng: 29.9,
description: '托勒密王朝在亚历山大港建立的古代最大图书馆,汇聚地中海世界的典籍与学者。',
},
{
id: 'No.043',
+ code: 'BC03-EUITSE-P0043',
time: '~287 BC',
timeNumeric: -287,
region: '古希腊 · 叙拉古',
name: '阿基米德',
category: '人',
- sketch: 'figure',
+ sketch: 'mask',
lat: 37.1, lng: 15.3,
description: '古希腊科学家,发现浮力定律与杠杆原理,是古代最伟大的数学家与物理学家之一。',
},
{
id: 'No.044',
+ code: 'BC03-ASINNE-P0044',
time: '~273 BC',
timeNumeric: -273,
region: '古印度 · 华氏城',
name: '阿育王',
category: '人',
- sketch: 'crown',
+ sketch: 'scepter',
lat: 25.6, lng: 85.1,
description: '孔雀王朝君主,皈依佛教后以法轮与石柱弘扬佛法,将佛教推向整个南亚。',
},
{
id: 'No.045',
+ code: 'BC03-ASCNNE-B0045',
time: '~214 BC',
timeNumeric: -214,
region: '中国 · 北方边塞',
name: '万里长城',
category: '物',
- sketch: 'pyramid',
+ sketch: 'mountain',
lat: 40.4, lng: 116.9,
description: '秦始皇连接战国长城抵御匈奴,成为人类史上规模最大的军事防御工程。',
},
{
id: 'No.046',
+ code: 'BC03-ASCNNE-I0046',
time: '~210 BC',
timeNumeric: -210,
region: '中国 · 陕西西安',
name: '秦始皇兵马俑',
category: '物',
- sketch: 'fossil',
+ sketch: 'mountain',
lat: 34.38, lng: 109.27,
description: '秦始皇陵陪葬的八千陶俑军阵,千人千面,被誉为“世界第八大奇迹”。',
},
{
id: 'No.047',
+ code: 'BC03-ASCNNE-E0047',
time: '~206 BC',
timeNumeric: -206,
region: '中国 · 陕西汉中',
name: '汉朝建立',
category: '事',
- sketch: 'crown',
+ sketch: 'palace',
lat: 33.1, lng: 107.0,
description: '刘邦击败项羽建立汉朝,开启四百年大汉王朝,奠定中华文明的主体格局。',
},
{
id: 'No.048',
+ code: 'BC02-AFTNNE-E0048',
time: '~146 BC',
timeNumeric: -146,
region: '北非 · 迦太基',
name: '迦太基灭亡',
category: '事',
- sketch: 'colosseum',
+ sketch: 'helmet',
lat: 36.9, lng: 10.3,
description: '第三次布匿战争后罗马夷平迦太基,确立地中海霸权,北非纳入罗马版图。',
},
{
id: 'No.049',
+ code: 'BC01-ASCNSW-T0049',
time: '~100 BC',
timeNumeric: -100,
region: '中国 · 四川',
@@ -549,6 +598,7 @@ export const ENTRIES = [
},
{
id: 'No.050',
+ code: 'BC01-EUITSE-E0050',
time: '~73 BC',
timeNumeric: -73,
region: '古罗马 · 卡普阿',
@@ -560,28 +610,31 @@ export const ENTRIES = [
},
{
id: 'No.051',
+ code: 'BC01-ASPSCE-P0051',
time: '~4 BC',
timeNumeric: -4,
region: '犹太 · 伯利恒',
name: '耶稣诞生',
category: '人',
- sketch: 'figure',
+ sketch: 'lyre',
lat: 31.7, lng: 35.2,
description: '基督教创始人耶稣诞生于伯利恒,其福音与受难复活成为世界最大宗教的根基。',
},
{
id: 'No.052',
+ code: 'AD02-ASCNNE-T0052',
time: 'AD 105',
timeNumeric: 105,
region: '中国 · 东汉洛阳',
name: '蔡伦造纸',
category: '事',
- sketch: 'scroll',
+ sketch: 'book',
lat: 34.6, lng: 112.4,
description: '蔡伦以树皮、麻头、破布、渔网造出轻便廉价的纸,深刻改变知识的记录与传播方式。',
},
{
id: 'No.053',
+ code: 'AD01-EUITSE-E0053',
time: 'AD 79',
timeNumeric: 79,
region: '古罗马 · 庞贝',
@@ -593,6 +646,7 @@ export const ENTRIES = [
},
{
id: 'No.054',
+ code: 'AD03-GLXXXX-E0054',
time: 'AD 250',
timeNumeric: 250,
region: '中美洲 · 墨西哥',
@@ -604,50 +658,55 @@ export const ENTRIES = [
},
{
id: 'No.055',
+ code: 'AD04-EUITNW-E0055',
time: 'AD 313',
timeNumeric: 313,
region: '古罗马 · 米兰',
name: '米兰敕令',
category: '事',
- sketch: 'temple',
+ sketch: 'columns',
lat: 45.46, lng: 9.19,
description: '君士坦丁颁布米兰敕令,基督教获得合法地位,从被迫害宗教转向帝国宗教。',
},
{
id: 'No.056',
+ code: 'AD04-ASTRNW-E0056',
time: 'AD 330',
timeNumeric: 330,
region: '拜占庭 · 君士坦丁堡',
name: '君士坦丁堡建城',
category: '事',
- sketch: 'crown',
+ sketch: 'laurel',
lat: 41.01, lng: 28.98,
description: '君士坦丁大帝在拜占庭故址营建新都,东罗马帝国从此千年不坠。',
},
{
id: 'No.057',
+ code: 'AD04-EUGRNE-E0057',
time: 'AD 380',
timeNumeric: 380,
region: '罗马帝国 · 塞萨洛尼基',
name: '基督教成为国教',
category: '事',
- sketch: 'temple',
+ sketch: 'palace',
lat: 40.64, lng: 22.94,
description: '狄奥多西颁布敕令,将尼西亚基督教立为罗马国教,奠定欧洲基督教文明。',
},
{
id: 'No.058',
+ code: 'AD05-EUITEA-E0058',
time: 'AD 476',
timeNumeric: 476,
region: '西罗马 · 拉文纳',
name: '西罗马帝国灭亡',
category: '事',
- sketch: 'colosseum',
+ sketch: 'fortress',
lat: 44.42, lng: 12.2,
description: '日耳曼首领奥多亚克废黜末帝,西罗马灭亡,西欧进入中世纪。',
},
{
id: 'No.059',
+ code: 'AD06-ASSASW-P0059',
time: '~AD 570',
timeNumeric: 570,
region: '阿拉伯半岛 · 麦加',
@@ -659,6 +718,7 @@ export const ENTRIES = [
},
{
id: 'No.060',
+ code: 'AD06-ASCNEA-E0060',
time: 'AD 589',
timeNumeric: 589,
region: '中国 · 建康',
@@ -670,39 +730,43 @@ export const ENTRIES = [
},
{
id: 'No.061',
+ code: 'AD07-ASSANW-E0061',
time: 'AD 622',
timeNumeric: 622,
region: '阿拉伯半岛 · 麦地那',
name: '希吉拉',
category: '事',
- sketch: 'temple',
+ sketch: 'pyramid',
lat: 24.47, lng: 39.61,
description: '穆罕默德从麦加迁徙麦地那,标志着伊斯兰历元年与穆斯林公社的建立。',
},
{
id: 'No.062',
+ code: 'AD07-ASIRWE-E0062',
time: 'AD 651',
timeNumeric: 651,
region: '波斯 · 萨珊',
name: '萨珊波斯灭亡',
category: '事',
- sketch: 'crown',
+ sketch: 'throne',
lat: 32.0, lng: 53.0,
description: '阿拉伯军队灭亡萨珊波斯,波斯皈依伊斯兰,西亚格局剧变。',
},
{
id: 'No.063',
+ code: 'AD07-ASKRSW-E0063',
time: 'AD 660',
timeNumeric: 660,
region: '朝鲜半岛 · 百济',
name: '新罗统一三国',
category: '事',
- sketch: 'crown',
+ sketch: 'scepter',
lat: 35.1, lng: 126.9,
description: '新罗联合唐朝灭亡百济、高句丽,统一朝鲜半岛大部,奠定朝鲜民族格局。',
},
{
id: 'No.064',
+ code: 'AD08-EUGBSW-E0064',
time: 'AD 711',
timeNumeric: 711,
region: '伊比利亚 · 直布罗陀',
@@ -714,6 +778,7 @@ export const ENTRIES = [
},
{
id: 'No.065',
+ code: 'AD08-EUFRXX-E0065',
time: 'AD 732',
timeNumeric: 732,
region: '法兰克 · 图尔',
@@ -725,39 +790,43 @@ export const ENTRIES = [
},
{
id: 'No.066',
+ code: 'AD08-ASKZSE-E0066',
time: 'AD 751',
timeNumeric: 751,
region: '中亚 · 怛罗斯',
name: '怛罗斯之战',
category: '事',
- sketch: 'sword',
+ sketch: 'helmet',
lat: 42.9, lng: 71.3,
description: '唐军与阿拔斯军队在中亚交战,造纸术由此西传阿拉伯世界。',
},
{
id: 'No.067',
+ code: 'AD08-EUDEWE-P0067',
time: 'AD 768',
timeNumeric: 768,
region: '法兰克 · 亚琛',
name: '查理曼大帝',
category: '人',
- sketch: 'crown',
+ sketch: 'palace',
lat: 50.78, lng: 6.08,
description: '法兰克国王,统一西欧大部,800年加冕为“罗马人的皇帝”,被誉为欧洲之父。',
},
{
id: 'No.068',
+ code: 'AD08-ASJPSW-E0068',
time: 'AD 794',
timeNumeric: 794,
region: '日本 · 京都',
name: '平安京建都',
category: '事',
- sketch: 'crown',
+ sketch: 'laurel',
lat: 35.01, lng: 135.77,
description: '桓武天皇迁都平安京,开启日本平安时代,贵族文化与物语文学繁荣。',
},
{
id: 'No.069',
+ code: 'AD09-EUFRXX-E0069',
time: 'AD 843',
timeNumeric: 843,
region: '法兰克 · 凡尔登',
@@ -769,17 +838,19 @@ export const ENTRIES = [
},
{
id: 'No.070',
+ code: 'AD09-ASCNNW-I0070',
time: 'AD 868',
timeNumeric: 868,
region: '中国 · 敦煌',
name: '《金刚经》雕版印刷',
category: '物',
- sketch: 'scroll',
+ sketch: 'document',
lat: 40.1, lng: 94.7,
description: '敦煌藏经洞发现的《金刚经》,是世界现存最早有明确纪年的雕版印刷品。',
},
{
id: 'No.071',
+ code: 'AD10-ASCNNE-E0071',
time: 'AD 960',
timeNumeric: 960,
region: '中国 · 开封',
@@ -791,39 +862,43 @@ export const ENTRIES = [
},
{
id: 'No.072',
+ code: 'AD10-NAGLSW-E0072',
time: 'AD 1000',
timeNumeric: 1000,
region: '北欧 · 格陵兰',
name: '维京人抵达美洲',
category: '事',
- sketch: 'ship',
+ sketch: 'anchor',
lat: 64.2, lng: -51.7,
description: '莱夫·埃里克松率维京人抵达北美文兰,早于哥伦布近五百年。',
},
{
id: 'No.073',
+ code: 'AD11-ASCNSE-T0073',
time: 'AD 1040',
timeNumeric: 1040,
region: '中国 · 北宋',
name: '毕昇活字印刷',
category: '物',
- sketch: 'scroll',
+ sketch: 'lyre',
lat: 30.2, lng: 120.2,
description: '毕昇发明胶泥活字,实现可重复使用的排版印刷,是世界印刷史上的革命。',
},
{
id: 'No.074',
+ code: 'AD11-EUGBSE-E0074',
time: 'AD 1066',
timeNumeric: 1066,
region: '英格兰 · 黑斯廷斯',
name: '诺曼征服',
category: '事',
- sketch: 'sword',
+ sketch: 'cannon',
lat: 50.9, lng: 0.5,
description: '诺曼底公爵威廉在黑斯廷斯击败英军,征服英格兰,深刻改变英国政治与语言。',
},
{
id: 'No.075',
+ code: 'AD11-EUITCE-B0075',
time: 'AD 1088',
timeNumeric: 1088,
region: '意大利 · 博洛尼亚',
@@ -835,17 +910,19 @@ export const ENTRIES = [
},
{
id: 'No.076',
+ code: 'AD11-EUFRXX-E0076',
time: 'AD 1096',
timeNumeric: 1096,
region: '西欧 · 克莱蒙',
name: '第一次十字军东征',
category: '事',
- sketch: 'sword',
+ sketch: 'fortress',
lat: 45.78, lng: 3.08,
description: '教皇乌尔班二世号召东征,十字军夺回耶路撒冷,开启两百年的宗教战争。',
},
{
id: 'No.077',
+ code: 'AD12-ASKHNW-B0077',
time: 'AD 1150',
timeNumeric: 1150,
region: '柬埔寨 · 吴哥',
@@ -857,17 +934,19 @@ export const ENTRIES = [
},
{
id: 'No.078',
+ code: 'AD12-EUFRXX-B0078',
time: 'AD 1163',
timeNumeric: 1163,
region: '法国 · 巴黎',
name: '巴黎圣母院',
category: '物',
- sketch: 'temple',
+ sketch: 'columns',
lat: 48.85, lng: 2.35,
description: '哥特式建筑的开山之作巴黎圣母院动工,飞扶壁与玫瑰窗开创建筑新纪元。',
},
{
id: 'No.079',
+ code: 'AD13-ASMNNE-P0079',
time: 'AD 1206',
timeNumeric: 1206,
region: '蒙古高原',
@@ -879,72 +958,79 @@ export const ENTRIES = [
},
{
id: 'No.080',
+ code: 'AD13-EUGBSE-I0080',
time: 'AD 1215',
timeNumeric: 1215,
region: '英格兰 · 兰尼米德',
name: '《大宪章》',
category: '物',
- sketch: 'scale',
+ sketch: 'gavel',
lat: 51.4, lng: -0.6,
description: '英王约翰被迫签署《大宪章》,王权首次受法律约束,是宪政与法治的基石。',
},
{
id: 'No.081',
+ code: 'AD13-ASCNNE-E0081',
time: 'AD 1271',
timeNumeric: 1271,
region: '中国 · 大都',
name: '元朝建立',
category: '事',
- sketch: 'crown',
+ sketch: 'throne',
lat: 39.9, lng: 116.4,
description: '忽必烈改国号为元,定都大都,中国首次由少数民族建立全国性统一王朝。',
},
{
id: 'No.082',
+ code: 'AD13-EUITSO-E0082',
time: 'AD 1300',
timeNumeric: 1300,
region: '意大利 · 佛罗伦萨',
name: '文艺复兴兴起',
category: '事',
- sketch: 'book',
+ sketch: 'scroll',
lat: 43.77, lng: 11.26,
description: '佛罗伦萨等地兴起人文主义,复兴古典文化,开启从中世纪向近代的转型。',
},
{
id: 'No.083',
+ code: 'AD14-EUFRXX-E0083',
time: 'AD 1337',
timeNumeric: 1337,
region: '欧洲 · 英法',
name: '百年战争',
category: '事',
- sketch: 'sword',
+ sketch: 'bow',
lat: 48.5, lng: 2.0,
description: '英法为争夺王位与领土爆发百年战争,民族国家意识在战火中觉醒。',
},
{
id: 'No.084',
+ code: 'AD14-EUITNW-E0084',
time: 'AD 1347',
timeNumeric: 1347,
region: '欧洲 · 意大利',
name: '黑死病大流行',
category: '事',
- sketch: 'fossil',
+ sketch: 'dinosaur',
lat: 45.4, lng: 10.0,
description: '鼠疫席卷欧洲,约三分之一人口死亡,深刻动摇中世纪社会与教会权威。',
},
{
id: 'No.085',
+ code: 'AD15-AFMANE-E0085',
time: 'AD 1415',
timeNumeric: 1415,
region: '葡萄牙 · 休达',
name: '葡萄牙海外扩张',
category: '事',
- sketch: 'ship',
+ sketch: 'compass',
lat: 35.9, lng: -5.3,
description: '葡萄牙攻占北非休达,拉开欧洲大航海时代与海外殖民的序幕。',
},
{
id: 'No.086',
+ code: 'AD15-EUFRXX-P0086',
time: 'AD 1431',
timeNumeric: 1431,
region: '法国 · 鲁昂',
@@ -956,6 +1042,7 @@ export const ENTRIES = [
},
{
id: 'No.087',
+ code: 'AD15-EUDESW-T0087',
time: 'AD 1440',
timeNumeric: 1440,
region: '德意志 · 美因茨',
@@ -967,39 +1054,43 @@ export const ENTRIES = [
},
{
id: 'No.088',
+ code: 'AD15-EUESSO-E0088',
time: 'AD 1492',
timeNumeric: 1492,
region: '西班牙 · 格拉纳达',
name: '收复失地运动完成',
category: '事',
- sketch: 'sword',
+ sketch: 'shield',
lat: 37.18, lng: -3.6,
description: '西班牙双王攻克格拉纳达,完成对伊比利亚的再征服,终结穆斯林统治。',
},
{
id: 'No.089',
+ code: 'AD15-NAESAM-E0089',
time: 'AD 1492',
timeNumeric: 1492,
region: '西班牙 → 美洲',
name: '哥伦布发现新大陆',
category: '事',
- sketch: 'ship',
+ sketch: 'globe',
lat: 24.0, lng: -75.0,
description: '哥伦布率三艘帆船横渡大西洋抵达美洲,揭开大航海时代与全球联系的序幕。',
},
{
id: 'No.090',
+ code: 'AD15-GLPTTR-E0090',
time: 'AD 1494',
timeNumeric: 1494,
region: '大西洋 · 托德西利亚斯',
name: '《托德西利亚斯条约》',
category: '事',
- sketch: 'scale',
+ sketch: 'columns',
lat: 41.5, lng: -5.0,
description: '教皇仲裁西葡瓜分新世界,划定教皇子午线,塑造拉美殖民格局。',
},
{
id: 'No.091',
+ code: 'AD15-ASINSW-E0091',
time: 'AD 1498',
timeNumeric: 1498,
region: '印度 · 卡利卡特',
@@ -1011,17 +1102,19 @@ export const ENTRIES = [
},
{
id: 'No.092',
+ code: 'AD16-EUDENE-E0092',
time: 'AD 1517',
timeNumeric: 1517,
region: '德意志 · 维滕贝格',
name: '马丁·路德宗教改革',
category: '事',
- sketch: 'scroll',
+ sketch: 'book',
lat: 51.87, lng: 12.64,
description: '马丁·路德张贴《九十五条论纲》,宗教改革席卷欧洲,新教从此兴起。',
},
{
id: 'No.093',
+ code: 'AD16-GLXXXX-E0093',
time: 'AD 1519',
timeNumeric: 1519,
region: '中美洲 · 墨西哥',
@@ -1033,6 +1126,7 @@ export const ENTRIES = [
},
{
id: 'No.094',
+ code: 'AD16-GLGLGB-E0094',
time: 'AD 1519—1522',
timeNumeric: 1519,
region: '欧洲 → 环球',
@@ -1044,61 +1138,67 @@ export const ENTRIES = [
},
{
id: 'No.095',
+ code: 'AD16-ASINNW-E0095',
time: 'AD 1526',
timeNumeric: 1526,
region: '印度 · 帕尼帕特',
name: '莫卧儿帝国建立',
category: '事',
- sketch: 'crown',
+ sketch: 'scepter',
lat: 29.4, lng: 77.0,
description: '巴布尔在帕尼帕特击败德里苏丹,建立莫卧儿帝国,印度进入新的黄金时代。',
},
{
id: 'No.096',
+ code: 'AD16-SAPESE-E0096',
time: 'AD 1533',
timeNumeric: 1533,
region: '南美 · 秘鲁',
name: '皮萨罗征服印加',
category: '事',
- sketch: 'sword',
+ sketch: 'helmet',
lat: -13.5, lng: -72.0,
description: '皮萨罗俘虏印加王阿塔瓦尔帕,灭亡印加帝国,安第斯文明戛然而止。',
},
{
id: 'No.097',
+ code: 'AD16-EUPLNO-T0097',
time: 'AD 1543',
timeNumeric: 1543,
region: '波兰 · 弗龙堡',
name: '哥白尼日心说',
category: '物',
- sketch: 'globe',
+ sketch: 'compass',
lat: 54.36, lng: 19.68,
description: '哥白尼《天体运行论》提出日心说,动摇地心说,开启近代科学革命。',
},
{
id: 'No.098',
+ code: 'AD16-EUDESE-E0098',
time: 'AD 1555',
timeNumeric: 1555,
region: '德意志 · 奥格斯堡',
name: '《奥格斯堡和约》',
category: '事',
- sketch: 'scale',
+ sketch: 'document',
lat: 48.37, lng: 10.9,
description: '神圣罗马帝国确立“教随国定”原则,承认路德宗合法地位。',
},
{
id: 'No.099',
+ code: 'AD16-EUGBCH-E0099',
time: 'AD 1588',
timeNumeric: 1588,
region: '英吉利海峡',
name: '击败无敌舰队',
category: '事',
- sketch: 'ship',
+ sketch: 'anchor',
lat: 50.2, lng: -3.0,
description: '英国海军击败西班牙无敌舰队,西班牙海上霸权衰落,英国崛起。',
},
{
id: 'No.100',
+ code: 'AD19-EUGBSE-T0100',
time: 'AD 1859',
timeNumeric: 1859,
region: '英国 · 伦敦',
@@ -1110,28 +1210,31 @@ export const ENTRIES = [
},
{
id: 'No.101',
+ code: 'AD17-ASJPEA-E0101',
time: 'AD 1603',
timeNumeric: 1603,
region: '日本 · 江户',
name: '德川幕府建立',
category: '事',
- sketch: 'crown',
+ sketch: 'palace',
lat: 35.68, lng: 139.76,
description: '德川家康在关原之战后建立江户幕府,日本进入两百余年锁国与和平。',
},
{
id: 'No.102',
+ code: 'AD17-EUESCE-I0102',
time: 'AD 1605',
timeNumeric: 1605,
region: '西班牙 · 马德里',
name: '《堂吉诃德》',
category: '物',
- sketch: 'book',
+ sketch: 'document',
lat: 40.42, lng: -3.7,
description: '塞万提斯发表《堂吉诃德》,被誉为欧洲第一部现代小说。',
},
{
id: 'No.103',
+ code: 'AD17-EUITNE-T0103',
time: 'AD 1609',
timeNumeric: 1609,
region: '意大利 · 帕多瓦',
@@ -1143,17 +1246,19 @@ export const ENTRIES = [
},
{
id: 'No.104',
+ code: 'AD17-NAUSNE-E0104',
time: 'AD 1620',
timeNumeric: 1620,
region: '北美 · 普利茅斯',
name: '五月花号',
category: '事',
- sketch: 'ship',
+ sketch: 'compass',
lat: 41.96, lng: -70.66,
description: '清教徒乘“五月花号”抵达北美,签订《五月花号公约》,奠定自治传统。',
},
{
id: 'No.105',
+ code: 'AD17-EUDENW-E0105',
time: 'AD 1648',
timeNumeric: 1648,
region: '欧洲 · 威斯特伐利亚',
@@ -1165,6 +1270,7 @@ export const ENTRIES = [
},
{
id: 'No.106',
+ code: 'AD17-EUGBSE-T0106',
time: 'AD 1687',
timeNumeric: 1687,
region: '英国 · 剑桥',
@@ -1176,61 +1282,67 @@ export const ENTRIES = [
},
{
id: 'No.107',
+ code: 'AD18-EUFRXX-E0107',
time: 'AD 1715',
timeNumeric: 1715,
region: '法国 · 凡尔赛',
name: '路易十四时代',
category: '事',
- sketch: 'crown',
+ sketch: 'laurel',
lat: 48.8, lng: 2.13,
description: '“太阳王”路易十四的绝对君主制与凡尔赛宫,是欧洲专制的巅峰。',
},
{
id: 'No.108',
+ code: 'AD18-EUSESW-T0108',
time: 'AD 1735',
timeNumeric: 1735,
region: '瑞典 · 乌普萨拉',
name: '林奈分类法',
category: '物',
- sketch: 'tree',
+ sketch: 'flower',
lat: 59.86, lng: 17.64,
description: '林奈创立双名法与生物分类体系,为现代生物学奠定命名基础。',
},
{
id: 'No.109',
+ code: 'AD18-EUGBSO-E0109',
time: 'AD 1750',
timeNumeric: 1750,
region: '欧洲 · 英格兰',
name: '工业革命',
category: '事',
- sketch: 'machine',
+ sketch: 'gear',
lat: 53.0, lng: -2.0,
description: '纺织机与蒸汽动力推动生产方式剧变,人类社会从农业转向工业。',
},
{
id: 'No.110',
+ code: 'AD18-EUGBNW-T0110',
time: 'AD 1769',
timeNumeric: 1769,
region: '英国 · 格拉斯哥',
name: '瓦特改良蒸汽机',
category: '物',
- sketch: 'machine',
+ sketch: 'anvil',
lat: 55.86, lng: -4.25,
description: '瓦特改良的蒸汽机大幅提高热效率并输出旋转动力,成为工业革命核心引擎。',
},
{
id: 'No.111',
+ code: 'AD18-OCAUSE-E0111',
time: 'AD 1770',
timeNumeric: 1770,
region: '澳大利亚 · 植物学湾',
name: '库克抵达澳大利亚',
category: '事',
- sketch: 'ship',
+ sketch: 'globe',
lat: -33.99, lng: 151.2,
description: '库克船长抵达澳洲东海岸并宣称为英国属地,欧洲人开始殖民澳洲。',
},
{
id: 'No.112',
+ code: 'AD18-NAUSEA-E0112',
time: 'AD 1776',
timeNumeric: 1776,
region: '北美 · 费城',
@@ -1242,6 +1354,7 @@ export const ENTRIES = [
},
{
id: 'No.113',
+ code: 'AD20-NAUSNE-E0113',
time: 'AD 1945',
timeNumeric: 1945,
region: '世界 · 纽约',
@@ -1253,105 +1366,115 @@ export const ENTRIES = [
},
{
id: 'No.114',
+ code: 'AD18-EUGBSO-T0114',
time: 'AD 1796',
timeNumeric: 1796,
region: '英格兰 · 伯克利',
name: '琴纳牛痘接种',
category: '事',
- sketch: 'apple',
+ sketch: 'flask',
lat: 51.69, lng: -2.46,
description: '爱德华·琴纳以牛痘接种预防天花,开创免疫学与疫苗时代。',
},
{
id: 'No.115',
+ code: 'AD19-NAHTSO-E0115',
time: 'AD 1804',
timeNumeric: 1804,
region: '海地 · 太子港',
name: '海地独立',
category: '事',
- sketch: 'bell',
+ sketch: 'gavel',
lat: 18.54, lng: -72.34,
description: '海地革命胜利,建立第一个黑人共和国,废除奴隶制。',
},
{
id: 'No.116',
+ code: 'AD19-SAVENW-E0116',
time: 'AD 1810',
timeNumeric: 1810,
region: '拉丁美洲 · 委内瑞拉',
name: '拉美独立运动',
category: '事',
- sketch: 'bell',
+ sketch: 'document',
lat: 10.5, lng: -66.9,
description: '玻利瓦尔与圣马丁领导南美独立战争,西葡殖民帝国土崩瓦解。',
},
{
id: 'No.117',
+ code: 'AD19-EURUSW-E0117',
time: 'AD 1812',
timeNumeric: 1812,
region: '俄国 · 莫斯科',
name: '拿破仑远征俄国',
category: '事',
- sketch: 'sword',
+ sketch: 'cannon',
lat: 55.75, lng: 37.62,
description: '拿破仑大军远征俄国失败,帝国由盛转衰,欧洲反法同盟反攻。',
},
{
id: 'No.118',
+ code: 'AD19-EUBECE-E0118',
time: 'AD 1815',
timeNumeric: 1815,
region: '比利时 · 滑铁卢',
name: '滑铁卢战役',
category: '事',
- sketch: 'sword',
+ sketch: 'fortress',
lat: 50.68, lng: 4.41,
description: '反法同盟在滑铁卢最终击败拿破仑,欧洲进入维也纳体系时代。',
},
{
id: 'No.119',
+ code: 'AD19-EUGBEA-T0119',
time: 'AD 1825',
timeNumeric: 1825,
region: '英格兰 · 斯托克顿',
name: '第一条铁路',
category: '物',
- sketch: 'machine',
+ sketch: 'locomotive',
lat: 54.57, lng: -1.32,
description: '斯托克顿至达灵顿铁路通车,蒸汽机车开启铁路时代。',
},
{
id: 'No.120',
+ code: 'AD19-ASCNSE-E0120',
time: 'AD 1839',
timeNumeric: 1839,
region: '中国 · 广东虎门',
name: '虎门销烟',
category: '事',
- sketch: 'flag',
+ sketch: 'laurel',
lat: 22.8, lng: 113.6,
description: '林则徐在虎门销毁鸦片,引发第一次鸦片战争,中国近代史开端。',
},
{
id: 'No.121',
+ code: 'AD19-EUUASE-E0121',
time: 'AD 1854',
timeNumeric: 1854,
region: '克里米亚 · 塞瓦斯托波尔',
name: '南丁格尔护理改革',
category: '事',
- sketch: 'flag',
+ sketch: 'mountain',
lat: 44.6, lng: 33.52,
description: '南丁格尔在克里米亚战场改革战地护理,创立现代护理学。',
},
{
id: 'No.122',
+ code: 'AD19-NAUSSE-E0122',
time: 'AD 1861',
timeNumeric: 1861,
region: '美国 · 萨姆特堡',
name: '美国内战爆发',
category: '事',
- sketch: 'sword',
+ sketch: 'bow',
lat: 32.75, lng: -79.87,
description: '南北因奴隶制与州权决裂,内战爆发,最终废除奴隶制并维护统一。',
},
{
id: 'No.123',
+ code: 'AD19-EUITWE-E0123',
time: 'AD 1861',
timeNumeric: 1861,
region: '意大利 · 都灵',
@@ -1363,39 +1486,43 @@ export const ENTRIES = [
},
{
id: 'No.124',
+ code: 'AD19-NAUSEA-I0124',
time: 'AD 1863',
timeNumeric: 1863,
region: '美国 · 葛底斯堡',
name: '《解放宣言》',
category: '物',
- sketch: 'scale',
+ sketch: 'gavel',
lat: 39.83, lng: -77.23,
description: '林肯颁布《解放黑人奴隶宣言》,废奴成为内战目标,扭转战争性质。',
},
{
id: 'No.125',
+ code: 'AD19-NAUSSE-E0125',
time: 'AD 1865',
timeNumeric: 1865,
region: '美国 · 华盛顿',
name: '林肯遇刺',
category: '事',
- sketch: 'flag',
+ sketch: 'dove',
lat: 38.9, lng: -77.02,
description: '林肯在剧院遇刺身亡,这位废奴总统成为美国统一与自由的象征。',
},
{
id: 'No.126',
+ code: 'AD19-EUSESW-T0126',
time: 'AD 1867',
timeNumeric: 1867,
region: '瑞典 · 斯德哥尔摩',
name: '诺贝尔炸药',
category: '物',
- sketch: 'machine',
+ sketch: 'factory',
lat: 59.33, lng: 18.06,
description: '诺贝尔发明安全炸药,推动采矿与工程,也奠定诺贝尔奖的财富基础。',
},
{
id: 'No.127',
+ code: 'AD19-AFEGNE-B0127',
time: 'AD 1869',
timeNumeric: 1869,
region: '埃及 · 苏伊士',
@@ -1407,17 +1534,19 @@ export const ENTRIES = [
},
{
id: 'No.128',
+ code: 'AD19-EURUNW-T0128',
time: 'AD 1869',
timeNumeric: 1869,
region: '俄国 · 圣彼得堡',
name: '门捷列夫元素周期表',
category: '物',
- sketch: 'atom',
+ sketch: 'microscope',
lat: 59.93, lng: 30.36,
description: '门捷列夫发表元素周期表,揭示化学元素的内在规律。',
},
{
id: 'No.129',
+ code: 'AD19-NAUSNE-T0129',
time: 'AD 1876',
timeNumeric: 1876,
region: '美国 · 波士顿',
@@ -1429,50 +1558,55 @@ export const ENTRIES = [
},
{
id: 'No.130',
+ code: 'AD19-NAUSNE-T0130',
time: 'AD 1879',
timeNumeric: 1879,
region: '美国 · 门洛帕克',
name: '爱迪生白炽灯',
category: '物',
- sketch: 'atom',
+ sketch: 'telescope',
lat: 40.56, lng: -74.33,
description: '爱迪生改进白炽灯并建立供电系统,电灯走进千家万户。',
},
{
id: 'No.131',
+ code: 'AD19-EUDENE-T0131',
time: 'AD 1882',
timeNumeric: 1882,
region: '德国 · 柏林',
name: '科赫发现结核杆菌',
category: '事',
- sketch: 'apple',
+ sketch: 'tree',
lat: 52.52, lng: 13.4,
description: '罗伯特·科赫发现结核杆菌,奠定细菌学与传染病防治基础。',
},
{
id: 'No.132',
+ code: 'AD19-EUDESW-T0132',
time: 'AD 1885',
timeNumeric: 1885,
region: '德国 · 曼海姆',
name: '本茨汽车',
category: '物',
- sketch: 'machine',
+ sketch: 'gear',
lat: 49.49, lng: 8.47,
description: '卡尔·本茨制造第一辆内燃机汽车,汽车时代就此发端。',
},
{
id: 'No.133',
+ code: 'AD19-EUDESO-T0133',
time: 'AD 1895',
timeNumeric: 1895,
region: '德国 · 维尔茨堡',
name: '伦琴发现X射线',
category: '事',
- sketch: 'atom',
+ sketch: 'flask',
lat: 49.79, lng: 9.95,
description: '伦琴发现X射线,开创放射学,医疗影像与物理学进入新阶段。',
},
{
id: 'No.134',
+ code: 'AD20-NAUSSE-T0134',
time: 'AD 1903',
timeNumeric: 1903,
region: '美国 · 基蒂霍克',
@@ -1484,28 +1618,31 @@ export const ENTRIES = [
},
{
id: 'No.135',
+ code: 'AD20-EUCHCE-T0135',
time: 'AD 1905',
timeNumeric: 1905,
region: '瑞士 · 伯尔尼',
name: '爱因斯坦狭义相对论',
category: '事',
- sketch: 'atom',
+ sketch: 'lightbulb',
lat: 46.95, lng: 7.45,
description: '爱因斯坦提出狭义相对论,得出质能方程 E=mc²,颠覆牛顿绝对时空观。',
},
{
id: 'No.136',
+ code: 'AD20-NAUSNE-T0136',
time: 'AD 1909',
timeNumeric: 1909,
region: '美国 · 纽约',
name: '福特T型车',
category: '物',
- sketch: 'machine',
+ sketch: 'anvil',
lat: 42.33, lng: -83.05,
description: '福特T型车量产,流水线生产方式让汽车走进大众,工业进入大规模生产时代。',
},
{
id: 'No.137',
+ code: 'AD20-ASCNSE-E0137',
time: 'AD 1911',
timeNumeric: 1911,
region: '中国 · 武昌',
@@ -1517,6 +1654,7 @@ export const ENTRIES = [
},
{
id: 'No.138',
+ code: 'AD20-ANAQSP-E0138',
time: 'AD 1911',
timeNumeric: 1911,
region: '挪威 · 南极',
@@ -1528,50 +1666,55 @@ export const ENTRIES = [
},
{
id: 'No.139',
+ code: 'AD20-GLATOC-E0139',
time: 'AD 1912',
timeNumeric: 1912,
region: '大西洋',
name: '泰坦尼克号沉没',
category: '事',
- sketch: 'ship',
+ sketch: 'anchor',
lat: 41.73, lng: -49.95,
description: '“不沉之船”泰坦尼克号首航撞冰山沉没,促使国际海事安全规则建立。',
},
{
id: 'No.140',
+ code: 'AD20-EUBACE-E0140',
time: 'AD 1914',
timeNumeric: 1914,
region: '欧洲 · 萨拉热窝',
name: '第一次世界大战',
category: '事',
- sketch: 'sword',
+ sketch: 'shield',
lat: 43.86, lng: 18.41,
description: '萨拉热窝刺杀点燃大战,同盟国与协约国厮杀四年,重绘世界版图。',
},
{
id: 'No.141',
+ code: 'AD20-EUFRXX-E0141',
time: 'AD 1918',
timeNumeric: 1918,
region: '欧洲 · 康边',
name: '一战结束',
category: '事',
- sketch: 'scale',
+ sketch: 'columns',
lat: 49.42, lng: 2.82,
description: '德国签署停战协定,一战结束,四大帝国解体。',
},
{
id: 'No.142',
+ code: 'AD20-EUCHSW-E0142',
time: 'AD 1920',
timeNumeric: 1920,
region: '瑞士 · 日内瓦',
name: '国际联盟成立',
category: '事',
- sketch: 'scale',
+ sketch: 'document',
lat: 46.2, lng: 6.15,
description: '一战后成立的国际联盟,虽未能阻止二战,却是联合国的前身。',
},
{
id: 'No.143',
+ code: 'AD20-ASCNNE-E0143',
time: 'AD 1931',
timeNumeric: 1931,
region: '中国 · 沈阳',
@@ -1583,39 +1726,43 @@ export const ENTRIES = [
},
{
id: 'No.144',
+ code: 'AD20-ASCNSE-E0144',
time: 'AD 1934',
timeNumeric: 1934,
region: '中国 · 江西',
name: '红军长征',
category: '事',
- sketch: 'flag',
+ sketch: 'laurel',
lat: 26.0, lng: 115.0,
description: '中央红军开始长征,历经两万五千里,中国革命转危为安。',
},
{
id: 'No.145',
+ code: 'AD20-EUPLEA-E0145',
time: 'AD 1939',
timeNumeric: 1939,
region: '欧洲 · 波兰',
name: '第二次世界大战',
category: '事',
- sketch: 'sword',
+ sketch: 'helmet',
lat: 52.23, lng: 21.01,
description: '德国闪击波兰,二战全面爆发,人类陷入空前浩劫。',
},
{
id: 'No.146',
+ code: 'AD20-NAUSSW-E0146',
time: 'AD 1941',
timeNumeric: 1941,
region: '美国 · 珍珠港',
name: '珍珠港事件',
category: '事',
- sketch: 'ship',
+ sketch: 'compass',
lat: 21.36, lng: -157.95,
description: '日本偷袭珍珠港,美国参战,二战扩展为全球战争。',
},
{
id: 'No.147',
+ code: 'AD20-NAUSNE-E0147',
time: 'AD 1942',
timeNumeric: 1942,
region: '美国 · 芝加哥',
@@ -1627,72 +1774,79 @@ export const ENTRIES = [
},
{
id: 'No.148',
+ code: 'AD20-EURUSO-E0148',
time: 'AD 1943',
timeNumeric: 1943,
region: '苏联 · 斯大林格勒',
name: '斯大林格勒战役',
category: '事',
- sketch: 'sword',
+ sketch: 'cannon',
lat: 48.7, lng: 44.5,
description: '苏军在斯大林格勒扭转战局,成为二战东线的转折点。',
},
{
id: 'No.149',
+ code: 'AD20-EUFRXX-E0149',
time: 'AD 1944',
timeNumeric: 1944,
region: '法国 · 诺曼底',
name: '诺曼底登陆',
category: '事',
- sketch: 'sword',
+ sketch: 'fortress',
lat: 49.4, lng: -0.6,
description: '盟军诺曼底登陆开辟第二战场,加速纳粹德国覆灭。',
},
{
id: 'No.150',
+ code: 'AD20-ASJPSW-E0150',
time: 'AD 1945',
timeNumeric: 1945,
region: '日本 · 广岛',
name: '原子弹爆炸',
category: '事',
- sketch: 'atom',
+ sketch: 'microscope',
lat: 34.39, lng: 132.46,
description: '美国在广岛、长崎投下原子弹,日本投降,核武器首次用于实战。',
},
{
id: 'No.151',
+ code: 'AD20-ASINNW-E0151',
time: 'AD 1947',
timeNumeric: 1947,
region: '印度 · 新德里',
name: '印度独立',
category: '事',
- sketch: 'bell',
+ sketch: 'gavel',
lat: 28.61, lng: 77.21,
description: '印度独立并印巴分治,英国殖民帝国开始解体。',
},
{
id: 'No.152',
+ code: 'AD20-NAUSSW-T0152',
time: 'AD 1947',
timeNumeric: 1947,
region: '美国 · 新墨西哥',
name: '晶体管发明',
category: '物',
- sketch: 'machine',
+ sketch: 'locomotive',
lat: 32.8, lng: -106.0,
description: '贝尔实验室发明晶体管,电子设备小型化与数字化时代来临。',
},
{
id: 'No.153',
+ code: 'AD20-ASILNO-E0153',
time: 'AD 1948',
timeNumeric: 1948,
region: '以色列 · 特拉维夫',
name: '以色列建国',
category: '事',
- sketch: 'crown',
+ sketch: 'throne',
lat: 32.08, lng: 34.78,
description: '以色列宣告独立,中东格局剧变,阿以冲突绵延。',
},
{
id: 'No.154',
+ code: 'AD20-EUBECE-E0154',
time: 'AD 1949',
timeNumeric: 1949,
region: '欧洲 · 布鲁塞尔',
@@ -1704,127 +1858,139 @@ export const ENTRIES = [
},
{
id: 'No.155',
+ code: 'AD20-ASKPSE-E0155',
time: 'AD 1950',
timeNumeric: 1950,
region: '朝鲜半岛',
name: '朝鲜战争',
category: '事',
- sketch: 'sword',
+ sketch: 'bow',
lat: 38.0, lng: 127.0,
description: '朝鲜战争爆发,冷战首次热战,东亚格局固化。',
},
{
id: 'No.156',
+ code: 'AD20-ASNPEA-E0156',
time: 'AD 1953',
timeNumeric: 1953,
region: '尼泊尔 · 珠峰',
name: '首次登顶珠峰',
category: '事',
- sketch: 'flag',
+ sketch: 'mountain',
lat: 27.99, lng: 86.93,
description: '希拉里与丹增首次登顶珠穆朗玛峰,人类征服地球之巅。',
},
{
id: 'No.157',
+ code: 'AD20-ASIDSE-E0157',
time: 'AD 1955',
timeNumeric: 1955,
region: '印度尼西亚 · 万隆',
name: '万隆会议',
category: '事',
- sketch: 'dove',
+ sketch: 'laurel',
lat: -6.9, lng: 107.6,
description: '亚非29国召开万隆会议,不结盟运动与第三世界力量兴起。',
},
{
id: 'No.158',
+ code: 'AD20-ASKZSW-T0158',
time: 'AD 1957',
timeNumeric: 1957,
region: '苏联 · 拜科努尔',
name: '斯普特尼克1号',
category: '物',
- sketch: 'rocket',
+ sketch: 'telescope',
lat: 45.96, lng: 63.32,
description: '苏联发射第一颗人造卫星,太空时代与美苏太空竞赛开启。',
},
{
id: 'No.159',
+ code: 'AD20-NACUNW-E0159',
time: 'AD 1959',
timeNumeric: 1959,
region: '古巴 · 哈瓦那',
name: '古巴革命',
category: '事',
- sketch: 'bell',
+ sketch: 'document',
lat: 23.11, lng: -82.37,
description: '卡斯特罗领导古巴革命成功,拉美左翼浪潮兴起。',
},
{
id: 'No.160',
+ code: 'AD20-NACUNW-E0160',
time: 'AD 1962',
timeNumeric: 1962,
region: '古巴 · 加勒比',
name: '古巴导弹危机',
category: '事',
- sketch: 'atom',
+ sketch: 'telescope',
lat: 23.0, lng: -80.0,
description: '美苏在古巴导弹危机中濒临核战边缘,冷战的至暗时刻。',
},
{
id: 'No.161',
+ code: 'AD20-ASCNNW-E0161',
time: 'AD 1964',
timeNumeric: 1964,
region: '中国 · 罗布泊',
name: '中国第一颗原子弹',
category: '事',
- sketch: 'atom',
+ sketch: 'flask',
lat: 40.5, lng: 90.0,
description: '中国成功试爆第一颗原子弹,成为核大国,打破核垄断。',
},
{
id: 'No.162',
+ code: 'AD20-ASVNSE-E0162',
time: 'AD 1965',
timeNumeric: 1965,
region: '美国 · 越南',
name: '越南战争升级',
category: '事',
- sketch: 'sword',
+ sketch: 'shield',
lat: 14.0, lng: 108.0,
description: '美国大规模卷入越南战争,冷战在东南亚激烈较量。',
},
{
id: 'No.163',
+ code: 'AD20-AFZASW-T0163',
time: 'AD 1967',
timeNumeric: 1967,
region: '南非 · 开普敦',
name: '首例心脏移植',
category: '事',
- sketch: 'apple',
+ sketch: 'flower',
lat: -33.92, lng: 18.42,
description: '巴纳德完成世界首例心脏移植手术,器官移植进入新纪元。',
},
{
id: 'No.164',
+ code: 'AD20-NAUSSE-E0164',
time: 'AD 1969',
timeNumeric: 1969,
region: '美国 · 卡纳维拉尔角',
name: '阿波罗11号登月',
category: '事',
- sketch: 'rocket',
+ sketch: 'lightbulb',
lat: 28.4, lng: -80.6,
description: '阿波罗11号将人类首次送上月球,阿姆斯特朗迈出“人类一大步”。',
},
{
id: 'No.165',
+ code: 'AD20-NAUSSW-T0165',
time: 'AD 1969',
timeNumeric: 1969,
region: '美国 · 洛杉矶',
name: '互联网诞生',
category: '事',
- sketch: 'machine',
+ sketch: 'factory',
lat: 34.05, lng: -118.24,
description: 'ARPANET前身节点联通,互联网的雏形诞生,信息时代来临。',
},
{
id: 'No.166',
+ code: 'AD20-ASEGNE-E0166',
time: 'AD 1973',
timeNumeric: 1973,
region: '中东 · 埃及叙利亚',
@@ -1836,6 +2002,7 @@ export const ENTRIES = [
},
{
id: 'No.167',
+ code: 'AD20-NAUSNW-E0167',
time: 'AD 1975',
timeNumeric: 1975,
region: '美国 · 西雅图',
@@ -1847,6 +2014,7 @@ export const ENTRIES = [
},
{
id: 'No.168',
+ code: 'AD20-NAUSSW-E0168',
time: 'AD 1976',
timeNumeric: 1976,
region: '美国 · 库比蒂诺',
@@ -1858,138 +2026,151 @@ export const ENTRIES = [
},
{
id: 'No.169',
+ code: 'AD20-ASIRNW-E0169',
time: 'AD 1979',
timeNumeric: 1979,
region: '伊朗 · 德黑兰',
name: '伊朗伊斯兰革命',
category: '事',
- sketch: 'crown',
+ sketch: 'scepter',
lat: 35.69, lng: 51.39,
description: '霍梅尼领导伊斯兰革命推翻巴列维王朝,中东政治宗教化。',
},
{
id: 'No.170',
+ code: 'AD20-EUUANW-E0170',
time: 'AD 1986',
timeNumeric: 1986,
region: '苏联 · 切尔诺贝利',
name: '切尔诺贝利核事故',
category: '事',
- sketch: 'atom',
+ sketch: 'lightbulb',
lat: 51.39, lng: 30.1,
description: '切尔诺贝利核电站爆炸,最严重的核灾难,促使全球反思核安全。',
},
{
id: 'No.171',
+ code: 'AD20-EUNLSO-E0171',
time: 'AD 1993',
timeNumeric: 1993,
region: '欧洲 · 马斯特里赫特',
name: '欧盟成立',
category: '事',
- sketch: 'scale',
+ sketch: 'gavel',
lat: 50.85, lng: 5.68,
description: '《马斯特里赫特条约》生效,欧洲联盟正式诞生。',
},
{
id: 'No.172',
+ code: 'AD20-AFZANE-E0172',
time: 'AD 1994',
timeNumeric: 1994,
region: '南非 · 比勒陀利亚',
name: '南非结束种族隔离',
category: '事',
- sketch: 'dove',
+ sketch: 'flag',
lat: -25.75, lng: 28.19,
description: '曼德拉当选总统,南非废除种族隔离制度,实现种族和解。',
},
{
id: 'No.173',
+ code: 'AD20-EUGBNW-T0173',
time: 'AD 1996',
timeNumeric: 1996,
region: '苏格兰 · 爱丁堡',
name: '克隆羊多利',
category: '事',
- sketch: 'tree',
+ sketch: 'dinosaur',
lat: 55.95, lng: -3.19,
description: '克隆羊多利诞生,体细胞克隆技术震惊世界,引发伦理大讨论。',
},
{
id: 'No.174',
+ code: 'AD20-ASCNSE-E0174',
time: 'AD 1997',
timeNumeric: 1997,
region: '中国 · 香港',
name: '香港回归',
category: '事',
- sketch: 'crown',
+ sketch: 'palace',
lat: 22.32, lng: 114.17,
description: '香港回归祖国,一国两制付诸实践。',
},
{
id: 'No.175',
+ code: 'AD20-NAUSSW-E0175',
time: 'AD 1998',
timeNumeric: 1998,
region: '美国 · 加利福尼亚',
name: '谷歌成立',
category: '事',
- sketch: 'machine',
+ sketch: 'gear',
lat: 37.42, lng: -122.08,
description: '拉里·佩奇与谢尔盖·布林创立谷歌,搜索引擎重塑信息获取方式。',
},
{
id: 'No.176',
+ code: 'AD20-EUDESW-E0176',
time: 'AD 1999',
timeNumeric: 1999,
region: '欧洲 · 法兰克福',
name: '欧元启动',
category: '事',
- sketch: 'scale',
+ sketch: 'columns',
lat: 50.11, lng: 8.68,
description: '欧元正式启动,欧洲货币一体化迈出历史性一步。',
},
{
id: 'No.177',
+ code: 'AD20-ASCNSE-E0177',
time: 'AD 1999',
timeNumeric: 1999,
region: '中国 · 澳门',
name: '澳门回归',
category: '事',
- sketch: 'crown',
+ sketch: 'laurel',
lat: 22.2, lng: 113.55,
description: '澳门回归祖国,殖民时代在亚洲落幕。',
},
{
id: 'No.178',
+ code: 'AD21-ASIQEA-E0178',
time: 'AD 2003',
timeNumeric: 2003,
region: '伊拉克 · 巴格达',
name: '伊拉克战争',
category: '事',
- sketch: 'sword',
+ sketch: 'helmet',
lat: 33.31, lng: 44.36,
description: '美英联军发动伊拉克战争,中东秩序再度动荡。',
},
{
id: 'No.179',
+ code: 'AD21-ASCNNW-E0179',
time: 'AD 2003',
timeNumeric: 2003,
region: '中国 · 酒泉',
name: '神舟五号载人航天',
category: '事',
- sketch: 'rocket',
+ sketch: 'compass',
lat: 40.96, lng: 100.29,
description: '杨利伟乘神舟五号进入太空,中国成为第三个独立载人航天国家。',
},
{
id: 'No.180',
+ code: 'AD21-NAUSSW-T0180',
time: 'AD 2007',
timeNumeric: 2007,
region: '美国 · 旧金山',
name: 'iPhone发布',
category: '物',
- sketch: 'machine',
+ sketch: 'anvil',
lat: 37.77, lng: -122.42,
description: '苹果发布iPhone,智能手机与移动互联网时代由此开启。',
},
{
id: 'No.181',
+ code: 'AD21-NAUSSE-E0181',
time: 'AD 2020',
timeNumeric: 2020,
region: '美国 · 卡纳维拉尔角',
@@ -2001,12 +2182,13 @@ export const ENTRIES = [
},
{
id: 'No.182',
+ code: 'AD21-ASCNSE-E0182',
time: 'AD 2021',
timeNumeric: 2021,
region: '中国 · 文昌',
name: '天问一号登陆火星',
category: '事',
- sketch: 'rocket',
+ sketch: 'telescope',
lat: 19.6, lng: 110.9,
description: '中国天问一号携祝融号成功着陆火星,深空探测迈入新阶段。',
},
diff --git a/src/main.js b/src/main.js
index 942020c..d54311f 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,24 +1,32 @@
import { App } from './core/App.js';
+import { initLoading } from './ui/loading.js';
+
+// 计时起点:脚本执行开始(含 App 构造耗时),加载界面强制最短展示 6 秒
+const t0 = Date.now();
+const MIN_DISPLAY = 8000;
// 全站禁止复制/剪切/右键菜单(配合 CSS user-select: none 禁止选中)
for (const ev of ['copy', 'cut', 'contextmenu']) {
document.addEventListener(ev, (e) => e.preventDefault());
}
+// OS1 加载环动画
+const loadingAnim = initLoading();
+
// 启动应用
const app = new App();
window.__app = app; // 便于调试
-// 加载界面:等首帧渲染后立即后台预载地理视图(与 loading 并行,不等其淡出);
-// loading 最少展示 3 秒(期间 Cesium 预载已同步进行)。
-const t0 = Date.now();
-const MIN_DISPLAY = 6000;
+// 首帧渲染后立即后台预载地理视图(与 loading 并行)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
- app.preloadGlobe(); // 首帧已渲染,立即预载地理界面
- const wait = Math.max(0, MIN_DISPLAY - (Date.now() - t0));
- setTimeout(() => {
- document.getElementById('loading').classList.add('hidden');
- }, wait);
+ app.preloadGlobe();
});
});
+
+// 强制 6 秒后淡出加载界面
+const wait = Math.max(0, MIN_DISPLAY - (Date.now() - t0));
+setTimeout(() => {
+ document.getElementById('loading').classList.add('hidden');
+ loadingAnim.dispose();
+}, wait);
diff --git a/src/scene/binder.js b/src/scene/binder.js
index 6348f7a..ca0dd9b 100644
--- a/src/scene/binder.js
+++ b/src/scene/binder.js
@@ -1,7 +1,7 @@
import * as THREE from 'three';
import { ENTRIES } from '../data/entries.js';
-import { PAGE_W, PAGE_H, HOLE_U, acquirePageTextures, releasePageTextures, getPlaceholderTextures, makeTwoSidedMaterial } from '../textures/pages.js';
-import { clamp, easeOutCubic } from '../utils/easing.js';
+import { PAGE_W, PAGE_H, HOLE_U, acquirePageTextures, releasePageTextures, getPlaceholderTextures, prefetchPageTextures, makeTwoSidedMaterial } from '../textures/pages.js';
+import { clamp, easeOutCubic, easeInOutCubic } from '../utils/easing.js';
const RING_R = 0.085;
const RING_TUBE = 0.0115;
@@ -234,7 +234,7 @@ function makeLeatherNormalMap() {
// 内侧绒面法线:密集细绒毛(高频噪点)
function makeSuedeNormalMap() {
- const w = 512, h = 640; // 降分辨率省 75% 逐像素计算,视觉几乎无差
+ const w = 512, h = 640;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d', { willReadFrequently: true });
@@ -278,6 +278,7 @@ export class Binder {
this.pages = [];
this.current = 0;
this.flip = null; // { index, from, to, t, duration }
+ this.resetTween = null; // 回卷重置动画
this.onChange = null;
this.suedeTex = makeSuedeTexture();
@@ -379,23 +380,25 @@ export class Binder {
return geo;
}
+ _ensureHiGeo(p) {
+ if (!p.hiGeo) {
+ p.hiGeo = this._makePageGeometry(96, 8);
+ p.hiBase = Float32Array.from(p.hiGeo.attributes.position.array);
+ }
+ }
+
_buildPages() {
- const segX = 96, segY = 8;
+ // 共享极简几何(所有栈内页相同,只露边缘无需弯曲)
+ this.loGeo = this._makePageGeometry(1, 1);
ENTRIES.forEach((entry, i) => {
- // 高分段几何:翻页/顶层页(弯曲需要足够分段)
- const hiGeo = this._makePageGeometry(segX, segY);
- const hiBase = Float32Array.from(hiGeo.attributes.position.array);
- // 极简几何:栈内页(只露边缘,无需弯曲,2 个三角形)
- const loGeo = this._makePageGeometry(1, 1);
-
// 单 mesh + 双面材质:正面登记卡、背面素描(gl_FrontFacing 自动选择)
// 活页孔用 alphaTest 直接丢弃透明像素(不启用 transparent,避免透明+双面拆两次 draw call)
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, {
alphaTest: 0.5,
});
- const mesh = new THREE.Mesh(loGeo, mat); // 初始为栈内页,用极简几何
- mesh.castShadow = false; // 由 _restack 依据是否为顶层页控制
+ const mesh = new THREE.Mesh(this.loGeo, mat); // 初始为栈内页,用极简几何
+ mesh.castShadow = false; // 由 _restackState 依据是否为顶层页控制
const g = new THREE.Group();
g.add(mesh);
@@ -404,9 +407,9 @@ export class Binder {
this.pages.push({
group: g,
- hiGeo,
- loGeo,
- hiBase,
+ hiGeo: null, // 高分段几何延迟创建(成为顶层页时才需要弯曲)
+ hiBase: null,
+ loGeo: this.loGeo,
material: mat,
mesh,
isPlaceholder: true,
@@ -428,9 +431,16 @@ export class Binder {
const angle = j < this.current ? -Math.PI : 0;
p.angle = 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);
- // 顶层页抬高,与栈内页拉开深度,避免低精度深度缓冲下的穿模(z-fighting)
+ });
+ this._restackState(this.current, instant);
+ this._updateCurvature();
+ }
+
+ // 堆叠状态(z 目标/亮度/投影/几何 LOD),不碰角度;可传未来 current 实现与翻页并行
+ _restackState(cur, instant = false) {
+ this.pages.forEach((p, j) => {
+ const onTop = (j === cur || j === cur - 1);
+ const depth = j < cur ? (cur - 1 - j) : (j - cur);
p.targetZ = onTop ? TOP_LIFT : -depth * STACK_GAP;
if (instant) {
p.z = p.targetZ;
@@ -438,28 +448,34 @@ export class Binder {
}
p.group.position.y = 0;
p.group.position.x = 0;
- // 下层页略暗
const dim = 1 - Math.min(depth * 0.06, 0.22);
p.material.color.setScalar(dim);
- // 只让顶层(当前右页 + 左侧页)投影,栈内被遮挡页不投影,省阴影 pass
p.mesh.castShadow = onTop;
- // 顶层页用高分段几何(翻页弯曲),栈内页用极简几何(省顶点)
- if ((p.mesh.geometry === p.hiGeo) !== onTop) {
- p.mesh.geometry = onTop ? p.hiGeo : p.loGeo;
+ if (onTop) {
+ this._ensureHiGeo(p);
+ if (p.mesh.geometry !== p.hiGeo) p.mesh.geometry = p.hiGeo;
+ } else if (p.mesh.geometry !== p.loGeo) {
+ p.mesh.geometry = p.loGeo;
}
});
- this._updateCurvature();
this._ensureTextures();
}
- // 需要真实纹理的窗口:当前页 ±2(左右可见页 + 翻页时会露出的下一层页 + 缓冲)
+ // 需要真实纹理的窗口:左右可见页 + 翻页时会露出的下一层页(按需,首屏最小)
_pageWindow() {
const len = this.pages.length;
const win = new Set();
- for (let d = -2; d <= 2; d++) {
+ // 左页 + 右页(当前)
+ for (let d = -1; d <= 0; d++) {
const i = this.current + d;
if (i >= 0 && i < len) win.add(i);
}
+ // 向前翻页会露出的下一层右页
+ const fwd = this.current + 1;
+ if (fwd >= 0 && fwd < len) win.add(fwd);
+ // 向后翻页会露出的下一层左页
+ const bwd = this.current - 2;
+ if (bwd >= 0 && bwd < len) win.add(bwd);
return win;
}
@@ -485,10 +501,17 @@ export class Binder {
}
}
+ // 预生成即将进入纹理窗口的页:推迟到下一帧,避免占用点击这一帧造成卡顿
+ _prefetchForFlip(index) {
+ if (index < 0 || index >= ENTRIES.length) return;
+ requestAnimationFrame(() => prefetchPageTextures(ENTRIES[index]));
+ }
+
_updateCurvature(index = null) {
// 翻页时整页柔软弧弯:自由边最高、中段有“腹”、书脊侧贴环;顶边略强于底边;静止时完全平整
const pages = index === null ? this.pages : [this.pages[index]];
pages.forEach((p) => {
+ if (!p.hiGeo) return; // 栈内页未创建高分段几何,跳过
// 弯曲只作用于高分段几何(顶层/翻页页);栈内极简几何不弯曲
const pos = p.hiGeo.attributes.position;
const arr = pos.array;
@@ -514,12 +537,13 @@ export class Binder {
syncTo(index) {
this.current = clamp(index, 0, this.pages.length - 1);
this.flip = null;
+ this.resetTween = null; // 外部跳转时终止回卷/快进动画,避免旧状态残留
this._restack(true);
if (this.onChange) this.onChange(this.current);
}
- canFlipForward() { return !this.flip; }
- canFlipBackward() { return !this.flip && this.current > 0; }
+ canFlipForward() { return !this.flip && !this.resetTween; }
+ canFlipBackward() { return !this.flip && !this.resetTween; }
// 页面 z 是否仍在向目标位置过渡(用于按需渲染:过渡期间需要持续重绘)
isSettling() {
@@ -535,46 +559,74 @@ export class Binder {
p.group.rotation.y = f.to;
p.z = TOP_LIFT;
p.group.position.z = TOP_LIFT;
- if (f.to === -Math.PI) {
- this.current = (this.current + 1) % this.pages.length;
- } else {
- this.current -= 1;
- }
+ this.current = f.nextCurrent;
this.flip = null;
this._restack(false);
}
+ _startWrap(targetCur) {
+ // 回卷/快进:所有页从各自当前角度平滑转到目标角度(左页→右侧 或 右页→左侧),current 跳到目标页
+ this.current = targetCur;
+ this.flip = null;
+ this.resetTween = { t: 0, duration: 0.5 };
+ this.pages.forEach((p, j) => {
+ p._resetFrom = p.angle;
+ p._resetTo = j < targetCur ? -Math.PI : 0;
+ });
+ this._restackState(targetCur, false);
+ if (this.onChange) this.onChange(targetCur);
+ }
+
flipForward() {
if (this.flip) this._finalizeFlip(); // 前一页没翻完也能继续翻
if (!this.canFlipForward()) return;
- this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.38 };
- // 提前把即将露出的下一层右页切到顶层状态(高分段几何/亮度/投影),避免翻页完成瞬间跳变
- const reveal = this.pages[(this.current + 1) % this.pages.length];
- if (reveal) {
- if (reveal.mesh.geometry !== reveal.hiGeo) reveal.mesh.geometry = reveal.hiGeo;
- reveal.mesh.castShadow = true;
- reveal.material.color.setScalar(1.0);
+ // END 页(所有页都已翻到左侧,current = pages.length):向前 → 回卷到第一页
+ if (this.current >= this.pages.length) {
+ this._startWrap(0);
+ return;
}
+ // 最后一页翻完即进入 END 页:右侧无纸,左页为最后一页的背面
+ const nextCurrent = this.current + 1;
+ this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.38, nextCurrent };
+ // 堆叠重排与旋转并行:翻页开始即设好目标位置,z-lerp 随旋转同步过渡
+ this._restackState(nextCurrent, false);
+ this._prefetchForFlip((nextCurrent + 2) % this.pages.length);
// 优先更新底部导航与信息卡(翻页开始即切换目标页)
- if (this.onChange) this.onChange((this.current + 1) % this.pages.length);
+ if (this.onChange) this.onChange(nextCurrent);
}
flipBackward() {
if (this.flip) this._finalizeFlip();
if (!this.canFlipBackward()) return;
- this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.38 };
- // 提前把即将露出的下一层左页切到顶层状态,避免翻页完成瞬间跳变
- const reveal = this.pages[this.current - 2];
- if (reveal) {
- if (reveal.mesh.geometry !== reveal.hiGeo) reveal.mesh.geometry = reveal.hiGeo;
- reveal.mesh.castShadow = true;
- reveal.material.color.setScalar(1.0);
+ // 第一页向后:快进到 END 页(所有未翻的右页都翻到左侧,展示末页背面)
+ if (this.current === 0) {
+ this._startWrap(this.pages.length);
+ return;
}
- // 优先更新底部导航与信息卡
- if (this.onChange) this.onChange(this.current - 1);
+ const nextCurrent = this.current - 1;
+ this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.38, nextCurrent };
+ this._restackState(nextCurrent, false);
+ this._prefetchForFlip((nextCurrent - 2 + this.pages.length) % this.pages.length);
+ if (this.onChange) this.onChange(nextCurrent);
}
update(dt) {
+ // 回卷重置动画:所有页角度从各自起点平滑回到 0(右侧)
+ if (this.resetTween) {
+ const r = this.resetTween;
+ r.t += dt / r.duration;
+ const k = clamp(r.t, 0, 1);
+ const e = easeInOutCubic(k);
+ this.pages.forEach((p) => {
+ p.angle = p._resetFrom + (p._resetTo - p._resetFrom) * e;
+ p.group.rotation.y = p.angle;
+ });
+ if (k >= 1) {
+ this.resetTween = null;
+ this._restack(true);
+ }
+ }
+
if (this.flip) {
const f = this.flip;
f.t += dt / f.duration;
@@ -591,12 +643,10 @@ export class Binder {
this._updateCurvature(f.index);
if (k >= 1) {
- if (f.to === -Math.PI) {
- // 最后一页翻完后循环回第一页
- this.current = (this.current + 1) % this.pages.length;
- } else this.current -= 1;
+ this.current = f.nextCurrent;
this.flip = null;
- this._restack(false);
+ this._updateCurvature();
+ this._ensureTextures();
}
}
@@ -610,9 +660,9 @@ export class Binder {
}
dispose() {
+ if (this.loGeo) this.loGeo.dispose();
this.pages.forEach((p) => {
- p.hiGeo.dispose();
- p.loGeo.dispose();
+ if (p.hiGeo) p.hiGeo.dispose();
p.material.dispose();
});
}
diff --git a/src/scene/timeline.js b/src/scene/timeline.js
index fc6d07a..a82595d 100644
--- a/src/scene/timeline.js
+++ b/src/scene/timeline.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
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 { clamp, easeInOutCubic } from '../utils/easing.js';
@@ -22,12 +22,13 @@ export class Timeline {
this.group = new THREE.Group();
this.cards = [];
this.selected = -1;
- this.placeholder = getPlaceholderTextures();
+ this.placeholder = getPlaceholderTexturesLow();
this._textured = new Set();
this._visRange = null;
this._ready = false;
this._pending = [];
this._dirty = false;
+ this._paused = false;
this._buildLine();
this._buildCards();
@@ -44,15 +45,16 @@ export class Timeline {
]);
this.group.add(new THREE.Line(lineGeo, lineMat));
- // 刻度:向上指向卡片、向下指向时间标签
+ // 刻度:合并为单个 LineSegments,一次绘制,避免上百次 draw call
const tickMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.5 });
+ const tickPts = [];
ENTRIES.forEach((e, i) => {
const x = cardX(i, n);
- const g = new THREE.BufferGeometry().setFromPoints([
- new THREE.Vector3(x, AXIS_Y - 0.14, 0), new THREE.Vector3(x, AXIS_Y + 0.12, 0),
- ]);
- this.group.add(new THREE.Line(g, tickMat));
+ tickPts.push(x, AXIS_Y - 0.14, 0, x, AXIS_Y + 0.12, 0);
});
+ const tickGeo = new THREE.BufferGeometry();
+ tickGeo.setAttribute('position', new THREE.Float32BufferAttribute(tickPts, 3));
+ this.group.add(new THREE.LineSegments(tickGeo, tickMat));
}
_buildCards() {
@@ -62,7 +64,7 @@ export class Timeline {
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);
// 翻转容器:单 mesh + 双面材质(翻面时 gl_FrontFacing 自动切换正/背)
@@ -76,25 +78,45 @@ export class Timeline {
flipGroup.add(cardMesh);
- // 活页上方:主题(常显,随选中上浮,超宽居中换行)
- const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 });
- name.position.set(x, NAME_Y, 0);
-
- // 轴下方:时间(常显,固定不动)
- const time = makeTextSprite(entry.time, { fontSize: 13, color: '#c9a227', scale: 0.11 });
- time.position.set(x, TIME_Y, 0);
-
- this.group.add(flipGroup, name, time);
- this.cards.push({ card: flipGroup, mesh: cardMesh, material: mat, geo: cardGeo, base, name, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null, isPlaceholder: true });
+ // 文字标签(名称/时间)延迟到首屏后由 initLabels() 分帧生成,避免阻塞首屏
+ this.group.add(flipGroup);
+ this.cards.push({ card: flipGroup, mesh: cardMesh, material: mat, geo: cardGeo, base, name: null, time: null, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null, isPlaceholder: true });
});
}
+ // 时间轴文字标签:延迟到首屏后空闲时分帧生成(364 个 canvas 文字精灵较慢,避免阻塞首屏)
+ initLabels() {
+ if (this._labelsDone) return;
+ this._labelsDone = true;
+ const n = ENTRIES.length;
+ let i = 0;
+ const next = () => window.requestAnimationFrame(step);
+ const step = () => {
+ const budget = 4; // 每帧生成 4 个标签,避免卡帧
+ for (let k = 0; k < budget && i < n; k++, i++) {
+ const c = this.cards[i];
+ const entry = ENTRIES[i];
+ const x = cardX(i, n);
+ const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 });
+ name.position.set(x, NAME_Y, 0);
+ const time = makeTextSprite(entry.time, { fontSize: 13, color: '#c9a227', scale: 0.11 });
+ time.position.set(x, TIME_Y, 0);
+ this.group.add(name, time);
+ c.name = name;
+ c.time = time;
+ if (this.selected === i) name.position.y = c.nameBaseY + CARD_FLOAT;
+ }
+ if (i < n) next();
+ };
+ next();
+ }
+
setSelected(index) {
this.selected = index;
this.cards.forEach((c) => {
const on = c.index === index;
c.card.position.y = on ? CARD_FLOAT : 0;
- c.name.position.y = c.nameBaseY + (on ? CARD_FLOAT : 0);
+ if (c.name) c.name.position.y = c.nameBaseY + (on ? CARD_FLOAT : 0);
});
}
@@ -106,7 +128,7 @@ export class Timeline {
// App 每帧根据可视区域调用:minI/maxI 为可见卡片索引范围(可为小数)
setVisibleRange(minI, maxI) {
const len = this.cards.length;
- const PAD = 4; // 缓冲:可视范围外多生成几页,拖拽时提前就绪
+ const PAD = 6; // 缓冲:可视范围外多生成几页,拖拽时提前就绪
const lo = Math.max(0, Math.floor(minI) - PAD);
const hi = Math.min(len - 1, Math.ceil(maxI) + PAD);
if (this._visRange && this._visRange.min === lo && this._visRange.max === hi) return;
@@ -138,7 +160,7 @@ export class Timeline {
for (const i of [...this._textured]) {
if (win.has(i)) continue;
const c = this.cards[i];
- releasePageTextures(ENTRIES[i]);
+ releasePageTexturesLow(ENTRIES[i]);
c.material.setPageMaps(this.placeholder.front, this.placeholder.back);
c.isPlaceholder = true;
this._textured.delete(i);
@@ -152,8 +174,15 @@ export class Timeline {
this.drainPending();
}
+ // 拖拽期间暂停纹理生成,避免主线程同步绘制导致掉帧
+ setPausedGeneration(paused) {
+ this._paused = paused;
+ if (!paused) this.drainPending(); // 恢复时立即补生成
+ }
+
// 每帧由 App._animate 驱动,逐批生成窗口内纹理(避免阻塞)
drainPending() {
+ if (this._paused) return false;
if (!this._pending || !this._pending.length) return false;
const budget = 2; // 每帧最多生成 2 页
let did = false;
@@ -161,7 +190,7 @@ export class Timeline {
const i = this._pending.shift();
const c = this.cards[i];
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.isPlaceholder = false;
this._textured.add(i);
diff --git a/src/styles.css b/src/styles.css
index 7211290..80be4e5 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -99,39 +99,35 @@ html, body {
color: rgba(230,216,182,0.65); font-family: var(--serif);
}
-/* 视图切换(位于底部导航栏内) */
-.view-switch {
- display: flex; align-items: center; gap: 8px;
-}
+/* 视图切换(单按钮,图标循环切换) */
.view-btn {
- font-family: var(--serif); font-size: 14px; letter-spacing: 0.1em;
- padding: 8px 16px; color: var(--paper); cursor: pointer;
- background: rgba(20,14,10,0.55); border: 1px solid rgba(201,162,39,0.35);
- border-radius: 3px; backdrop-filter: blur(4px);
- transition: all 0.25s ease;
-}
-.view-btn:hover { border-color: var(--gold); color: #fff; }
-.view-btn: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;
+ flex-shrink: 0;
+ width: 32px; height: 32px;
+ display: flex; align-items: center; justify-content: center;
+ color: var(--gold-soft); cursor: pointer;
+ background: rgba(255,255,255,0.04); border: 1px solid rgba(201,162,39,0.4);
+ border-radius: 50%;
+ transition: all 0.2s ease;
}
+.view-btn:hover { background: rgba(201,162,39,0.25); color: #fff; }
+.v-icon { display: none; width: 17px; height: 17px; }
+.view-btn[data-view="binder"] .v-binder { display: block; }
+.view-btn[data-view="timeline"] .v-timeline { display: block; }
+.view-btn[data-view="globe"] .v-globe { display: block; }
/* 底部导航 */
.bottom-bar {
- left: 50%; bottom: 22px; transform: translateX(-50%);
- display: flex; align-items: center; gap: 16px;
- padding: 10px 18px; border-radius: 4px;
+ left: 50%; bottom: 26px; transform: translateX(-50%);
+ display: flex; align-items: center; gap: 12px;
+ padding: 5px 14px; border-radius: 4px;
background: rgba(20,14,10,0.6); border: 1px solid rgba(201,162,39,0.25);
backdrop-filter: blur(6px);
}
.nav-btn {
- font-family: var(--serif); font-size: 16px; line-height: 1;
- width: 38px; height: 38px; border-radius: 50%;
+ flex-shrink: 0;
+ display: flex; align-items: center; justify-content: center;
+ font-family: var(--serif); font-size: 15px; line-height: 1;
+ width: 32px; height: 32px; border-radius: 50%;
color: var(--gold-soft); cursor: pointer;
background: rgba(255,255,255,0.04); border: 1px solid rgba(201,162,39,0.4);
transition: all 0.2s ease;
@@ -139,13 +135,13 @@ html, body {
.nav-btn:hover { background: rgba(201,162,39,0.25); color: #fff; transform: scale(1.06); }
.nav-btn:active { transform: scale(0.96); }
.flip-hint { width: auto; border-radius: 20px; padding: 0 18px; letter-spacing: 0.12em; font-size: 14px; }
-.page-indicator { display: flex; align-items: baseline; gap: 6px; min-width: 200px; justify-content: center; }
-#page-num { font-size: 24px; color: var(--gold-soft); font-family: var(--serif); }
+.page-indicator { display: flex; align-items: center; gap: 6px; min-width: 0; justify-content: center; }
+#page-num { font-size: 22px; color: var(--gold-soft); font-family: var(--serif); }
.sep { color: rgba(230,216,182,0.5); }
#page-total { color: rgba(230,216,182,0.6); font-family: var(--serif); }
.page-name {
font-size: 14px; color: var(--paper); letter-spacing: 0.05em; margin-left: 10px;
- flex: 0 0 160px; /* 固定宽度,容纳多数词条名,避免翻页时布局抖动 */
+ flex: 0 0 160px; /* 固定宽度,导航条不随词条名长短变化 */
text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
@@ -161,7 +157,7 @@ html, body {
}
.detail-card.hidden { opacity: 0; pointer-events: none; transform: translateY(14px); }
.detail-head { display: flex; justify-content: space-between; align-items: center; }
-.detail-no { font-family: var(--serif); font-size: 13px; letter-spacing: 0.14em; color: var(--ink-soft); }
+.detail-no { font-family: Consolas, "Courier New", "Lucida Console", monospace; font-size: 13px; letter-spacing: 0.02em; color: var(--ink-soft); }
.detail-cat {
font-size: 12px; padding: 2px 10px; border-radius: 2px;
background: var(--leather); color: var(--gold-soft); letter-spacing: 0.2em;
@@ -182,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 canvas { width: 100%; height: 170px; object-fit: contain; border-radius: 2px; opacity: 0.94; }
-/* 提示 */
+/* 提示(导航条下方) */
.hint {
- left: 26px; bottom: 26px; font-size: 12px; letter-spacing: 0.06em;
+ left: 50%; transform: translateX(-50%); bottom: 5px;
+ font-size: 12px; letter-spacing: 0.06em;
color: rgba(230,216,182,0.55);
+ white-space: nowrap;
}
@media (max-width: 760px) {
diff --git a/src/textures/pages.js b/src/textures/pages.js
index a7d4181..bfe467d 100644
--- a/src/textures/pages.js
+++ b/src/textures/pages.js
@@ -7,9 +7,6 @@ export const PAGE_W = 1.6; // 世界单位:页宽
export const PAGE_H = 2.24; // 世界单位:页高
export const TEX_W = 768;
export const TEX_H = 1075;
-// 输出纹理与绘制分辨率一致,保证文字清晰(显存靠懒加载 + 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_X = 0.065; // 孔中心相对页内缘(环前交点 0.085 − 页缘空隙 0.02)
export const HOLE_R = 0.029; // 孔半径 ≈5.5mm 直径(真实活页本孔径)
@@ -17,6 +14,7 @@ export const HOLE_R = 0.029; // 孔半径 ≈5.5mm 直径(真实活页
const SERIF = 'Georgia, "Noto Serif SC", serif'; // 正文与标题字体一致(时间轴字体)
const KAI = '"Kaiti SC","STKaiti","KaiTi","Noto Serif SC",serif';
const NUM = 'Georgia,"Times New Roman",serif';
+const CODE = 'Consolas, "Courier New", "Lucida Console", monospace'; // 编目号等宽字体
const TITLE_FONT = '"Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif'; // 与时间轴标题字体一致
// 版面边距(左边缘避开活页孔,右/上/下为常规边距)
@@ -69,28 +67,6 @@ export function makePaper(w, h, { tone = 0, ruled = false, margin = false, seed
ctx.fillRect(0, 0, w, h);
}
- // 烫金斑点:不规则金色斑点(淡、纯色、硬边)
- ctx.fillStyle = 'rgba(212, 168, 64, 0.5)';
- const goldBlob = (x, y, r) => {
- const pts = 8;
- ctx.beginPath();
- for (let p = 0; p < pts; p++) {
- const a = (p / pts) * Math.PI * 2;
- const rr = r * (0.55 + rand() * 0.9);
- const px = x + Math.cos(a) * rr;
- const py = y + Math.sin(a) * rr;
- if (p === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
- }
- ctx.closePath();
- ctx.fill();
- };
- for (let i = 0; i < 70; i++) {
- goldBlob(rand() * w, rand() * h, rand() * 8 + 3);
- }
- for (let i = 0; i < 320; i++) {
- goldBlob(rand() * w, rand() * h, rand() * 2.4 + 0.8);
- }
-
// 边缘做旧(暗角)
const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.4, w / 2, h / 2, Math.max(w, h) * 0.72);
edge.addColorStop(0, 'rgba(0,0,0,0)');
@@ -200,63 +176,56 @@ export function renderFront(entry) {
drawBorder(ctx, w, h);
drawHoles(ctx, w, h);
- // 页眉:编号(去掉右上角印章标记)
- text(ctx, '编 号 · ' + entry.id, CONTENT_L, 92, { font: NUM, size: 26, color: '#5a432c' });
+ // 页眉:编目号(档案式编号,Consolas 等宽字体)
+ text(ctx, 'NO.' + (entry.code || entry.id), CONTENT_L, 92, { font: CODE, size: 23, color: '#33271a', weight: '700' });
- // 顶部分隔线
- ctx.strokeStyle = 'rgba(90,61,43,0.4)';
+ // 顶部分隔线(网格)
+ ctx.strokeStyle = 'rgba(90,61,43,0.45)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(CONTENT_L, 160); ctx.lineTo(CONTENT_R, 160); ctx.stroke();
- // 名称(大字,居中,标题字体与时间轴一致)
+ // 名称(大字,居中)
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `700 ${fitFont(ctx, entry.name, CONTENT_W - 40, 36, 86, TITLE_FONT)}px ${TITLE_FONT}`;
ctx.fillStyle = '#33271a';
- ctx.fillText(entry.name, w / 2, 248);
- // 名称下划线装饰
- ctx.strokeStyle = info.color;
- ctx.lineWidth = 2.4;
- ctx.beginPath(); ctx.moveTo(w / 2 - 220, 314); ctx.lineTo(w / 2 + 220, 314); ctx.stroke();
- ctx.fillStyle = info.color;
- ctx.beginPath(); ctx.arc(w / 2, 314, 4, 0, Math.PI * 2); ctx.fill();
+ ctx.fillText(entry.name, w / 2, 210);
+
+ // —— 时间 / 地区(左对齐,字体同标题)——
+ const gridTop = 276, rowH = 64;
+ const gLeft = CONTENT_L, gRight = CONTENT_R;
+ const gBottom = gridTop + rowH * 2;
+
+ ctx.strokeStyle = 'rgba(120,95,60,0.5)';
+ ctx.lineWidth = 1;
+ for (let r = 0; r <= 2; r++) {
+ const y = gridTop + r * rowH;
+ ctx.beginPath(); ctx.moveTo(gLeft, y); ctx.lineTo(gRight, y); ctx.stroke();
+ }
- // 登记字段
- const labelX = CONTENT_L + 56; // 标签右对齐
- const colonX = CONTENT_L + 64; // 冒号
- const valX = CONTENT_L + 82; // 值起始
- const fy = 372, rowH = 62;
const fields = [
- ['时间', entry.time],
- ['地区', entry.region],
+ ['时 间', entry.time],
+ ['地 区', entry.region],
];
fields.forEach(([label, value], i) => {
- const y = fy + i * rowH;
- text(ctx, label, labelX, y, { font: SERIF, size: 26, color: '#7a5c38', align: 'right' });
- text(ctx, ':', colonX, y, { font: SERIF, size: 26, color: '#7a5c38' });
- text(ctx, value, valX, y, { font: SERIF, size: 26, color: '#33271a' });
- // 值后虚线延伸至右界
- const vw = ctx.measureText(value).width;
- ctx.strokeStyle = 'rgba(120,95,60,0.35)';
- ctx.lineWidth = 0.9;
- ctx.setLineDash([2, 5]);
- ctx.beginPath(); ctx.moveTo(valX + vw + 14, y + 12); ctx.lineTo(CONTENT_R, y + 12); ctx.stroke();
- ctx.setLineDash([]);
+ const cy = gridTop + i * rowH + rowH / 2;
+ text(ctx, label, gLeft, cy, { font: TITLE_FONT, size: 26, color: '#7a5c38', baseline: 'middle' });
+ text(ctx, value, gLeft + 96, cy, { font: TITLE_FONT, size: 26, color: '#33271a', baseline: 'middle' });
});
- // 描述区
- const dy = 600;
- text(ctx, '描 述', CONTENT_L, dy, { font: SERIF, size: 26, color: '#7a5c38' });
- ctx.strokeStyle = 'rgba(120,95,60,0.3)';
- ctx.lineWidth = 0.9;
- ctx.beginPath(); ctx.moveTo(CONTENT_L, dy + 18); ctx.lineTo(CONTENT_R, dy + 18); ctx.stroke();
+ // —— 描述区 ——
+ const dy = 482;
+ text(ctx, '描 述', CONTENT_L, dy, { font: TITLE_FONT, size: 26, color: '#7a5c38' });
+ ctx.strokeStyle = 'rgba(120,95,60,0.45)';
+ ctx.lineWidth = 1;
+ ctx.beginPath(); ctx.moveTo(CONTENT_L, dy + 22); ctx.lineTo(CONTENT_R, dy + 22); ctx.stroke();
- ctx.font = `400 24px ${SERIF}`;
+ ctx.font = `400 24px ${TITLE_FONT}`;
ctx.fillStyle = '#33271a';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const maxW = CONTENT_W;
- let line = '', yy = dy + 40;
+ let line = '', yy = dy + 44;
for (const ch of entry.description) {
const test = line + ch;
if (ctx.measureText(test).width > maxW && line) {
@@ -334,7 +303,9 @@ let placeholder = null;
function makeTexture(canvas) {
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
- tex.anisotropy = 8; // 保持倾斜视角下的清晰度
+ tex.anisotropy = 4; // 倾斜视角已够用,降低采样带宽
+ tex.generateMipmaps = false; // 页面距离变化小,关 mipmap 省 ~33% 显存
+ tex.minFilter = THREE.LinearFilter;
return tex;
}
@@ -349,32 +320,36 @@ function renderPlaceholder() {
export function getPlaceholderTextures() {
if (!placeholder) {
- const front = makeTexture(downscale(renderPlaceholder()));
- const back = makeTexture(downscale(renderPlaceholder()));
+ const front = makeTexture(renderPlaceholder());
+ const back = makeTexture(renderPlaceholder());
placeholder = { front, back };
}
return placeholder;
}
function generatePair(entry) {
- const front = makeTexture(downscale(renderFront(entry)));
- const back = makeTexture(downscale(renderBack(entry)));
+ const front = makeTexture(renderFront(entry));
+ const back = makeTexture(renderBack(entry));
return { front, back };
}
-function evictIfNeeded() {
- if (texCache.size <= MAX_CACHED_PAGES) return;
- const candidates = [...texCache.values()]
+function evictCache(cache, maxPages) {
+ if (cache.size <= maxPages) return;
+ const candidates = [...cache.values()]
.filter((r) => r.refs === 0)
.sort((a, b) => a.lastUsed - b.lastUsed);
- while (texCache.size > MAX_CACHED_PAGES && candidates.length) {
+ while (cache.size > maxPages && candidates.length) {
const r = candidates.shift();
r.front.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) {
let rec = texCache.get(entry.id);
if (!rec) {
@@ -392,6 +367,21 @@ export function acquirePageTextures(entry) {
return { front: rec.front, back: rec.back };
}
+// 预生成纹理(不改变引用计数,供翻页前预热用,避免翻页中途同步生成造成卡顿)
+export function prefetchPageTextures(entry) {
+ let rec = texCache.get(entry.id);
+ if (!rec) {
+ rec = { entry, front: null, back: null, refs: 0, lastUsed: 0 };
+ texCache.set(entry.id, rec);
+ }
+ if (!rec.front) {
+ const pair = generatePair(entry);
+ rec.front = pair.front;
+ rec.back = pair.back;
+ evictIfNeeded();
+ }
+}
+
export function releasePageTextures(entry) {
const rec = texCache.get(entry.id);
if (!rec) return;
@@ -399,6 +389,63 @@ export function releasePageTextures(entry) {
if (rec.refs === 0) rec.lastUsed = performance.now();
}
+// —— 低分辨率纹理池(时间轴卡片用,显示尺寸小,省显存与上传带宽)——
+const TEX_LOW_W = 256;
+const TEX_LOW_H = 358;
+const MAX_CACHED_PAGES_LOW = 40;
+const texCacheLow = new Map();
+let placeholderLow = null;
+
+function downscaleLow(src) {
+ const c = document.createElement('canvas');
+ c.width = TEX_LOW_W;
+ c.height = TEX_LOW_H;
+ const ctx = c.getContext('2d');
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(src, 0, 0, TEX_LOW_W, TEX_LOW_H);
+ return c;
+}
+
+function generatePairLow(entry) {
+ const front = makeTexture(downscaleLow(renderFront(entry)));
+ const back = makeTexture(downscaleLow(renderBack(entry)));
+ return { front, back };
+}
+
+export function getPlaceholderTexturesLow() {
+ if (!placeholderLow) {
+ const front = makeTexture(downscaleLow(renderPlaceholder()));
+ const back = makeTexture(downscaleLow(renderPlaceholder()));
+ placeholderLow = { front, back };
+ }
+ return placeholderLow;
+}
+
+export function acquirePageTexturesLow(entry) {
+ let rec = texCacheLow.get(entry.id);
+ if (!rec) {
+ rec = { entry, front: null, back: null, refs: 0, lastUsed: 0 };
+ texCacheLow.set(entry.id, rec);
+ }
+ rec.refs++;
+ rec.lastUsed = performance.now();
+ if (!rec.front) {
+ const pair = generatePairLow(entry);
+ rec.front = pair.front;
+ rec.back = pair.back;
+ evictCache(texCacheLow, MAX_CACHED_PAGES_LOW);
+ }
+ return { front: rec.front, back: rec.back };
+}
+
+export function releasePageTexturesLow(entry) {
+ const rec = texCacheLow.get(entry.id);
+ if (!rec) return;
+ rec.refs = Math.max(0, rec.refs - 1);
+ if (rec.refs === 0) rec.lastUsed = performance.now();
+}
+
// 双面材质:同一 mesh 用 gl_FrontFacing 在正/反面显示不同纹理,
// 替代原先 front/back 两个 mesh,把每页 draw call 减半。
export function makeTwoSidedMaterial(front, back, {
diff --git a/src/textures/sketch.js b/src/textures/sketch.js
index 7e9d198..b267fad 100644
--- a/src/textures/sketch.js
+++ b/src/textures/sketch.js
@@ -581,68 +581,534 @@ export function renderSketch(entry, size = 760) {
},
book() {
- // 摊开的书
- fill([[-0.5, -0.1], [0.0, -0.2], [0.5, -0.1], [0.5, 0.4], [0.0, 0.5], [-0.5, 0.4]], 'rgba(210,190,150,0.18)');
- line([[-0.5, -0.1], [0.0, -0.2], [0.5, -0.1], [0.5, 0.4], [0.0, 0.5], [-0.5, 0.4], [-0.5, -0.1]], { lw: 2.4 });
- line([[0.0, -0.2], [0.0, 0.5]], { lw: 2 });
- for (let i = 0; i < 4; i++) {
- const y = -0.05 + i * 0.14;
- line([[-0.4, y], [-0.08, y]], { lw: 1.2, color: INK_SOFT, passes: 1, wobble: 0.004 });
- line([[0.08, y], [0.4, y]], { lw: 1.2, color: INK_SOFT, passes: 1, wobble: 0.004 });
+ // 封面(略大一圈,深色底)
+ fill([[-0.55, -0.28], [0.55, -0.28], [0.55, 0.5], [0.0, 0.6], [-0.55, 0.5]], 'rgba(120,80,50,0.10)');
+ line([[-0.55, -0.28], [0.55, -0.28], [0.55, 0.5], [0.0, 0.6], [-0.55, 0.5], [-0.55, -0.28]], { lw: 2.6 });
+ // 摊开的书页
+ fill([[-0.5, -0.2], [0.0, -0.3], [0.5, -0.2], [0.5, 0.42], [0.0, 0.52], [-0.5, 0.42]], 'rgba(210,190,150,0.22)');
+ line([[-0.5, -0.2], [0.0, -0.3], [0.5, -0.2], [0.5, 0.42], [0.0, 0.52], [-0.5, 0.42], [-0.5, -0.2]], { lw: 2.4 });
+ // 中缝与页缘叠层
+ line([[0.0, -0.3], [0.0, 0.52]], { lw: 1.8, color: INK_SOFT });
+ for (let i = 0; i < 3; i++) {
+ const o = i * 0.016;
+ line([[-0.5 + o, -0.2], [-0.5 + o, 0.42]], { lw: 1, color: INK_FAINT, passes: 1 });
+ line([[0.5 - o, -0.2], [0.5 - o, 0.42]], { lw: 1, color: INK_FAINT, passes: 1 });
}
- hatch([[-0.5, -0.1], [0.0, -0.2], [0.5, -0.1], [0.5, 0.4], [0.0, 0.5], [-0.5, 0.4]], { spacing: 0.04, angle: -45, color: INK_FAINT, lw: 1 });
+ // 左右页文字行
+ for (let i = 0; i < 6; i++) {
+ const y = -0.14 + i * 0.105;
+ line([[-0.42, y], [-0.08, y]], { lw: 1.3, color: INK_SOFT, passes: 1, wobble: 0.004 });
+ line([[0.08, y], [0.42, y]], { lw: 1.3, color: INK_SOFT, passes: 1, wobble: 0.004 });
+ }
+ // 右页小插图
+ circle(0.25, 0.12, 0.09, { lw: 1.4, passes: 1, color: INK_SOFT });
+ line([[0.18, 0.12], [0.32, 0.12]], { lw: 1, color: INK_FAINT, passes: 1 });
+ line([[0.25, 0.05], [0.25, 0.19]], { lw: 1, color: INK_FAINT, passes: 1 });
+ // 书签丝带
+ line([[0.0, 0.52], [0.06, 0.8]], { lw: 1.6, color: '#a03a2a' });
+ line([[0.06, 0.8], [0.0, 0.76], [0.09, 0.7]], { lw: 1.4, color: '#a03a2a', passes: 1 });
+ // 阴影排线
+ hatch([[-0.5, -0.2], [0.0, -0.3], [0.5, -0.2], [0.5, 0.42], [0.0, 0.52], [-0.5, 0.42]], { spacing: 0.045, angle: -45, color: INK_FAINT, lw: 1 });
},
bridge() {
- line([[-0.7, 0.45], [0.7, 0.45]], { lw: 1.6, color: INK_SOFT, passes: 1 });
- line([[-0.6, 0.6], [0.6, 0.6]], { lw: 1.2, color: INK_FAINT, passes: 1 });
- fill([[-0.5, 0.45], [-0.5, -0.05], [0.5, -0.05], [0.5, 0.45]], 'rgba(150,140,130,0.10)');
- line([[-0.5, 0.45], [-0.5, 0.05], [-0.35, -0.2], [0.0, -0.35], [0.35, -0.2], [0.5, 0.05], [0.5, 0.45]], { lw: 2.6 });
- line([[-0.55, 0.45], [-0.55, 0.28], [0.55, 0.28], [0.55, 0.45]], { lw: 1.6, color: INK_SOFT });
- for (let i = 0; i < 7; i++) {
- const x = -0.45 + i * 0.15;
- line([[x, 0.28], [x, 0.4]], { lw: 1.2, passes: 1, color: INK_SOFT });
+ // 水面波纹
+ line([[-0.72, 0.45], [0.72, 0.45]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ line([[-0.6, 0.56], [-0.3, 0.58], [0.0, 0.56], [0.3, 0.58], [0.6, 0.56]], { lw: 1.1, color: INK_FAINT, passes: 1, wobble: 0.01 });
+ // 桥体拱形
+ fill([[-0.5, 0.32], [-0.5, 0.06], [-0.34, -0.2], [0.0, -0.3], [0.34, -0.2], [0.5, 0.06], [0.5, 0.32]], 'rgba(150,140,130,0.12)');
+ line([[-0.5, 0.32], [-0.5, 0.06], [-0.34, -0.2], [0.0, -0.3], [0.34, -0.2], [0.5, 0.06], [0.5, 0.32]], { lw: 2.6 });
+ // 拱券石缝
+ for (let i = 1; i < 7; i++) {
+ const t = i / 7;
+ const x = -0.5 + t;
+ const y = 0.06 - Math.sin(Math.PI * t) * 0.36;
+ line([[x, y], [x, 0.32]], { lw: 1, color: INK_SOFT, passes: 1 });
}
- line([[-0.3, 0.52], [0.0, 0.56], [0.3, 0.52]], { lw: 1, color: INK_FAINT, passes: 1 });
+ // 桥面与栏杆
+ line([[-0.62, 0.32], [0.62, 0.32]], { lw: 2.6 });
+ line([[-0.64, 0.32], [-0.64, 0.2], [0.64, 0.2], [0.64, 0.32]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ for (let i = 0; i <= 10; i++) {
+ const x = -0.55 + i * 0.11;
+ line([[x, 0.2], [x, 0.29]], { lw: 1.4, passes: 1 });
+ }
+ line([[-0.6, 0.29], [0.6, 0.29]], { lw: 1.2, passes: 1, color: INK_SOFT });
+ // 倒影
+ line([[-0.4, 0.48], [0.0, 0.52], [0.4, 0.48]], { lw: 1.1, color: INK_FAINT, passes: 1, dash: [6, 5] });
+ // 两岸
+ line([[-0.74, 0.45], [-0.74, 0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[0.74, 0.45], [0.74, 0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ hatch([[-0.5, 0.32], [-0.5, 0.06], [-0.34, -0.2], [0.0, -0.3], [0.34, -0.2], [0.5, 0.06], [0.5, 0.32]], { spacing: 0.04, angle: -60, color: INK_FAINT, lw: 1 });
},
flag() {
- line([[-0.4, 0.6], [-0.4, -0.7]], { lw: 2.4 });
- fill([[-0.4, -0.65], [-0.1, -0.6], [0.05, -0.45], [-0.25, -0.5]], 'rgba(160,58,42,0.16)');
- line([[-0.4, -0.65], [-0.1, -0.6], [0.05, -0.45], [-0.25, -0.5], [-0.4, -0.65]], { lw: 2.2 });
- line([[-0.32, -0.55], [0.0, -0.48]], { lw: 1.4, color: INK_SOFT, passes: 1 });
- line([[-0.25, -0.5], [-0.15, -0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
- line([[-0.6, 0.62], [0.6, 0.62]], { lw: 1.6, color: INK_FAINT });
- for (let i = 0; i < 7; i++) {
- const a = -0.5 + i * 0.3;
- line([[0.05 + Math.cos(a) * 0.3, -0.3 + Math.sin(a) * 0.2], [0.05 + Math.cos(a) * 0.42, -0.3 + Math.sin(a) * 0.3]], { lw: 1.2, color: INK_FAINT, passes: 1 });
+ // 地面与旗杆
+ line([[-0.62, 0.6], [0.62, 0.6]], { lw: 1.6, color: INK_FAINT });
+ line([[-0.42, 0.6], [-0.42, -0.74]], { lw: 2.6 });
+ line([[-0.47, 0.6], [-0.47, -0.74]], { lw: 1.2, color: INK_SOFT, passes: 1 });
+ circle(-0.42, -0.77, 0.04, { lw: 1.8, passes: 1 });
+ // 飘动的旗面(S 形两褶)
+ fill([[-0.42, -0.7], [-0.1, -0.62], [0.18, -0.5], [0.04, -0.36], [-0.42, -0.42]], 'rgba(160,58,42,0.16)');
+ line([[-0.42, -0.7], [-0.1, -0.62], [0.18, -0.5], [0.04, -0.36], [-0.42, -0.42], [-0.42, -0.7]], { lw: 2.4 });
+ // 褶皱与星徽
+ line([[-0.42, -0.56], [-0.08, -0.5], [0.1, -0.42]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ dot(-0.2, -0.55, 0.035, '#a03a2a');
+ // 底部垂穗
+ line([[0.04, -0.36], [0.06, -0.27]], { lw: 1.2, color: INK_SOFT, passes: 1 });
+ line([[0.18, -0.5], [0.24, -0.43]], { lw: 1.2, color: INK_SOFT, passes: 1 });
+ // 旗面阴影
+ hatch([[-0.42, -0.7], [-0.1, -0.62], [0.18, -0.5], [0.04, -0.36], [-0.42, -0.42]], { spacing: 0.04, angle: -40, color: INK_FAINT, lw: 1 });
+ // 光芒
+ for (let i = 0; i < 6; i++) {
+ const a = -0.9 + i * 0.35;
+ line([[0.1 + Math.cos(a) * 0.28, -0.12 + Math.sin(a) * 0.18], [0.1 + Math.cos(a) * 0.4, -0.12 + Math.sin(a) * 0.26]], { lw: 1.2, color: INK_FAINT, passes: 1 });
}
},
scale() {
- line([[0.0, 0.6], [0.0, -0.15]], { lw: 2.4 });
- line([[-0.55, -0.05], [0.55, -0.05]], { lw: 2.2 });
- circle(0.0, -0.1, 0.06, { lw: 1.8, passes: 1 });
- line([[-0.55, -0.05], [-0.55, 0.2]], { lw: 1.6, passes: 1 });
- line([[0.55, -0.05], [0.55, 0.2]], { lw: 1.6, passes: 1 });
- line([[-0.72, 0.2], [-0.38, 0.2]], { lw: 2 });
- line([[0.38, 0.2], [0.72, 0.2]], { lw: 2 });
- line([[-0.7, 0.2], [-0.72, 0.34], [-0.38, 0.34], [-0.4, 0.2]], { lw: 1.6, color: INK_SOFT, passes: 1 });
- line([[0.38, 0.2], [0.4, 0.34], [0.7, 0.34], [0.72, 0.2]], { lw: 1.6, color: INK_SOFT, passes: 1 });
- line([[-0.3, 0.6], [0.3, 0.6]], { lw: 2.6 });
- line([[-0.2, 0.68], [0.2, 0.68]], { lw: 2 });
+ // 三级底座
+ line([[-0.34, 0.6], [0.34, 0.6]], { lw: 2.6 });
+ line([[-0.26, 0.66], [0.26, 0.66]], { lw: 2.2 });
+ line([[-0.18, 0.72], [0.18, 0.72]], { lw: 1.8, color: INK_SOFT });
+ // 立柱与柱头
+ line([[-0.05, 0.6], [-0.05, -0.06]], { lw: 2.4 });
+ line([[0.05, 0.6], [0.05, -0.06]], { lw: 1.2, color: INK_SOFT, passes: 1 });
+ circle(0.0, -0.09, 0.06, { lw: 2, passes: 1 });
+ // 横梁与支点
+ line([[-0.6, -0.02], [0.6, -0.02]], { lw: 2.4 });
+ line([[-0.6, -0.1], [0.6, -0.1]], { lw: 1.2, color: INK_SOFT, passes: 1 });
+ circle(0.0, -0.05, 0.07, { lw: 2, passes: 1 });
+ // 吊链(两段折线)
+ line([[-0.6, -0.02], [-0.6, 0.2]], { lw: 1.6, passes: 1 });
+ line([[-0.6, 0.2], [-0.45, 0.28]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[-0.45, 0.28], [-0.45, 0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[0.6, -0.02], [0.6, 0.2]], { lw: 1.6, passes: 1 });
+ line([[0.6, 0.2], [0.45, 0.28]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[0.45, 0.28], [0.45, 0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ // 左托盘
+ fill([[-0.78, 0.3], [-0.12, 0.3], [-0.2, 0.42], [-0.7, 0.42]], 'rgba(150,140,130,0.12)');
+ line([[-0.78, 0.3], [-0.12, 0.3]], { lw: 2.2 });
+ line([[-0.78, 0.3], [-0.7, 0.42], [-0.2, 0.42], [-0.12, 0.3]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ // 右托盘
+ fill([[0.12, 0.3], [0.78, 0.3], [0.7, 0.42], [0.2, 0.42]], 'rgba(150,140,130,0.12)');
+ line([[0.12, 0.3], [0.78, 0.3]], { lw: 2.2 });
+ line([[0.12, 0.3], [0.2, 0.42], [0.7, 0.42], [0.78, 0.3]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ // 托盘阴影
+ hatch([[-0.78, 0.3], [-0.12, 0.3], [-0.2, 0.42], [-0.7, 0.42]], { spacing: 0.04, angle: -30, color: INK_FAINT, lw: 1 });
+ hatch([[0.12, 0.3], [0.78, 0.3], [0.7, 0.42], [0.2, 0.42]], { spacing: 0.04, angle: -30, color: INK_FAINT, lw: 1 });
+ // 横梁装饰点
+ dot(-0.3, -0.05, 0.02, INK_SOFT);
+ dot(0.3, -0.05, 0.02, INK_SOFT);
},
sword() {
- line([[0.1, -0.6], [0.0, -0.05], [-0.02, 0.15]], { lw: 2.6 });
- line([[0.1, -0.6], [0.05, -0.05], [0.02, 0.15]], { lw: 1.4, color: INK_SOFT, passes: 1 });
- fill([[0.1, -0.6], [0.0, -0.05], [0.05, -0.05]], 'rgba(150,150,160,0.10)');
- line([[-0.25, 0.05], [0.22, -0.02]], { lw: 3 });
- line([[-0.02, 0.15], [0.05, 0.42]], { lw: 2.4 });
- circle(0.02, 0.45, 0.05, { lw: 1.6, passes: 1 });
- line([[-0.5, 0.6], [0.5, 0.6]], { lw: 1.4, color: INK_FAINT, passes: 1 });
- line([[0.15, -0.5], [0.28, -0.42]], { lw: 1.2, color: INK_FAINT, passes: 1 });
- line([[0.18, -0.3], [0.32, -0.22]], { lw: 1, color: INK_FAINT, passes: 1 });
+ // 盾牌(背景)
+ fill([[-0.45, -0.2], [0.45, -0.2], [0.55, 0.15], [0.45, 0.5], [0.0, 0.68], [-0.45, 0.5], [-0.55, 0.15]], 'rgba(120,95,70,0.10)');
+ line([[-0.45, -0.2], [0.45, -0.2], [0.55, 0.15], [0.45, 0.5], [0.0, 0.68], [-0.45, 0.5], [-0.55, 0.15], [-0.45, -0.2]], { lw: 2.4 });
+ // 盾面十字纹
+ line([[0.0, -0.2], [0.0, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[-0.35, 0.15], [0.35, 0.15]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ hatch([[-0.45, -0.2], [0.45, -0.2], [0.55, 0.15], [0.45, 0.5], [0.0, 0.68], [-0.45, 0.5], [-0.55, 0.15]], { spacing: 0.035, angle: 60, color: INK_FAINT, lw: 1 });
+ // 剑身(竖直插于盾前)
+ fill([[-0.1, -0.7], [0.1, -0.7], [0.1, 0.3], [-0.1, 0.3]], 'rgba(150,150,160,0.14)');
+ line([[-0.1, -0.7], [0.1, -0.7], [0.1, 0.3], [-0.1, 0.3], [-0.1, -0.7]], { lw: 2.6 });
+ line([[-0.1, -0.7], [0.0, -0.88], [0.1, -0.7]], { lw: 2.4 });
+ // 血槽与高光
+ line([[0.0, -0.6], [0.0, 0.2]], { lw: 1.2, color: INK_SOFT, passes: 1 });
+ line([[-0.04, -0.5], [-0.04, 0.1]], { lw: 1, color: INK_FAINT, passes: 1 });
+ // 护手
+ line([[-0.32, 0.3], [0.32, 0.3]], { lw: 3 });
+ // 柄与缠绕纹
+ fill([[-0.05, 0.3], [0.05, 0.3], [0.05, 0.52], [-0.05, 0.52]], 'rgba(90,65,45,0.12)');
+ line([[-0.05, 0.3], [0.05, 0.3], [0.05, 0.52], [-0.05, 0.52]], { lw: 2.2 });
+ for (let i = 0; i < 4; i++) {
+ const y = 0.34 + i * 0.05;
+ line([[-0.05, y], [0.05, y - 0.025]], { lw: 1, color: INK_SOFT, passes: 1 });
+ }
+ // 柄首
+ circle(0.0, 0.56, 0.05, { lw: 2, passes: 1 });
+ dot(0.0, 0.56, 0.02, '#a03a2a');
+ // 地面与星点
+ line([[-0.6, 0.72], [0.6, 0.72]], { lw: 1.4, color: INK_FAINT, passes: 1 });
+ dot(-0.62, -0.3, 0.02, INK_FAINT);
+ dot(0.62, -0.3, 0.02, INK_FAINT);
+ },
+
+ helmet() {
+ fill([[-0.4, 0.2], [0.4, 0.2], [0.35, -0.15], [0.2, -0.45], [-0.2, -0.45], [-0.35, -0.15]], 'rgba(150,150,160,0.12)');
+ line([[-0.4, 0.2], [0.4, 0.2], [0.35, -0.15], [0.2, -0.45], [-0.2, -0.45], [-0.35, -0.15], [-0.4, 0.2]], { lw: 2.6 });
+ circle(0.0, -0.45, 0.07, { lw: 2, passes: 1 });
+ line([[0.0, -0.5], [0.02, -0.78], [0.14, -0.74], [0.02, -0.68]], { lw: 1.8, passes: 1 });
+ line([[-0.4, 0.2], [-0.5, 0.5]], { lw: 2 });
+ line([[0.4, 0.2], [0.5, 0.5]], { lw: 2 });
+ line([[-0.5, 0.5], [-0.3, 0.5]], { lw: 1.6, color: INK_SOFT });
+ line([[0.5, 0.5], [0.3, 0.5]], { lw: 1.6, color: INK_SOFT });
+ line([[-0.26, 0.1], [-0.08, 0.16]], { lw: 1.8 });
+ line([[0.08, 0.16], [0.26, 0.1]], { lw: 1.8 });
+ hatch([[-0.4, 0.2], [0.4, 0.2], [0.35, -0.15], [0.2, -0.45], [-0.2, -0.45], [-0.35, -0.15]], { spacing: 0.04, angle: -30, color: INK_FAINT, lw: 1 });
+ },
+
+ cannon() {
+ line([[-0.7, 0.5], [0.7, 0.5]], { lw: 1.6, color: INK_FAINT });
+ fill([[-0.45, -0.05], [0.45, -0.05], [0.5, 0.2], [-0.5, 0.2]], 'rgba(90,70,50,0.12)');
+ line([[-0.45, -0.05], [0.45, -0.05], [0.5, 0.2], [-0.5, 0.2], [-0.45, -0.05]], { lw: 2.6 });
+ line([[-0.4, 0.05], [0.4, 0.05]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ circle(-0.38, 0.2, 0.22, { lw: 2.4 });
+ circle(-0.38, 0.2, 0.05, { lw: 1.6, passes: 1 });
+ for (let i = 0; i < 6; i++) { const a = (i / 6) * Math.PI * 2; line([[-0.38 + Math.cos(a) * 0.05, 0.2 + Math.sin(a) * 0.05], [-0.38 + Math.cos(a) * 0.22, 0.2 + Math.sin(a) * 0.22]], { lw: 1.2, passes: 1 }); }
+ line([[0.0, -0.05], [0.0, -0.35]], { lw: 2 });
+ line([[0.0, -0.35], [0.18, -0.45], [0.0, -0.4]], { lw: 1.6, passes: 1 });
+ circle(0.55, 0.35, 0.09, { lw: 1.6, passes: 1, fill: 'rgba(150,150,160,0.10)' });
+ hatch([[-0.45, -0.05], [0.45, -0.05], [0.5, 0.2], [-0.5, 0.2]], { spacing: 0.035, angle: -55, color: INK_FAINT, lw: 1 });
+ },
+
+ fortress() {
+ line([[-0.7, 0.55], [0.7, 0.55]], { lw: 1.6, color: INK_FAINT });
+ fill([[-0.55, 0.55], [-0.55, -0.2], [0.55, -0.2], [0.55, 0.55]], 'rgba(150,140,130,0.10)');
+ line([[-0.55, 0.55], [-0.55, -0.2], [0.55, -0.2], [0.55, 0.55]], { lw: 2.4 });
+ line([[-0.55, 0.3], [0.55, 0.3]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ for (let x = -0.55; x <= 0.55; x += 0.1375) { line([[x, 0.3], [x + 0.07, 0.3], [x + 0.07, 0.42], [x, 0.42]], { lw: 1.4, passes: 1 }); }
+ for (let x = -0.5; x <= 0.5; x += 0.2) { line([[x, 0.42], [x, 0.72]], { lw: 1.8 }); }
+ line([[-0.56, 0.72], [-0.4, 0.72]], { lw: 1.8 });
+ line([[0.4, 0.72], [0.56, 0.72]], { lw: 1.8 });
+ line([[-0.4, -0.2], [-0.32, -0.42], [0.32, -0.42], [0.4, -0.2]], { lw: 1.8 });
+ line([[0.0, -0.42], [0.0, -0.2]], { lw: 1.2, color: INK_SOFT, passes: 1 });
+ hatch([[-0.55, 0.55], [-0.55, 0.3], [0.55, 0.3], [0.55, 0.55]], { spacing: 0.04, angle: 45, color: INK_FAINT, lw: 1 });
+ },
+
+ bow() {
+ line([[-0.6, 0.6], [0.6, 0.6]], { lw: 1.2, color: INK_FAINT, passes: 1 });
+ line([[-0.55, -0.5], [-0.25, -0.1], [0.0, 0.1], [0.25, -0.1], [0.55, -0.5]], { lw: 2.6 });
+ line([[-0.55, -0.5], [0.55, -0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[0.0, 0.02], [0.5, -0.32]], { lw: 2 });
+ line([[0.5, -0.32], [0.62, -0.42]], { lw: 1.8, passes: 1 });
+ line([[0.5, -0.32], [0.4, -0.42], [0.5, -0.44]], { lw: 1.6, passes: 1 });
+ dot(0.55, -0.5, 0.03, INK_SOFT);
+ dot(-0.55, -0.5, 0.03, INK_SOFT);
+ },
+
+ shield() {
+ fill([[-0.5, -0.5], [0.5, -0.5], [0.55, -0.1], [0.35, 0.4], [0.0, 0.6], [-0.35, 0.4], [-0.55, -0.1]], 'rgba(150,140,130,0.10)');
+ line([[-0.5, -0.5], [0.5, -0.5], [0.55, -0.1], [0.35, 0.4], [0.0, 0.6], [-0.35, 0.4], [-0.55, -0.1], [-0.5, -0.5]], { lw: 2.6 });
+ line([[-0.5, -0.5], [0.5, -0.5]], { lw: 2 });
+ circle(0.0, -0.05, 0.16, { lw: 2, passes: 1 });
+ dot(0.0, -0.05, 0.05, '#a03a2a');
+ line([[0.0, 0.35], [0.0, 0.55]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ hatch([[-0.5, -0.5], [0.5, -0.5], [0.55, -0.1], [0.35, 0.4], [0.0, 0.6], [-0.35, 0.4], [-0.55, -0.1]], { spacing: 0.04, angle: -45, color: INK_FAINT, lw: 1 });
+ },
+
+ throne() {
+ line([[-0.6, 0.55], [0.6, 0.55]], { lw: 1.6, color: INK_FAINT });
+ fill([[-0.4, 0.5], [-0.4, -0.4], [0.4, -0.4], [0.4, 0.5]], 'rgba(160,120,50,0.10)');
+ line([[-0.4, 0.5], [-0.4, -0.4], [0.4, -0.4], [0.4, 0.5]], { lw: 2.6 });
+ line([[-0.34, 0.5], [-0.28, 0.72], [0.28, 0.72], [0.34, 0.5]], { lw: 2.2 });
+ circle(0.0, 0.66, 0.09, { lw: 1.8, passes: 1 });
+ line([[-0.2, -0.4], [-0.2, -0.62]], { lw: 2 });
+ line([[0.2, -0.4], [0.2, -0.62]], { lw: 2 });
+ line([[-0.28, 0.1], [0.28, 0.1]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ hatch([[-0.4, 0.5], [-0.4, -0.4], [0.4, -0.4], [0.4, 0.5]], { spacing: 0.04, angle: -60, color: INK_FAINT, lw: 1 });
+ },
+
+ scepter() {
+ line([[-0.05, 0.6], [0.0, -0.6]], { lw: 2.6 });
+ line([[0.05, 0.6], [0.08, -0.6]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ circle(0.0, 0.62, 0.12, { lw: 2.2 });
+ line([[-0.08, 0.7], [-0.16, 0.78], [0.0, 0.82], [0.16, 0.78], [0.08, 0.7]], { lw: 1.8 });
+ dot(0.0, 0.78, 0.05, '#a03a2a');
+ circle(0.03, -0.62, 0.07, { lw: 1.8, passes: 1 });
+ for (let i = 0; i < 5; i++) { const y = -0.4 + i * 0.2; line([[-0.05, y], [0.08, y - 0.04]], { lw: 1.2, color: INK_SOFT, passes: 1 }); }
+ line([[-0.4, 0.75], [0.4, 0.75]], { lw: 1.2, color: INK_FAINT, passes: 1 });
+ },
+
+ palace() {
+ line([[-0.7, 0.5], [0.7, 0.5]], { lw: 1.6, color: INK_FAINT });
+ fill([[-0.55, 0.5], [-0.55, -0.25], [0.55, -0.25], [0.55, 0.5]], 'rgba(160,140,110,0.10)');
+ line([[-0.55, 0.5], [-0.55, -0.25], [0.55, -0.25], [0.55, 0.5]], { lw: 2.4 });
+ line([[-0.55, 0.12], [0.55, 0.12]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ for (let x = -0.5; x <= 0.5; x += 0.2) { line([[x, -0.25], [x, 0.12]], { lw: 1.8 }); }
+ line([[-0.62, 0.12], [-0.55, 0.4], [-0.4, 0.12]], { lw: 2 });
+ line([[-0.3, 0.12], [-0.18, 0.4], [0.0, 0.12]], { lw: 2 });
+ line([[0.1, 0.12], [0.28, 0.42], [0.42, 0.12]], { lw: 2 });
+ line([[0.5, 0.12], [0.62, 0.4], [0.7, 0.12]], { lw: 2 });
+ line([[-0.32, -0.25], [-0.28, -0.5], [0.28, -0.5], [0.32, -0.25]], { lw: 1.8 });
+ hatch([[-0.55, 0.5], [-0.55, 0.12], [0.55, 0.12], [0.55, 0.5]], { spacing: 0.04, angle: 45, color: INK_FAINT, lw: 1 });
+ },
+
+ laurel() {
+ circle(0, 0, 0.55, { lw: 2.4 });
+ circle(0, 0, 0.42, { lw: 1.2, color: INK_FAINT, passes: 1 });
+ for (let i = 0; i < 16; i++) {
+ const a = (i / 16) * Math.PI * 2;
+ const x = Math.cos(a), y = Math.sin(a);
+ const px = x * 0.48, py = y * 0.48;
+ line([[px, py], [px - y * 0.1 - x * 0.06, py + x * 0.1 - y * 0.06]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ }
+ dot(0, 0.42, 0.03, '#a03a2a');
+ },
+
+ gavel() {
+ line([[-0.6, 0.4], [0.6, 0.4]], { lw: 1.4, color: INK_FAINT, passes: 1 });
+ fill([[-0.4, 0.1], [0.4, 0.1], [0.42, 0.28], [-0.42, 0.28]], 'rgba(120,90,60,0.14)');
+ line([[-0.4, 0.1], [0.4, 0.1], [0.42, 0.28], [-0.42, 0.28], [-0.4, 0.1]], { lw: 2.6 });
+ line([[-0.32, 0.16], [0.32, 0.16]], { lw: 1.2, color: INK_SOFT, passes: 1 });
+ line([[-0.05, 0.28], [0.0, 0.58]], { lw: 2.2 });
+ circle(0.0, 0.62, 0.05, { lw: 1.6, passes: 1 });
+ fill([[-0.6, 0.4], [0.6, 0.4], [0.55, 0.5], [-0.55, 0.5]], 'rgba(150,140,130,0.10)');
+ line([[-0.6, 0.4], [0.6, 0.4], [0.55, 0.5], [-0.55, 0.5], [-0.6, 0.4]], { lw: 1.8 });
+ hatch([[-0.4, 0.1], [0.4, 0.1], [0.42, 0.28], [-0.42, 0.28]], { spacing: 0.04, angle: -30, color: INK_FAINT, lw: 1 });
+ },
+
+ columns() {
+ line([[-0.7, 0.55], [0.7, 0.55]], { lw: 2 });
+ line([[-0.66, 0.62], [0.66, 0.62]], { lw: 1.8 });
+ line([[-0.7, -0.5], [0.7, -0.5]], { lw: 2 });
+ line([[-0.66, -0.57], [0.66, -0.57]], { lw: 1.8 });
+ for (let x = -0.55; x <= 0.55; x += 0.22) {
+ line([[x, 0.55], [x, -0.5]], { lw: 2 });
+ line([[x - 0.02, 0.55], [x - 0.02, -0.5]], { lw: 1, color: INK_SOFT, passes: 1 });
+ line([[x - 0.09, 0.42], [x + 0.09, 0.42]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[x - 0.09, -0.38], [x + 0.09, -0.38]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ }
+ },
+
+ document() {
+ fill([[-0.45, -0.55], [0.45, -0.55], [0.5, -0.2], [0.45, 0.5], [-0.45, 0.5], [-0.5, -0.2]], 'rgba(210,190,150,0.20)');
+ line([[-0.45, -0.55], [0.45, -0.55], [0.5, -0.2], [0.45, 0.5], [-0.45, 0.5], [-0.5, -0.2], [-0.45, -0.55]], { lw: 2.4 });
+ for (let i = 0; i < 6; i++) { const y = -0.4 + i * 0.15; line([[-0.34, y], [0.34, y]], { lw: 1.3, color: INK_SOFT, passes: 1, wobble: 0.004 }); }
+ line([[0.05, 0.0], [0.2, 0.1], [0.05, 0.2], [0.05, 0.0]], { lw: 1.6, passes: 1 });
+ dot(0.05, 0.1, 0.03, '#a03a2a');
+ line([[-0.05, 0.5], [0.0, 0.72]], { lw: 1.6, color: '#a03a2a' });
+ line([[0.0, 0.72], [-0.06, 0.68], [0.04, 0.64]], { lw: 1.4, color: '#a03a2a', passes: 1 });
+ hatch([[-0.45, -0.55], [0.45, -0.55], [0.5, -0.2], [0.45, 0.5], [-0.45, 0.5], [-0.5, -0.2]], { spacing: 0.045, angle: -40, color: INK_FAINT, lw: 1 });
+ },
+
+ gear() {
+ circle(0, 0, 0.5, { lw: 2.6 });
+ circle(0, 0, 0.32, { lw: 2 });
+ circle(0, 0, 0.12, { lw: 1.8, passes: 1 });
+ for (let i = 0; i < 12; i++) {
+ const a = (i / 12) * Math.PI * 2;
+ const x1 = Math.cos(a) * 0.5, y1 = Math.sin(a) * 0.5;
+ const x2 = Math.cos(a) * 0.62, y2 = Math.sin(a) * 0.62;
+ line([[x1, y1], [x2, y2]], { lw: 1.8, passes: 1 });
+ }
+ for (let i = 0; i < 6; i++) { const a = (i / 6) * Math.PI * 2 + 0.3; line([[Math.cos(a) * 0.12, Math.sin(a) * 0.12], [Math.cos(a) * 0.32, Math.sin(a) * 0.32]], { lw: 1.2, color: INK_SOFT, passes: 1 }); }
+ hatch([[Math.cos(0) * 0.32, Math.sin(0) * 0.32], [Math.cos(2) * 0.32, Math.sin(2) * 0.32], [Math.cos(4) * 0.32, Math.sin(4) * 0.32]], { spacing: 0.03, angle: -45, color: INK_FAINT, lw: 1 });
+ },
+
+ anvil() {
+ line([[-0.6, 0.5], [0.6, 0.5]], { lw: 1.6, color: INK_FAINT });
+ fill([[-0.4, 0.5], [-0.4, 0.15], [-0.25, -0.05], [0.25, -0.05], [0.4, 0.15], [0.4, 0.5]], 'rgba(90,70,50,0.14)');
+ line([[-0.4, 0.5], [-0.4, 0.15], [-0.25, -0.05], [0.25, -0.05], [0.4, 0.15], [0.4, 0.5]], { lw: 2.4 });
+ line([[-0.28, 0.5], [-0.28, 0.2], [0.28, 0.2], [0.28, 0.5]], { lw: 2 });
+ line([[-0.2, 0.5], [-0.2, 0.28], [0.2, 0.28], [0.2, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[-0.05, 0.28], [0.0, 0.72]], { lw: 2.2 });
+ line([[0.0, 0.72], [0.1, 0.62], [-0.05, 0.55]], { lw: 1.6, passes: 1 });
+ hatch([[-0.4, 0.5], [-0.4, 0.15], [-0.25, -0.05], [0.25, -0.05], [0.4, 0.15], [0.4, 0.5]], { spacing: 0.04, angle: 45, color: INK_FAINT, lw: 1 });
+ },
+
+ locomotive() {
+ line([[-0.7, 0.55], [0.7, 0.55]], { lw: 1.6, color: INK_FAINT });
+ fill([[-0.6, 0.1], [0.35, 0.1], [0.4, 0.35], [-0.55, 0.35]], 'rgba(90,70,50,0.14)');
+ line([[-0.6, 0.1], [0.35, 0.1], [0.4, 0.35], [-0.55, 0.35], [-0.6, 0.1]], { lw: 2.4 });
+ fill([[-0.3, 0.1], [-0.1, 0.1], [-0.1, -0.2], [-0.3, -0.2]], 'rgba(120,90,60,0.12)');
+ line([[-0.3, 0.1], [-0.1, 0.1], [-0.1, -0.2], [-0.3, -0.2], [-0.3, 0.1]], { lw: 2 });
+ line([[-0.2, -0.2], [-0.2, -0.42]], { lw: 1.8 });
+ circle(-0.3, 0.4, 0.08, { lw: 1.6, passes: 1 });
+ circle(0.15, 0.4, 0.08, { lw: 1.6, passes: 1 });
+ circle(0.55, 0.35, 0.28, { lw: 2.2 });
+ circle(0.55, 0.35, 0.06, { lw: 1.4, passes: 1 });
+ line([[-0.5, 0.1], [-0.5, 0.0]], { lw: 2 });
+ line([[0.28, 0.1], [0.28, 0.0]], { lw: 2 });
+ line([[-0.1, 0.28], [-0.02, 0.34], [-0.1, 0.36], [-0.16, 0.28]], { lw: 1.4, passes: 1 });
+ hatch([[-0.6, 0.1], [0.35, 0.1], [0.4, 0.35], [-0.55, 0.35]], { spacing: 0.04, angle: -55, color: INK_FAINT, lw: 1 });
+ },
+
+ factory() {
+ line([[-0.7, 0.5], [0.7, 0.5]], { lw: 1.6, color: INK_FAINT });
+ fill([[-0.55, 0.5], [-0.55, -0.3], [0.55, -0.3], [0.55, 0.5]], 'rgba(120,110,100,0.10)');
+ line([[-0.55, 0.5], [-0.55, -0.3], [0.55, -0.3], [0.55, 0.5]], { lw: 2.4 });
+ line([[-0.5, 0.5], [-0.5, -0.15]], { lw: 2 });
+ line([[-0.15, 0.5], [-0.15, -0.2]], { lw: 2 });
+ line([[0.2, 0.5], [0.2, -0.15]], { lw: 2 });
+ for (const x of [-0.4, 0.05, 0.35]) { line([[x, 0.1], [x - 0.1, 0.1], [x - 0.06, -0.3], [x + 0.06, -0.3], [x + 0.1, 0.1]], { lw: 1.6, passes: 1 }); }
+ for (let i = 0; i < 5; i++) { const a = -0.35 + i * 0.2; line([[a + 0.02, 0.3], [a, 0.12], [a + 0.06, -0.02], [a, -0.16]], { lw: 1.2, color: INK_SOFT, passes: 1 }); }
+ hatch([[-0.55, 0.5], [-0.55, -0.3], [0.55, -0.3], [0.55, 0.5]], { spacing: 0.04, angle: 45, color: INK_FAINT, lw: 1 });
+ },
+
+ microscope() {
+ line([[-0.5, 0.6], [0.5, 0.6]], { lw: 1.6, color: INK_FAINT });
+ line([[-0.15, -0.35], [-0.15, -0.2], [-0.5, -0.2]], { lw: 2.2 });
+ line([[-0.35, -0.35], [-0.35, 0.0]], { lw: 2.2 });
+ line([[-0.55, 0.0], [-0.35, 0.0]], { lw: 2.2 });
+ line([[-0.6, 0.0], [-0.6, -0.55]], { lw: 2.2 });
+ line([[-0.6, -0.55], [-0.05, -0.55]], { lw: 2.2 });
+ line([[-0.05, -0.55], [0.15, -0.55]], { lw: 2.2 });
+ circle(-0.12, -0.05, 0.18, { lw: 2 });
+ line([[-0.12, -0.2], [0.2, -0.5]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ fill([[-0.18, 0.3], [-0.06, 0.3], [-0.06, 0.1], [-0.18, 0.1]], 'rgba(150,150,160,0.10)');
+ line([[-0.18, 0.3], [-0.06, 0.3], [-0.06, 0.1], [-0.18, 0.1], [-0.18, 0.3]], { lw: 1.6, passes: 1 });
+ dot(-0.12, 0.2, 0.03, '#a03a2a');
+ },
+
+ telescope() {
+ line([[-0.4, 0.5], [0.0, 0.5], [0.0, 0.6]], { lw: 1.6, color: INK_FAINT });
+ fill([[-0.3, -0.3], [0.3, -0.3], [0.38, 0.05], [-0.38, 0.05]], 'rgba(150,150,160,0.12)');
+ line([[-0.3, -0.3], [0.3, -0.3], [0.38, 0.05], [-0.38, 0.05], [-0.3, -0.3]], { lw: 2.4 });
+ line([[-0.42, -0.28], [-0.32, -0.5], [-0.5, -0.5]], { lw: 2 });
+ line([[-0.42, -0.28], [-0.3, -0.5], [-0.5, -0.5]], { lw: 2 });
+ circle(0.32, -0.28, 0.1, { lw: 1.8, passes: 1 });
+ circle(-0.32, -0.28, 0.1, { lw: 1.8, passes: 1 });
+ line([[-0.35, -0.1], [0.35, -0.1]], { lw: 1.2, color: INK_SOFT, passes: 1 });
+ line([[0.0, -0.5], [0.0, 0.5]], { lw: 2 });
+ line([[-0.5, -0.5], [0.5, -0.5]], { lw: 2 });
+ line([[-0.4, -0.5], [-0.4, -0.7]], { lw: 1.8 });
+ line([[0.4, -0.5], [0.4, -0.7]], { lw: 1.8 });
+ hatch([[-0.3, -0.3], [0.3, -0.3], [0.38, 0.05], [-0.38, 0.05]], { spacing: 0.04, angle: -50, color: INK_FAINT, lw: 1 });
+ },
+
+ flask() {
+ line([[-0.5, 0.55], [0.5, 0.55]], { lw: 1.4, color: INK_FAINT, passes: 1 });
+ line([[-0.1, 0.1], [-0.1, -0.3]], { lw: 2 });
+ line([[0.1, 0.1], [0.1, -0.3]], { lw: 2 });
+ fill([[-0.1, -0.3], [0.1, -0.3], [0.32, 0.28], [0.12, 0.42], [-0.12, 0.42], [-0.32, 0.28]], 'rgba(150,150,160,0.10)');
+ line([[-0.1, -0.3], [0.1, -0.3], [0.32, 0.28], [0.12, 0.42], [-0.12, 0.42], [-0.32, 0.28], [-0.1, -0.3]], { lw: 2.2 });
+ line([[-0.16, 0.34], [0.16, 0.34]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[0.05, -0.45], [0.15, -0.7]], { lw: 1.2, color: INK_FAINT, passes: 1, dash: [6, 5] });
+ dot(0.2, -0.74, 0.02, INK_FAINT);
+ hatch([[-0.1, -0.3], [0.1, -0.3], [0.32, 0.28], [0.12, 0.42], [-0.12, 0.42], [-0.32, 0.28]], { spacing: 0.04, angle: 30, color: INK_FAINT, lw: 1 });
+ },
+
+ lightbulb() {
+ fill([[0, -0.6], [0.28, -0.4], [0.34, -0.1], [0.22, 0.15], [0.0, 0.3], [-0.22, 0.15], [-0.34, -0.1], [-0.28, -0.4]], 'rgba(200,170,80,0.14)');
+ line([[0, -0.6], [0.28, -0.4], [0.34, -0.1], [0.22, 0.15], [0.0, 0.3], [-0.22, 0.15], [-0.34, -0.1], [-0.28, -0.4], [0, -0.6]], { lw: 2.4 });
+ line([[0, -0.6], [0.02, -0.85]], { lw: 1.8 });
+ line([[-0.16, 0.12], [0.16, 0.12]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ line([[-0.1, 0.24], [0.1, 0.24]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ line([[-0.2, 0.36], [0.2, 0.36]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ for (let i = 0; i < 6; i++) { const a = -1.2 + i * 0.4; line([[Math.cos(a) * 0.4, Math.sin(a) * 0.4], [Math.cos(a) * 0.52, Math.sin(a) * 0.52]], { lw: 1.2, color: INK_FAINT, passes: 1 }); }
+ },
+
+ dinosaur() {
+ line([[-0.6, 0.6], [0.6, 0.6]], { lw: 1.4, color: INK_FAINT, passes: 1 });
+ line([[-0.55, 0.35], [-0.45, 0.1], [-0.3, -0.1], [-0.1, -0.2], [0.1, -0.2], [0.3, -0.1], [0.42, 0.0], [0.5, 0.2], [0.52, 0.4]], { lw: 2.4 });
+ line([[-0.45, 0.1], [-0.6, 0.0], [-0.65, 0.2]], { lw: 2 });
+ line([[0.42, 0.0], [0.55, -0.1], [0.58, 0.1]], { lw: 2 });
+ line([[-0.3, -0.1], [-0.32, 0.3]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ line([[-0.1, -0.2], [-0.1, 0.25]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ line([[0.1, -0.2], [0.12, 0.25]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ line([[0.3, -0.1], [0.32, 0.25]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ line([[-0.15, -0.16], [0.05, -0.08], [0.15, -0.16]], { lw: 1.4, passes: 1 });
+ line([[0.45, 0.3], [0.35, 0.42], [0.5, 0.5]], { lw: 1.6, color: INK_SOFT, passes: 1 });
+ },
+
+ flower() {
+ circle(0, 0, 0.12, { lw: 2, passes: 1 });
+ for (let i = 0; i < 6; i++) {
+ const a = (i / 6) * Math.PI * 2 + 0.3;
+ const x = Math.cos(a) * 0.24, y = Math.sin(a) * 0.24;
+ fill([[x - 0.05, y - 0.05], [x + 0.16, y - 0.02], [x + 0.2, y + 0.08], [x + 0.02, y + 0.18], [x - 0.12, y + 0.14], [x - 0.1, y + 0.02]], 'rgba(150,60,40,0.10)');
+ line([[x - 0.05, y - 0.05], [x + 0.16, y - 0.02], [x + 0.2, y + 0.08], [x + 0.02, y + 0.18], [x - 0.12, y + 0.14], [x - 0.1, y + 0.02], [x - 0.05, y - 0.05]], { lw: 1.8 });
+ }
+ line([[0.0, 0.1], [0.0, 0.75]], { lw: 2 });
+ fill([[0.0, 0.4], [0.18, 0.5], [0.1, 0.62]], 'rgba(60,90,50,0.16)');
+ line([[0.0, 0.4], [0.18, 0.5], [0.1, 0.62], [0.0, 0.4]], { lw: 1.6 });
+ dot(0.0, 0.0, 0.04, '#c9a227');
+ },
+
+ mountain() {
+ line([[-0.7, 0.45], [0.7, 0.45]], { lw: 1.8 });
+ fill([[-0.6, 0.45], [-0.15, -0.5], [0.1, -0.2], [0.6, 0.45]], 'rgba(150,140,130,0.10)');
+ line([[-0.6, 0.45], [-0.15, -0.5], [0.1, -0.2], [0.6, 0.45]], { lw: 2.4 });
+ line([[-0.15, -0.5], [-0.15, -0.6]], { lw: 2 });
+ line([[-0.6, 0.45], [-0.35, 0.05], [-0.15, -0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[-0.15, -0.5], [0.0, -0.15], [0.1, -0.2]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[0.1, -0.2], [0.3, 0.05], [0.6, 0.45]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ circle(0.55, -0.5, 0.14, { lw: 2, passes: 1 });
+ line([[-0.55, 0.6], [-0.1, 0.6]], { lw: 1.2, color: INK_FAINT, passes: 1 });
+ },
+
+ mask() {
+ circle(0, 0, 0.6, { lw: 2.4 });
+ circle(-0.14, -0.1, 0.12, { lw: 1.8, passes: 1 });
+ circle(0.14, -0.1, 0.12, { lw: 1.8, passes: 1 });
+ line([[-0.32, -0.3], [-0.05, -0.18], [-0.05, -0.5], [-0.18, -0.5], [-0.18, -0.18]], { lw: 1.8 });
+ line([[0.32, -0.3], [0.05, -0.18], [0.05, -0.5], [0.18, -0.5], [0.18, -0.18]], { lw: 1.8 });
+ line([[-0.2, 0.25], [0.2, 0.25]], { lw: 2, passes: 1 });
+ line([[-0.1, 0.25], [0.0, 0.4], [0.1, 0.25]], { lw: 1.8, passes: 1 });
+ line([[-0.3, 0.42], [-0.5, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[0.3, 0.42], [0.5, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ hatch([[Math.cos(0.8) * 0.6, Math.sin(0.8) * 0.6], [Math.cos(2.3) * 0.6, Math.sin(2.3) * 0.6], [Math.cos(4) * 0.6, Math.sin(4) * 0.6]], { spacing: 0.04, angle: 45, color: INK_FAINT, lw: 1 });
+ },
+
+ lyre() {
+ line([[-0.5, 0.5], [-0.3, -0.1], [-0.1, -0.5]], { lw: 2.2 });
+ line([[0.5, 0.5], [0.3, -0.1], [0.1, -0.5]], { lw: 2.2 });
+ line([[-0.5, 0.5], [0.5, 0.5]], { lw: 2.2 });
+ fill([[-0.3, -0.1], [0.3, -0.1], [0.15, 0.35], [-0.15, 0.35]], 'rgba(200,180,140,0.14)');
+ line([[-0.3, -0.1], [0.3, -0.1], [0.15, 0.35], [-0.15, 0.35], [-0.3, -0.1]], { lw: 1.8 });
+ for (let i = 0; i < 4; i++) { const y = -0.0 + i * 0.1; line([[-0.28 + i * 0.02, y], [0.28 - i * 0.02, y]], { lw: 1.2, color: INK_SOFT, passes: 1 }); }
+ line([[-0.1, -0.5], [0.0, -0.6], [0.1, -0.5]], { lw: 1.8 });
+ },
+
+ coin() {
+ circle(0, 0, 0.6, { lw: 2.6 });
+ circle(0, 0, 0.5, { lw: 1.4, color: INK_SOFT, passes: 1 });
+ circle(0, 0, 0.32, { lw: 1.8, passes: 1 });
+ // 侧面像(简化)
+ fill([[0.0, -0.2], [0.02, -0.02], [0.06, 0.1], [0.02, 0.2], [-0.08, 0.22], [-0.1, 0.1], [-0.08, -0.02], [-0.02, -0.2]], 'rgba(150,140,130,0.10)');
+ line([[0.0, -0.2], [0.02, -0.02], [0.06, 0.1], [0.02, 0.2], [-0.08, 0.22], [-0.1, 0.1], [-0.08, -0.02], [-0.02, -0.2], [0.0, -0.2]], { lw: 1.8 });
+ line([[-0.45, 0.05], [0.45, 0.05]], { lw: 1.2, color: INK_FAINT, passes: 1 });
+ line([[0.0, -0.5], [0.0, 0.5]], { lw: 1.2, color: INK_FAINT, passes: 1 });
+ },
+
+ key() {
+ circle(0.5, 0.2, 0.22, { lw: 2.4 });
+ circle(0.5, 0.2, 0.1, { lw: 1.6, passes: 1 });
+ line([[-0.55, -0.1], [0.28, 0.1]], { lw: 2.6 });
+ line([[0.28, 0.1], [0.5, 0.16]], { lw: 2.4 });
+ line([[-0.55, -0.1], [-0.55, 0.1]], { lw: 1.8 });
+ line([[-0.4, -0.05], [-0.4, 0.05]], { lw: 1.8 });
+ line([[-0.25, -0.02], [-0.25, 0.05]], { lw: 1.8 });
+ dot(-0.55, 0.0, 0.03, INK_SOFT);
+ },
+
+ hourglass() {
+ line([[-0.4, -0.6], [0.4, -0.6]], { lw: 2 });
+ line([[-0.4, 0.6], [0.4, 0.6]], { lw: 2 });
+ line([[-0.4, -0.6], [-0.4, 0.6]], { lw: 2.2 });
+ line([[0.4, -0.6], [0.4, 0.6]], { lw: 2.2 });
+ line([[-0.4, -0.5], [-0.1, 0.0], [-0.4, 0.5]], { lw: 2.2 });
+ line([[0.4, -0.5], [0.1, 0.0], [0.4, 0.5]], { lw: 2.2 });
+ fill([[-0.1, -0.1], [0.1, -0.1], [0.0, 0.3], [-0.1, 0.3]], 'rgba(160,120,50,0.14)');
+ line([[-0.1, -0.1], [0.1, -0.1], [0.1, 0.3], [-0.1, 0.3], [-0.1, -0.1]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ for (let i = 0; i < 5; i++) { dot(-0.03 + (i % 2) * 0.05, -0.05 + i * 0.07, 0.015, INK_SOFT); }
+ },
+
+ compass() {
+ circle(0, 0, 0.6, { lw: 2.6 });
+ circle(0, 0, 0.5, { lw: 1.2, color: INK_FAINT, passes: 1 });
+ line([[0, -0.5], [0, 0.5]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ line([[-0.5, 0], [0.5, 0]], { lw: 1.4, color: INK_SOFT, passes: 1 });
+ fill([[0, 0.35], [0.35, 0], [0, -0.2]], 'rgba(150,60,40,0.12)');
+ fill([[0, -0.35], [-0.35, 0], [0, 0.2]], 'rgba(150,150,160,0.12)');
+ line([[0, 0.35], [0.35, 0], [0, -0.2], [0, 0.35]], { lw: 1.8 });
+ line([[0, -0.35], [-0.35, 0], [0, 0.2], [0, -0.35]], { lw: 1.8 });
+ dot(0, 0, 0.05, '#a03a2a');
+ dot(0, 0.5, 0.03, INK_SOFT);
+ dot(0, -0.5, 0.03, INK_SOFT);
+ },
+
+ anchor() {
+ circle(0, -0.5, 0.1, { lw: 2.4, passes: 1 });
+ line([[0, -0.4], [0, 0.5]], { lw: 2.6 });
+ line([[-0.45, -0.2], [0.45, -0.2]], { lw: 2.4 });
+ line([[-0.45, -0.2], [-0.45, 0.1]], { lw: 2.2 });
+ line([[0.45, -0.2], [0.45, 0.1]], { lw: 2.2 });
+ line([[-0.5, 0.35], [0.0, 0.5], [0.5, 0.35]], { lw: 2.2 });
+ line([[-0.45, 0.1], [-0.62, 0.42]], { lw: 2 });
+ line([[0.45, 0.1], [0.62, 0.42]], { lw: 2 });
+ line([[-0.7, 0.5], [0.7, 0.5]], { lw: 1.2, color: INK_FAINT, passes: 1, wobble: 0.008 });
},
};
diff --git a/src/ui/loading.js b/src/ui/loading.js
new file mode 100644
index 0000000..5ca9291
--- /dev/null
+++ b/src/ui/loading.js
@@ -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);
+ },
+ };
+}
diff --git a/src/ui/overlay.js b/src/ui/overlay.js
index 432d3d6..af26bba 100644
--- a/src/ui/overlay.js
+++ b/src/ui/overlay.js
@@ -7,9 +7,12 @@ export class Overlay {
this.onPrev = onPrev;
this.onNext = onNext;
this.onFlip = onFlip;
+ this.view = 'binder';
+ this.globeReady = false;
this.els = {
iris: document.getElementById('iris'),
+ btnView: document.getElementById('btn-view'),
pageNum: document.getElementById('page-num'),
pageTotal: document.getElementById('page-total'),
pageName: document.getElementById('page-name'),
@@ -27,22 +30,31 @@ export class Overlay {
}
_wire() {
- document.querySelectorAll('.view-btn').forEach((btn) => {
- btn.addEventListener('click', () => this.onView(btn.dataset.view));
- });
+ this.els.btnView.addEventListener('click', () => this._cycleView());
document.getElementById('btn-prev').addEventListener('click', () => this.onPrev());
document.getElementById('btn-next').addEventListener('click', () => this.onNext());
document.getElementById('btn-flip')?.addEventListener('click', () => this.onFlip());
}
+ // 视图循环切换:活页本 → 时间轴 → 地理 → 活页本(地理未就绪时跳过)
+ _cycleView() {
+ const order = ['binder', 'timeline', 'globe'];
+ let next = order[(order.indexOf(this.view) + 1) % order.length];
+ if (next === 'globe' && !this.globeReady) next = 'binder';
+ this.onView(next);
+ }
+
+ setGlobeReady(ready) {
+ this.globeReady = ready;
+ }
+
setTotal(n) {
this.els.pageTotal.textContent = String(n).padStart(2, '0');
}
setView(view) {
- document.querySelectorAll('.view-btn').forEach((btn) => {
- btn.classList.toggle('active', btn.dataset.view === view);
- });
+ this.view = view;
+ this.els.btnView.dataset.view = view;
const HINTS = {
binder: '拖动旋转 · 滚轮缩放 · 点击书页选中 · ← → 翻页(翻面查看素描)',
timeline: '拖拽左右滑动 · 滚轮缩放 · 点击卡片查看详情',
@@ -52,15 +64,33 @@ export class Overlay {
}
setEntry(entry, index, total) {
+ this.els.detailCard.classList.remove('hidden');
this.els.pageNum.textContent = String(index + 1).padStart(2, '0');
this.els.pageName.textContent = entry.name;
- this.els.detailNo.textContent = entry.id;
+ this.els.detailNo.textContent = 'NO.' + (entry.code || entry.id);
this.els.detailName.textContent = entry.name;
this.els.detailTime.textContent = entry.time;
this.els.detailRegion.textContent = entry.region;
this.els.detailDesc.textContent = entry.description;
// 素描缩略图(等比放大填满,裁掉上下多余部分)
+ this._drawSketch(entry);
+ }
+
+ // END 页:展示最后一页背面(末页素描)与结束标记
+ setEnd(entry) {
+ this.els.detailCard.classList.remove('hidden');
+ this.els.pageNum.textContent = 'END';
+ this.els.pageName.textContent = '完 · 人类文明里程碑';
+ this.els.detailNo.textContent = 'END';
+ this.els.detailName.textContent = '完 · The End';
+ this.els.detailTime.textContent = '—';
+ this.els.detailRegion.textContent = '—';
+ this.els.detailDesc.textContent = '人类文明里程碑 · 活页编年史 · 全书完';
+ this._drawSketch(entry);
+ }
+
+ _drawSketch(entry) {
const c = this.els.detailSketch;
const ctx = c.getContext('2d');
ctx.clearRect(0, 0, c.width, c.height);