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);