commit 4e818d53e35f4c3b26ec35db9ab883ffcb64bc8f Author: fukunee Date: Fri Sep 18 19:19:53 2026 +0800 Add 3D loose-leaf chronicle of human civilisation A Three.js site presenting 20 world history milestones as double-sided loose-leaf pages, with three views: a leather binder with page-flip animation, a golden timeline, and a dotted globe with great-circle arcs. All textures (paper, leather, registration cards, and 18 hand-drawn sketches) are generated procedurally via Canvas 2D, so no external image assets are needed beyond the Earth map. Includes dev helper scripts for screenshots, ASCII previews, and headless verification. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..44503f4 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# 人类文明里程碑 · 活页编年史 + +一个以 **Three.js** 为核心、艺术化呈现世界历史里程碑的三维网站。它以一只**活页本**为隐喻,把历史中里程碑式的人、事、物登记成一张张两面活页:正面是登记卡(编号 / 时间 / 地区 / 名称 / 描述),背面是程序化生成的"铅笔素描"。 + +## 三种视图 + +| 视图 | 内容 | +| --- | --- | +| **活页本** | 皮革封面 + 金属活页环 + 20 张双面活页,翻页动画、纸张弯曲、堆叠阴影 | +| **时间轴** | 20 张活页沿金色时间轴一字排开,选中卡片上浮并显示年代 | +| **地理** | 点阵地球 + 经纬网 + 事件标记 + 相邻事件间的金色大圆航线,缓慢自转 | + +## 技术要点 + +- **Three.js**(ES 模块 + Vite)渲染全部三维内容,`OrbitControls` 供旋转/缩放。 +- **程序化纹理**:纸张、皮革、活页孔、登记卡、素描全部由 Canvas 2D 即时生成,无需外部图片。 +- **程序化素描**:18 种手绘风格速写(头骨、泥板、金字塔、人像、神庙、王冠、斗兽场、卷轴、骑士、帆船、地球仪、苹果、蒸汽机、自由钟、进化树、原子、和平鸽、火箭),带抖动线条与排线阴影,同一词条每次渲染结果确定一致。 +- **双面活页**:正面用 `FrontSide`、背面用 `BackSide`(内容预先水平镜像),配合纸张柔性弯曲,翻页时自然显露背面素描。 +- 数据(`src/data/entries.js`)包含 20 条真实历史里程碑,含经纬度与分类(人 / 事 / 物)。 + +## 运行 + +```bash +npm install +npm run dev # 开发服务器 +npm run build # 生产构建(dist/) +npm run preview # 预览生产构建 +``` + +## 操作 + +- **拖动** 旋转视角,**滚轮** 缩放 +- **← →** 或底部按钮 翻页(翻面查看背面素描) +- 点击 活页 / 时间轴卡片 / 地球标记 选中词条 +- 顶部按钮或按键 **1 / 2 / 3** 切换 活页本 / 时间轴 / 地理 视图 + +## 目录结构 + +``` +src/ + main.js 入口 + core/App.js 编排器:渲染、灯光、相机、视图切换、拾取 + scene/Binder.js 活页本(封面、环、双面活页、翻页动画) + scene/Timeline.js 时间轴视图 + scene/Globe.js 地理视图(点阵地球、弧线、标记) + textures/pages.js 纸张 + 登记卡/素描两面纹理 + textures/sketch.js 程序化素描生成器 + data/entries.js 历史词条数据 + data/worldMap.js 简化大陆轮廓 + ui/overlay.js HTML 覆盖层 + utils/ 缓动 / 文字精灵 +scripts/ 截图与验证辅助(开发用) +``` diff --git a/index.html b/index.html new file mode 100644 index 0000000..e968e15 --- /dev/null +++ b/index.html @@ -0,0 +1,72 @@ + + + + + + 人类文明里程碑 · 活页编年史 + + + + + + + +
+ + + + + + + + + + +
+

人类文明里程碑

+

活页编年史 · The Loose-leaf Chronicle of Civilisation

+
+ + + + + +
+ +
+ 01/20 + 露西 · 南方古猿 +
+ + +
+ + + + + +
拖动旋转 · 滚轮缩放 · 时间轴视图拖拽左右滑动 · 点击选中 · ← → 翻页
+
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c51885a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1525 @@ +{ + "name": "hist-leaves", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hist-leaves", + "version": "1.0.0", + "dependencies": { + "three": "^0.170.0" + }, + "devDependencies": { + "pngjs": "^7.0.0", + "puppeteer-core": "^25.11.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "3.2.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@puppeteer/browsers/-/browsers-3.2.2.tgz", + "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.8.4", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1680125", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/devtools-protocol/-/devtools-protocol-0.0.1680125.tgz", + "integrity": "sha512-rVARKAvOFJCFkvgX01VLvYqKIvt+cRCqvUxIhB2oAqVGPalxk3w3sb5TUGDagpow/EFblBpm/Zl/29QA2dd+jA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.8.5", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/modern-tar/-/modern-tar-0.8.5.tgz", + "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/puppeteer-core": { + "version": "25.11.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/puppeteer-core/-/puppeteer-core-25.11.0.tgz", + "integrity": "sha512-Ujym7JbxoAqanaxGcHgmTomfhC01HfDCSuGZ8Hs9boHrDjzbNVMnFRJo2KtbDT9LCRgeJoFnkn9JQZ41eEUrLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1680125", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.3", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/three": { + "version": "0.170.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", + "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..33e40fc --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "hist-leaves", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "人类文明里程碑 · 活页编年史 — 一个以活页本、时间轴与地理视图呈现世界历史的三维网站", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "three": "^0.170.0" + }, + "devDependencies": { + "pngjs": "^7.0.0", + "puppeteer-core": "^25.11.0", + "vite": "^6.0.0" + } +} diff --git a/scripts/analyze.mjs b/scripts/analyze.mjs new file mode 100644 index 0000000..325c29a --- /dev/null +++ b/scripts/analyze.mjs @@ -0,0 +1,24 @@ +import fs from 'node:fs'; +import { PNG } from 'pngjs'; + +for (const name of process.argv.slice(2)) { + const png = PNG.sync.read(fs.readFileSync(name)); + const { width: w, height: h, data } = png; + let sum = 0, bright = 0, n = 0; + // 分区域统计 + const zones = { center: 0, left: 0, right: 0 }; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = (y * w + x) * 4; + const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; + sum += lum; n++; + if (lum > 40) bright++; + if (lum > 40) { + if (x < w / 3) zones.left++; + else if (x > (2 * w) / 3) zones.right++; + else zones.center++; + } + } + } + console.log(`${name}: ${w}x${h} avgLum=${(sum / n).toFixed(1)} brightPct=${((bright / n) * 100).toFixed(1)}% left/center/right=${zones.left}/${zones.center}/${zones.right}`); +} diff --git a/scripts/ascii.mjs b/scripts/ascii.mjs new file mode 100644 index 0000000..460cbf9 --- /dev/null +++ b/scripts/ascii.mjs @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import { PNG } from 'pngjs'; + +// 以 ASCII 字符打印截图的亮度分布,粗略预览构图 +for (const name of process.argv.slice(2)) { + const png = PNG.sync.read(fs.readFileSync(name)); + const { width: w, height: h, data } = png; + const cols = 72, rows = 26; + const chars = ' .:-=+*#%@'; + console.log(`\n=== ${name} ===`); + for (let r = 0; r < rows; r++) { + let line = ''; + for (let c = 0; c < cols; c++) { + const x0 = Math.floor((c / cols) * w), x1 = Math.floor(((c + 1) / cols) * w); + const y0 = Math.floor((r / rows) * h), y1 = Math.floor(((r + 1) / rows) * h); + let sum = 0, n = 0; + for (let y = y0; y < y1; y += 3) { + for (let x = x0; x < x1; x += 3) { + const i = (y * w + x) * 4; + sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; + n++; + } + } + const lum = sum / n; + line += chars[Math.min(chars.length - 1, Math.floor((lum / 255) * chars.length))]; + } + console.log(line); + } +} diff --git a/scripts/colorascii.mjs b/scripts/colorascii.mjs new file mode 100644 index 0000000..2ecc104 --- /dev/null +++ b/scripts/colorascii.mjs @@ -0,0 +1,37 @@ +import fs from 'node:fs'; +import { PNG } from 'pngjs'; + +// 颜色分类预览:G=金, R=红, B=蓝, E=绿, P=纸(亮), .=暗 +// 用于检查地球上的弧线(G)与标记(R/B/E) +for (const name of process.argv.slice(2)) { + const png = PNG.sync.read(fs.readFileSync(name)); + const { width: w, height: h, data } = png; + const cols = 90, rows = 36; + console.log(`\n=== ${name} ===`); + for (let r = 0; r < rows; r++) { + let line = ''; + for (let c = 0; c < cols; c++) { + const x0 = Math.floor((c / cols) * w), x1 = Math.floor(((c + 1) / cols) * w); + const y0 = Math.floor((r / rows) * h), y1 = Math.floor(((r + 1) / rows) * h); + let R = 0, G = 0, B = 0, n = 0; + for (let y = y0; y < y1; y += 2) { + for (let x = x0; x < x1; x += 2) { + const i = (y * w + x) * 4; + R += data[i]; G += data[i + 1]; B += data[i + 2]; n++; + } + } + R /= n; G /= n; B /= n; + const lum = 0.299 * R + 0.587 * G + 0.114 * B; + let ch = '.'; + if (lum > 150) ch = 'P'; + else if (R > 150 && G > 110 && B < 120) ch = 'G'; // 金 + else if (R > 110 && G < 90 && B < 90) ch = 'R'; // 红 + else if (B > 90 && R < 90 && G < 110) ch = 'B'; // 蓝 + else if (G > 70 && R < 110 && B < 110) ch = 'E'; // 绿 + else if (lum > 60) ch = ':'; + else if (lum > 30) ch = '-'; + line += ch; + } + console.log(line); + } +} diff --git a/scripts/shot.mjs b/scripts/shot.mjs new file mode 100644 index 0000000..18853b0 --- /dev/null +++ b/scripts/shot.mjs @@ -0,0 +1,43 @@ +// 截图 + 控制台错误采集(开发辅助脚本) +import puppeteer from 'puppeteer-core'; + +const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; +const URL = process.argv[2] || 'http:/localhost:5199/'; +const OUT = process.argv[3] || 'shot'; + +const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: 'new', + args: ['--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--window-size=1440,900'], +}); + +const page = await browser.newPage(); +await page.setViewport({ width: 1440, height: 900 }); + +const logs = []; +page.on('console', (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on('pageerror', (e) => logs.push(`[pageerror] ${e.message}`)); + +await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); +await new Promise((r) => setTimeout(r, 6000)); + +// 依次截图三种视图 +await page.screenshot({ path: `${OUT}-binder.png` }); + +await page.click('.view-btn[data-view="timeline"]'); +await new Promise((r) => setTimeout(r, 2600)); +await page.screenshot({ path: `${OUT}-timeline.png` }); + +await page.click('.view-btn[data-view="globe"]'); +await new Promise((r) => setTimeout(r, 2600)); +await page.screenshot({ path: `${OUT}-globe.png` }); + +// 翻页后截图(回到活页本并翻一页) +await page.click('.view-btn[data-view="binder"]'); +await new Promise((r) => setTimeout(r, 2600)); +await page.click('#btn-next'); +await new Promise((r) => setTimeout(r, 1600)); +await page.screenshot({ path: `${OUT}-binder-flipped.png` }); + +console.log('LOGS:\n' + logs.join('\n')); +await browser.close(); diff --git a/scripts/verify.mjs b/scripts/verify.mjs new file mode 100644 index 0000000..bbe85bd --- /dev/null +++ b/scripts/verify.mjs @@ -0,0 +1,71 @@ +import puppeteer from 'puppeteer-core'; + +const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; +const URL = process.argv[2] || 'http://localhost:5199/'; + +const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: 'new', + args: ['--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--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()); }); + +await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); +await new Promise((r) => setTimeout(r, 6000)); + +async function snapshot(label) { + const info = await page.evaluate(() => { + const app = window.__app; + const gl = app.renderer.getContext(); + const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight; + const buf = new Uint8Array(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf); + let sum = 0, n = 0, bright = 0; + for (let i = 0; i < buf.length; i += 4) { + const lum = 0.299 * buf[i] + 0.587 * buf[i + 1] + 0.114 * buf[i + 2]; + sum += lum; n++; + if (lum > 40) bright++; + } + return { + view: app.view, + selected: app.selected, + binderCurrent: app.binder.current, + pages: app.binder.pages.length, + timelineCards: app.timeline.cards.length, + globeMarkers: app.globe.markers.length, + detailName: document.getElementById('detail-name')?.textContent, + detailNo: document.getElementById('detail-no')?.textContent, + avgLum: +(sum / n).toFixed(2), + brightPct: +((bright / n) * 100).toFixed(2), + }; + }); + console.log(label, JSON.stringify(info, null, 0)); +} + +await snapshot('binder '); + +await page.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 '); + +console.log('ERRORS:', errors.length ? errors.join('\n') : '(none)'); +await browser.close(); diff --git a/src/assets/earth-blue-marble.jpg b/src/assets/earth-blue-marble.jpg new file mode 100644 index 0000000..e673a7c Binary files /dev/null and b/src/assets/earth-blue-marble.jpg differ diff --git a/src/core/App.js b/src/core/App.js new file mode 100644 index 0000000..fe843f7 --- /dev/null +++ b/src/core/App.js @@ -0,0 +1,344 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { ENTRIES } from '../data/entries.js'; +import { Binder } from '../scene/binder.js'; +import { Timeline, TIMELINE_SPREAD } from '../scene/timeline.js'; +import { Globe } from '../scene/globe.js'; +import { Overlay } from '../ui/overlay.js'; +import { clamp, easeInOutCubic } from '../utils/easing.js'; + +const CAMERA_POSES = { + binder: { pos: new THREE.Vector3(0.2, 0.75, 2.95), target: new THREE.Vector3(0, 0.02, 0), max: 6, min: 1.6 }, + timeline: { pos: new THREE.Vector3(0, 1.4, 7.9), target: new THREE.Vector3(0, 0, 0), max: 13, min: 3.4 }, + globe: { pos: new THREE.Vector3(0, 0.35, 3.7), target: new THREE.Vector3(0, 0, 0), max: 8, min: 2.2 }, +}; + +export class App { + constructor() { + this.view = 'binder'; + this.selected = 0; + this.timelineOffset = 0; + + this._initRenderer(); + this._initLights(); + this._initScene(); + + this.overlay = new Overlay({ + onView: (v) => this.switchView(v), + onPrev: () => this._step(-1), + onNext: () => this._step(1), + onFlip: () => this._step(1), + }); + this.overlay.setTotal(ENTRIES.length); + + this._initEvents(); + this._select(0, { syncBinder: false }); + this.binder.syncTo(0); + + this.clock = new THREE.Clock(); + this.cameraTween = null; + this._animate = this._animate.bind(this); + requestAnimationFrame(this._animate); + } + + _initRenderer() { + const canvas = document.getElementById('scene'); + this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); + 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; + this.renderer.toneMapping = THREE.ACESFilmicToneMapping; + this.renderer.toneMappingExposure = 1.1; + + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(0x16110c); + this.scene.fog = new THREE.Fog(0x16110c, 6.5, 18); + + this.camera = new THREE.PerspectiveCamera(46, window.innerWidth / window.innerHeight, 0.1, 60); + this.camera.position.copy(CAMERA_POSES.binder.pos); + + this.controls = new OrbitControls(this.camera, canvas); + this.controls.target.copy(CAMERA_POSES.binder.target); + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.06; + this.controls.minDistance = CAMERA_POSES.binder.min; + this.controls.maxDistance = CAMERA_POSES.binder.max; + this.controls.maxPolarAngle = Math.PI * 0.82; + this.controls.update(); + } + + _initLights() { + const hemi = new THREE.HemisphereLight(0xfff1d8, 0x221811, 0.55); + this.scene.add(hemi); + + const key = new THREE.DirectionalLight(0xffe6c0, 2.4); + key.position.set(3.2, 4.4, 3.6); + key.castShadow = true; + key.shadow.mapSize.set(2048, 2048); + key.shadow.camera.near = 0.5; + key.shadow.camera.far = 30; + key.shadow.camera.left = -5; + key.shadow.camera.right = 5; + key.shadow.camera.top = 6; + key.shadow.camera.bottom = -6; + key.shadow.bias = -0.0002; + this.scene.add(key); + this.keyLight = key; + + const rim = new THREE.DirectionalLight(0x8fa0d0, 0.7); + rim.position.set(-2.5, 1.2, -3.5); + this.scene.add(rim); + } + + _initScene() { + this.binder = new Binder(); + this.binder.onChange = (i) => this._select(i, { syncBinder: false }); + + this.timeline = new Timeline(); + this.globe = new Globe({ renderer: this.renderer }); + + this.scene.add(this.binder.group); + this.scene.add(this.timeline.group); + this.scene.add(this.globe.group); + + this._buildGround(); + + this.timeline.group.visible = false; + this.globe.group.visible = false; + + this._collectInteractives(); + } + + _buildGround() { + // 柔和地面,承接阴影,让本子有落地的重量感 + const c = document.createElement('canvas'); + c.width = c.height = 512; + const g = c.getContext('2d'); + const rg = g.createRadialGradient(256, 256, 0, 256, 256, 256); + rg.addColorStop(0, 'rgba(201,162,39,0.15)'); + rg.addColorStop(0.45, 'rgba(70,45,22,0.10)'); + rg.addColorStop(1, 'rgba(0,0,0,0)'); + g.fillStyle = rg; + g.fillRect(0, 0, 512, 512); + const tex = new THREE.CanvasTexture(c); + tex.colorSpace = THREE.SRGBColorSpace; + + const geo = new THREE.PlaneGeometry(16, 16); + const mat = new THREE.MeshStandardMaterial({ + map: tex, color: 0x241a12, roughness: 0.95, metalness: 0.0, transparent: true, + }); + this.ground = new THREE.Mesh(geo, mat); + this.ground.rotation.x = -Math.PI / 2; + this.ground.position.y = -1.3; + this.ground.receiveShadow = true; + this.scene.add(this.ground); + } + + _collectInteractives() { + this.interactives = { + binder: [], + timeline: [], + globe: [], + }; + this.binder.pages.forEach((p) => { + p.group.children.forEach((m) => { + m.userData = { view: 'binder', index: p.group.userData.index }; + this.interactives.binder.push(m); + }); + }); + this.timeline.cards.forEach((c) => this.interactives.timeline.push(c.card)); + this.globe.markers.forEach((m) => this.interactives.globe.push(m.marker)); + } + + _initEvents() { + this.raycaster = new THREE.Raycaster(); + this.pointer = new THREE.Vector2(); + this._drag = null; + const canvas = this.renderer.domElement; + + const setPointer = (e) => { + const rect = canvas.getBoundingClientRect(); + this.pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + this.pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + }; + + canvas.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + setPointer(e); + if (this.view === 'timeline') { + // 时间轴:按下开始拖拽左右滑动;未移动的抬起才视为点击选中 + this._drag = { active: true, startX: e.clientX, startY: e.clientY, moved: false, offset: this.timelineOffset }; + canvas.setPointerCapture(e.pointerId); + } else { + this._pick(); + } + }); + + canvas.addEventListener('pointermove', (e) => { + setPointer(e); + if (this._drag && this._drag.active) { + const dx = e.clientX - this._drag.startX; + const dy = e.clientY - this._drag.startY; + if (!this._drag.moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) { + this._drag.moved = true; + } + if (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'; + } + return; + } + const hit = this._hitTest(); + canvas.style.cursor = hit ? 'pointer' : 'grab'; + }); + + canvas.addEventListener('pointerup', (e) => { + if (this._drag && this._drag.active) { + const wasClick = !this._drag.moved; + this._drag.active = false; + canvas.style.cursor = 'grab'; + if (wasClick) this._pick(); + } + }); + + canvas.addEventListener('pointercancel', () => { + if (this._drag) this._drag.active = false; + canvas.style.cursor = 'grab'; + }); + + window.addEventListener('resize', () => this._onResize()); + + window.addEventListener('keydown', (e) => { + if (e.key === 'ArrowRight') this._step(1); + else if (e.key === 'ArrowLeft') this._step(-1); + else if (e.key === '1') this.switchView('binder'); + else if (e.key === '2') this.switchView('timeline'); + else if (e.key === '3') this.switchView('globe'); + }); + } + + // 当前相机视野下,1 像素对应的世界宽度(拖拽跟手) + _worldPerPixel() { + const canvas = this.renderer.domElement; + const dist = this.camera.position.distanceTo(this.controls.target); + const halfFov = THREE.MathUtils.degToRad(this.camera.fov / 2); + const aspect = canvas.clientWidth / canvas.clientHeight; + return (2 * Math.tan(halfFov) * dist * aspect) / canvas.clientWidth; + } + + _hitTest() { + const list = this.interactives[this.view]; + if (!list || !list.length) return null; + this.raycaster.setFromCamera(this.pointer, this.camera); + const hits = this.raycaster.intersectObjects(list, false); + return hits.length ? hits[0] : null; + } + + _pick() { + const hit = this._hitTest(); + if (!hit) return; + const { view, index } = hit.object.userData; + if (view === 'binder') { + this._select(index, { syncBinder: true }); + } else { + this._select(index, { syncBinder: false }); + // 点击时间轴卡片 / 地理标记时,显示右下角登记卡详情 + document.getElementById('detail-card').classList.remove('hidden'); + this.binder.syncTo(index); + } + } + + _step(dir) { + if (dir > 0) this.binder.flipForward(); + else this.binder.flipBackward(); + } + + _select(index, { syncBinder = false } = {}) { + this.selected = clamp(index, 0, ENTRIES.length - 1); + const entry = ENTRIES[this.selected]; + this.overlay.setEntry(entry, this.selected, ENTRIES.length); + this.timeline.setSelected(this.selected); + this.globe.setSelected(this.selected); + if (syncBinder) this.binder.syncTo(this.selected); + } + + switchView(view) { + if (view === this.view || !CAMERA_POSES[view]) return; + const iris = document.getElementById('iris'); + iris.style.opacity = '1'; + const doSwap = () => { + this._setView(view); + setTimeout(() => { iris.style.opacity = '0'; }, 180); + }; + setTimeout(doSwap, 240); + } + + _setView(view) { + this.view = view; + this.overlay.setView(view); + + this.binder.group.visible = view === 'binder'; + this.timeline.group.visible = view === 'timeline'; + this.globe.group.visible = view === 'globe'; + + // 登记卡详情仅在活页本视图展示,避免遮挡时间轴/地理内容 + document.getElementById('detail-card').classList.toggle('hidden', view !== 'binder'); + + const pose = CAMERA_POSES[view]; + this.controls.minDistance = pose.min; + this.controls.maxDistance = pose.max; + // 时间轴:锁定旋转/平移;滚轮缩放;拖拽左右滑动 + const locked = view === 'timeline'; + this.controls.enableRotate = !locked; + this.controls.enablePan = !locked; + this.controls.enableZoom = true; + this.controls.autoRotate = view === 'globe'; + this.controls.autoRotateSpeed = 0.5; + + // 进入时间轴时复位横向偏移 + if (view === 'timeline') { + this.timelineOffset = 0; + this.timeline.group.position.x = 0; + } + + this._tweenCamera(pose.pos, pose.target); + } + + _tweenCamera(toPos, toTarget) { + const fromPos = this.camera.position.clone(); + const fromTarget = this.controls.target.clone(); + const dur = 1.1; + this.cameraTween = { + t: 0, dur, fromPos, toPos: toPos.clone(), fromTarget, toTarget: toTarget.clone(), + }; + } + + _onResize() { + this.camera.aspect = window.innerWidth / window.innerHeight; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(window.innerWidth, window.innerHeight); + } + + _animate() { + requestAnimationFrame(this._animate); + const dt = Math.min(this.clock.getDelta(), 0.05); + + this.binder.update(dt); + + if (this.cameraTween) { + const t = this.cameraTween; + t.t += dt / t.dur; + const k = clamp(t.t, 0, 1); + const e = easeInOutCubic(k); + this.camera.position.lerpVectors(t.fromPos, t.toPos, e); + this.controls.target.lerpVectors(t.fromTarget, t.toTarget, e); + if (k >= 1) this.cameraTween = null; + } + + this.controls.update(); + this.renderer.render(this.scene, this.camera); + } +} diff --git a/src/data/entries.js b/src/data/entries.js new file mode 100644 index 0000000..b53b936 --- /dev/null +++ b/src/data/entries.js @@ -0,0 +1,238 @@ +// 世界历史里程碑条目数据 +// timeNumeric 用于时间轴排序/定位(公元前为负,公元后为正,单位:年) + +export const CATEGORY_INFO = { + 人: { color: '#a03a2a', en: 'PERSONA' }, + 事: { color: '#2c4a6b', en: 'EVENTUS' }, + 物: { color: '#3f5c3a', en: 'ARTIFEX' }, +}; + +export const ENTRIES = [ + { + id: 'No.001', + time: '约公元前320万年', + timeNumeric: -3200000, + region: '东非 · 埃塞俄比亚', + name: '露西 · 南方古猿', + category: '人', + sketch: 'fossil', + lat: 11.2, lng: 40.5, + description: '1974年在埃塞俄比亚哈达尔发现的一具约40%完整的南方古猿阿法种骨架,是直立行走的最早证据之一,被亲切地称为"人类祖母"。', + }, + { + id: 'No.002', + time: '约公元前3200年', + timeNumeric: -3200, + region: '美索不达米亚 · 两河流域', + name: '楔形文字', + category: '物', + sketch: 'tablet', + lat: 31.3, lng: 45.6, + description: '苏美尔人用削尖的芦苇杆在湿润泥板上压刻出楔形符号,晾干成册,是人类已知最早的文字系统,也是文明记录的开端。', + }, + { + id: 'No.003', + time: '约公元前2560年', + timeNumeric: -2560, + region: '古埃及 · 吉萨', + name: '胡夫金字塔', + category: '物', + sketch: 'pyramid', + lat: 29.98, lng: 31.13, + description: '埃及第四王朝法老胡夫的陵墓,由约230万块巨石砌成,原高约146米,是古代世界七大奇迹中唯一留存至今的建筑。', + }, + { + id: 'No.004', + time: '公元前551年', + timeNumeric: -551, + region: '中国 · 鲁国曲阜', + name: '孔子', + category: '人', + sketch: 'figure', + lat: 35.6, lng: 117.0, + description: '儒家学派创始人,倡导仁、礼与中庸之道,其思想深刻影响了东亚两千余年,被后世尊为"至圣先师"。', + }, + { + id: 'No.005', + time: '公元前5世纪', + timeNumeric: -450, + region: '古希腊 · 雅典', + name: '雅典卫城 · 帕特农神庙', + category: '物', + sketch: 'temple', + lat: 37.97, lng: 23.73, + description: '矗立于雅典城邦山丘之上的建筑群,帕特农神庙为其核心,是古希腊文明、民主精神与古典美学的永恒象征。', + }, + { + id: 'No.006', + time: '公元前221年', + timeNumeric: -221, + region: '中国 · 咸阳', + name: '秦始皇统一六国', + category: '事', + sketch: 'crown', + lat: 34.34, lng: 108.94, + description: '嬴政扫灭六国,建立中国历史上第一个中央集权的大一统王朝,推行书同文、车同轨、统一度量衡,奠定后世版图与文化认同。', + }, + { + id: 'No.007', + time: '公元前334年', + timeNumeric: -334, + region: '马其顿 · 希腊半岛', + name: '亚历山大大帝', + category: '人', + sketch: 'figure', + lat: 40.7, lng: 22.5, + description: '马其顿国王,率军东征波斯,兵锋直抵印度河流域,建立横跨欧亚非的庞大帝国,促进了希腊化文明的广泛传播。', + }, + { + id: 'No.008', + time: '公元80年', + timeNumeric: 80, + region: '罗马帝国 · 罗马', + name: '罗马斗兽场', + category: '物', + sketch: 'colosseum', + lat: 41.89, lng: 12.49, + description: '古罗马最大的椭圆形竞技场,可容纳约五万名观众,拱券与环形结构的杰作,见证了一个帝国的荣光与工程技术。', + }, + { + id: 'No.009', + time: '公元105年', + timeNumeric: 105, + region: '中国 · 东汉洛阳', + name: '蔡伦造纸', + category: '事', + sketch: 'scroll', + lat: 34.6, lng: 112.4, + description: '蔡伦总结前人经验,以树皮、麻头、破布、渔网为原料造出轻便廉价的纸,深刻改变了知识的记录与传播方式。', + }, + { + id: 'No.010', + time: '约公元570年', + timeNumeric: 570, + region: '阿拉伯半岛 · 麦加', + name: '穆罕默德', + category: '人', + sketch: 'figure', + lat: 21.42, lng: 39.83, + description: '伊斯兰教先知,在阿拉伯半岛传播一神信仰,其创立的伊斯兰教深刻塑造了中东、北非乃至世界的文明版图。', + }, + { + id: 'No.011', + time: '公元1206年', + timeNumeric: 1206, + region: '蒙古高原', + name: '成吉思汗', + category: '人', + sketch: 'horseman', + lat: 47.9, lng: 106.9, + description: '铁木真统一蒙古各部,建立大蒙古国,其子孙随后建立了人类历史上疆域最为辽阔的陆上帝国,打通了欧亚大陆。', + }, + { + id: 'No.012', + time: '公元1492年', + timeNumeric: 1492, + region: '西班牙 → 美洲', + name: '哥伦布发现新大陆', + category: '事', + sketch: 'ship', + lat: 24.0, lng: -75.0, + description: '哥伦布率三艘帆船横渡大西洋抵达美洲,揭开大航海时代的序幕,也开启了全球联系与"地球村"的历史进程。', + }, + { + id: 'No.013', + time: '公元1519—1522年', + timeNumeric: 1519, + region: '欧洲 → 环球', + name: '麦哲伦环球航行', + category: '事', + sketch: 'globe', + lat: 53.5, lng: -70.8, + description: '麦哲伦船队首次完成环绕地球一周的航行,用三年艰苦航程实证了地球是圆的,人类的世界观从此改变。', + }, + { + id: 'No.014', + time: '公元1687年', + timeNumeric: 1687, + region: '英国 · 剑桥', + name: '牛顿《自然哲学的数学原理》', + category: '事', + sketch: 'apple', + lat: 52.2, lng: 0.12, + description: '牛顿提出三大运动定律与万有引力定律,将天上与地上的运动统一于同一套数学法则,奠定经典力学与现代科学的基础。', + }, + { + id: 'No.015', + time: '公元1769年', + timeNumeric: 1769, + region: '英国 · 格拉斯哥', + name: '瓦特改良蒸汽机', + category: '物', + sketch: 'machine', + lat: 55.86, lng: -4.25, + description: '瓦特改良的蒸汽机大幅提高热效率并实现旋转动力输出,成为第一次工业革命的核心引擎,机器时代由此轰鸣开启。', + }, + { + id: 'No.016', + time: '公元1776年', + timeNumeric: 1776, + region: '北美 · 费城', + name: '美国《独立宣言》', + category: '事', + sketch: 'bell', + lat: 39.95, lng: -75.16, + description: '宣告"人人生而平等",天赋人权与主权在民,开启现代共和制国家的先河,深刻影响了此后两百年的世界政治。', + }, + { + id: 'No.017', + time: '公元1859年', + timeNumeric: 1859, + region: '英国 · 伦敦', + name: '达尔文《物种起源》', + category: '事', + sketch: 'tree', + lat: 51.5, lng: -0.12, + description: '达尔文提出以自然选择为核心的进化论,彻底改变了人类对生命起源与演化的理解,是近代思想史上的一次革命。', + }, + { + id: 'No.018', + time: '公元1905年', + timeNumeric: 1905, + region: '瑞士 · 伯尔尼', + name: '爱因斯坦狭义相对论', + category: '事', + sketch: 'atom', + lat: 46.95, lng: 7.45, + description: '爱因斯坦提出狭义相对论,得出质能方程 E=mc²,颠覆牛顿的绝对时空观,开启现代物理学的新纪元。', + }, + { + id: 'No.019', + time: '公元1945年', + timeNumeric: 1945, + region: '世界 · 纽约', + name: '联合国成立', + category: '事', + sketch: 'dove', + lat: 40.75, lng: -73.97, + description: '51个国家签署《联合国宪章》,一个以维护世界和平、促进国际合作与发展为宗旨的全球组织正式诞生。', + }, + { + id: 'No.020', + time: '公元1969年', + timeNumeric: 1969, + region: '美国 · 卡纳维拉尔角', + name: '阿波罗11号登月', + category: '事', + sketch: 'rocket', + lat: 28.4, lng: -80.6, + description: '阿波罗11号将人类首次送上月球,阿姆斯特朗迈出"个人一小步、人类一大步",把文明的足迹延伸到地外天体。', + }, +]; + +// 时间轴横向定位:对称对数映射,远古压缩、近代展开 +export function timelineX(t) { + const sign = t >= 0 ? 1 : -1; + const mag = Math.log10(1 + Math.abs(t) / 50); + return sign * mag * 1.15; +} diff --git a/src/data/worldMap.js b/src/data/worldMap.js new file mode 100644 index 0000000..f6fed26 --- /dev/null +++ b/src/data/worldMap.js @@ -0,0 +1,49 @@ +// 简化的大陆轮廓(经纬度多边形),用于生成点阵风格的地球。 +// 坐标 [lng, lat],用于艺术化表达,不求精确。 + +export const CONTINENTS = [ + { name: '北美洲', poly: [[-168,66],[-140,70],[-90,72],[-60,60],[-55,52],[-65,45],[-75,35],[-80,25],[-90,29],[-97,26],[-97,22],[-105,20],[-110,23],[-117,33],[-125,40],[-125,49],[-135,58],[-150,60],[-165,60],[-168,66]] }, + { name: '格陵兰', poly: [[-45,60],[-55,70],[-40,83],[-20,80],[-22,70],[-32,60],[-45,60]] }, + { name: '南美洲', poly: [[-77,8],[-60,5],[-51,2],[-35,-5],[-40,-15],[-48,-25],[-58,-35],[-65,-40],[-68,-50],[-72,-53],[-75,-45],[-72,-35],[-70,-20],[-76,-12],[-80,-3],[-77,8]] }, + { name: '欧洲', poly: [[-9,43],[-9,37],[3,43],[10,44],[12,48],[8,54],[10,58],[18,58],[25,60],[30,60],[40,68],[30,70],[20,70],[10,64],[5,62],[-5,58],[-9,43]] }, + { name: '不列颠', poly: [[-5,50],[-2,53],[-2,57],[-5,58],[-4,54],[-5,50]] }, + { name: '冰岛', poly: [[-22,64],[-18,66],[-14,65],[-20,63],[-22,64]] }, + { name: '非洲', poly: [[-6,35],[10,37],[20,32],[32,31],[37,18],[43,11],[40,-3],[40,-11],[35,-20],[30,-30],[20,-35],[15,-28],[12,-18],[13,-9],[8,5],[0,5],[-9,5],[-15,10],[-17,15],[-13,25],[-6,35]] }, + { name: '马达加斯加', poly: [[44,-12],[48,-16],[50,-16],[47,-25],[44,-22],[44,-12]] }, + { name: '阿拉伯', poly: [[35,30],[43,12],[52,15],[58,22],[55,26],[48,30],[35,30]] }, + { name: '亚洲', poly: [[27,40],[45,42],[55,46],[60,50],[70,55],[90,60],[120,68],[160,70],[180,66],[180,60],[160,58],[140,52],[135,44],[125,39],[120,32],[112,21],[108,13],[104,8],[98,8],[95,16],[92,22],[85,20],[80,12],[77,7],[74,14],[70,21],[66,25],[60,25],[55,27],[48,29],[40,31],[32,34],[27,40]] }, + { name: '日本', poly: [[130,31],[140,35],[142,42],[145,44],[140,40],[135,34],[130,31]] }, + { name: '东南亚', poly: [[98,6],[102,-2],[106,-6],[114,-8],[120,-3],[110,3],[104,2],[98,6]] }, + { name: '新几内亚', poly: [[131,-1],[138,-3],[145,-6],[147,-9],[140,-8],[134,-4],[131,-1]] }, + { name: '澳大利亚', poly: [[114,-22],[122,-18],[130,-12],[138,-17],[146,-19],[153,-28],[150,-37],[140,-38],[130,-32],[120,-34],[114,-22]] }, + { name: '新西兰', poly: [[172,-34],[175,-38],[178,-38],[173,-41],[168,-46],[167,-45],[170,-40],[172,-34]] }, +]; + +// 判断点是否在多边形内(射线法) +function pointInPoly(lng, lat, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const [xi, yi] = poly[i]; + const [xj, yj] = poly[j]; + if ((yi > lat) !== (yj > lat) && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; +} + +// 生成陆地点阵(经纬度网格) +export function landPoints(step = 2.2) { + const pts = []; + for (let lat = -62; lat <= 74; lat += step) { + for (let lng = -180; lng < 180; lng += step) { + for (const c of CONTINENTS) { + if (pointInPoly(lng, lat, c.poly)) { + pts.push([lng, lat]); + break; + } + } + } + } + return pts; +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..3d6daab --- /dev/null +++ b/src/main.js @@ -0,0 +1,5 @@ +import { App } from './core/App.js'; + +// 启动应用 +const app = new App(); +window.__app = app; // 便于调试 diff --git a/src/scene/binder.js b/src/scene/binder.js new file mode 100644 index 0000000..e7d8136 --- /dev/null +++ b/src/scene/binder.js @@ -0,0 +1,318 @@ +import * as THREE from 'three'; +import { ENTRIES } from '../data/entries.js'; +import { PAGE_W, PAGE_H, HOLE_U, getPageTextures } from '../textures/pages.js'; +import { clamp, easeInOutQuint } from '../utils/easing.js'; + +const RING_R = 0.085; +const RING_TUBE = 0.0115; +const STACK_GAP = 0.004; +const PAGE_GAP = 0.02; // 页内缘离书脊的小空隙 + +function makeLeatherTexture({ emblem = false } = {}) { + const w = 512, h = 640; + const c = document.createElement('canvas'); + c.width = w; c.height = h; + const ctx = c.getContext('2d'); + // 皮革底色(斜向渐变) + const g = ctx.createLinearGradient(0, 0, w, h); + g.addColorStop(0, '#5c3f2c'); + g.addColorStop(0.5, '#4a3223'); + g.addColorStop(1, '#392516'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + // 皮革颗粒 + for (let i = 0; i < 32000; i++) { + const x = Math.random() * w, y = Math.random() * h; + ctx.fillStyle = Math.random() > 0.5 ? 'rgba(28,16,8,0.16)' : 'rgba(178,128,74,0.10)'; + ctx.fillRect(x, y, Math.random() * 3 + 0.5, Math.random() * 3 + 0.5); + } + // 大块斑驳 + for (let i = 0; i < 70; i++) { + const x = Math.random() * w, y = Math.random() * h, r = Math.random() * 90 + 20; + const rg = ctx.createRadialGradient(x, y, 0, x, y, r); + rg.addColorStop(0, Math.random() > 0.5 ? 'rgba(0,0,0,0.09)' : 'rgba(205,145,85,0.07)'); + rg.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = rg; + ctx.fillRect(0, 0, w, h); + } + // 金线压花边框 + 角饰 + ctx.strokeStyle = 'rgba(201,162,39,0.5)'; + ctx.lineWidth = 3; + ctx.strokeRect(20, 20, w - 40, h - 40); + ctx.strokeStyle = 'rgba(201,162,39,0.26)'; + ctx.lineWidth = 1.2; + ctx.strokeRect(28, 28, w - 56, h - 56); + ctx.fillStyle = 'rgba(201,162,39,0.72)'; + for (const [cx, cy] of [[20, 20], [w - 20, 20], [20, h - 20], [w - 20, h - 20]]) { + ctx.save(); ctx.translate(cx, cy); ctx.rotate(Math.PI / 4); ctx.fillRect(-4, -4, 8, 8); ctx.restore(); + } + if (emblem) { + // 中央烫金徽记 + ctx.strokeStyle = 'rgba(201,162,39,0.55)'; + ctx.lineWidth = 2.5; + ctx.beginPath(); ctx.arc(w / 2, h / 2, 84, 0, Math.PI * 2); ctx.stroke(); + ctx.beginPath(); ctx.arc(w / 2, h / 2, 70, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = 'rgba(224,196,106,0.92)'; + ctx.font = '700 72px "Kaiti SC","STKaiti","KaiTi",serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('史', w / 2, h / 2 + 6); + } + const tex = new THREE.CanvasTexture(c); + tex.colorSpace = THREE.SRGBColorSpace; + tex.anisotropy = 8; + return tex; +} + +// 圆角盒体(ExtrudeGeometry 拉伸 + 倒角) +function roundedRectShape(w, h, r) { + const s = new THREE.Shape(); + const x = -w / 2, y = -h / 2; + s.moveTo(x + r, y); + s.lineTo(x + w - r, y); + s.quadraticCurveTo(x + w, y, x + w, y + r); + s.lineTo(x + w, y + h - r); + s.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + s.lineTo(x + r, y + h); + s.quadraticCurveTo(x, y + h, x, y + h - r); + s.lineTo(x, y + r); + s.quadraticCurveTo(x, y, x + r, y); + return s; +} + +function makeRoundedBox(w, h, d, r, bevel = 0.012) { + const geo = new THREE.ExtrudeGeometry(roundedRectShape(w, h, r), { + depth: d, + bevelEnabled: true, + bevelThickness: bevel, + bevelSize: bevel * 0.85, + bevelSegments: 3, + curveSegments: 10, + }); + geo.translate(0, 0, -d / 2); + return geo; +} + +export class Binder { + constructor() { + this.group = new THREE.Group(); + this.pages = []; + this.current = 0; + this.flip = null; // { index, from, to, t, duration } + this.onChange = null; + + this.coverTex = makeLeatherTexture({ emblem: true }); + this.spineTex = makeLeatherTexture({ emblem: false }); + this._buildCovers(); + this._buildRings(); + this._buildPages(); + + // 微微后仰,像阅读架 + this.group.rotation.x = -0.09; + } + + _buildCovers() { + const coverW = PAGE_W + 0.12; + const coverH = PAGE_H + 0.18; + const thick = 0.05; + const coverMat = new THREE.MeshStandardMaterial({ + map: this.coverTex, roughness: 0.58, metalness: 0.1, + }); + const spineMat = new THREE.MeshStandardMaterial({ + map: this.spineTex, roughness: 0.6, metalness: 0.08, + }); + + // 圆角倒角封面 + const coverGeo = makeRoundedBox(coverW, coverH, thick, 0.07); + this.coverL = new THREE.Mesh(coverGeo, coverMat); + this.coverL.position.set(-(0.05 + coverW / 2), 0, -0.115); + this.coverL.receiveShadow = true; + this.coverL.castShadow = true; + this.coverR = new THREE.Mesh(coverGeo, coverMat); + this.coverR.position.set(0.05 + coverW / 2, 0, -0.115); + this.coverR.receiveShadow = true; + this.coverR.castShadow = true; + + // 圆角书脊 + const spineGeo = makeRoundedBox(0.1, coverH, 0.16, 0.03, 0.008); + this.spine = new THREE.Mesh(spineGeo, spineMat); + this.spine.position.set(0, 0, -0.11); + this.spine.receiveShadow = true; + + // 书脊上下金属箍 + const bandMat = new THREE.MeshStandardMaterial({ + color: 0xc9a227, metalness: 0.85, roughness: 0.32, + }); + const bandGeo = makeRoundedBox(0.115, 0.07, 0.18, 0.02, 0.004); + for (const by of [-0.72, 0.72]) { + const band = new THREE.Mesh(bandGeo, bandMat); + band.position.set(0, by, -0.11); + band.castShadow = true; + this.group.add(band); + } + + this.group.add(this.coverL, this.coverR, this.spine); + } + + _buildRings() { + const mat = new THREE.MeshStandardMaterial({ + color: 0xd8d2c6, metalness: 0.95, roughness: 0.22, + }); + // 更平滑的活页环 + const geo = new THREE.TorusGeometry(RING_R, RING_TUBE, 18, 56); + this.rings = []; + for (const u of HOLE_U) { + const y = (0.5 - u) * PAGE_H; + const ring = new THREE.Mesh(geo, mat); + ring.rotation.x = Math.PI / 2; // 环面落在 XZ 平面(绕书脊) + ring.position.set(0, y, 0); + ring.castShadow = true; + this.rings.push(ring); + this.group.add(ring); + } + } + + _buildPages() { + const segX = 72, segY = 6; + + ENTRIES.forEach((entry, i) => { + const geo = new THREE.PlaneGeometry(PAGE_W, PAGE_H, segX, segY); + // 页内缘离书脊留出小空隙,左右页不紧贴 + geo.translate(PAGE_W / 2 + PAGE_GAP, 0, 0); + const base = Float32Array.from(geo.attributes.position.array); + const { front, back } = getPageTextures(entry); + + const frontMat = new THREE.MeshStandardMaterial({ + map: front, roughness: 0.88, metalness: 0.0, side: THREE.FrontSide, + }); + const backMat = new THREE.MeshStandardMaterial({ + map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide, + }); + + const frontMesh = new THREE.Mesh(geo, frontMat); + frontMesh.position.z = 0.0008; + frontMesh.castShadow = true; + const backMesh = new THREE.Mesh(geo, backMat); + backMesh.position.z = -0.0008; + backMesh.castShadow = false; + + const g = new THREE.Group(); + g.add(frontMesh, backMesh); + g.userData.index = i; + g.userData.entry = entry; + + this.pages.push({ + group: g, + geometry: geo, + base, + frontMat, + backMat, + angle: 0, + y: 0, + }); + + this.group.add(g); + }); + + this._restack(); + } + + // 各页休息位置:角度 + 世界 z(堆叠深度) + _restack() { + this.pages.forEach((p, j) => { + const angle = j < this.current ? -Math.PI : 0; + p.angle = angle; + p.group.rotation.y = angle; + const depth = j < this.current ? (this.current - 1 - j) : (j - this.current); + const z = -depth * STACK_GAP; + p.group.position.z = z; + p.group.position.y = 0; + p.group.position.x = 0; + // 下层页略暗 + const dim = 1 - Math.min(depth * 0.06, 0.22); + p.frontMat.color.setScalar(dim); + p.backMat.color.setScalar(dim); + }); + this._updateCurvature(); + } + + _updateCurvature() { + // 翻页时:靠近书脊的一段平滑卷曲,顶边卷曲略强于底边(斜向扭转),静止时完全平整 + this.pages.forEach((p) => { + const pos = p.geometry.attributes.position; + const arr = pos.array; + const theta = Math.abs(p.angle); // 0..π + const c = Math.sin(theta); // 0 静止,1 翻页中途 + const L = 0.16 * c; // 卷曲延伸长度 + const h = 0.15 * c; // 卷曲抬升高度 + for (let i = 0; i < arr.length; i += 3) { + const x = p.base[i]; + const y = p.base[i + 1]; + const s = Math.max(0, x - PAGE_GAP); // 距页内缘 0..W(防浮点误差产生负值→NaN) + let z = 0; + if (s < L) { + const t = s / L; + const yNorm = (y + PAGE_H / 2) / PAGE_H; // 0 底边 .. 1 顶边 + const twist = 0.55 + 0.45 * yNorm; + z = h * twist * Math.sin((Math.PI * t) / 2); + } + arr[i + 2] = p.base[i + 2] + z; + } + pos.needsUpdate = true; + }); + } + + syncTo(index) { + this.current = clamp(index, 0, this.pages.length - 1); + this.flip = null; + this._restack(); + if (this.onChange) this.onChange(this.current); + } + + canFlipForward() { return !this.flip && this.current < this.pages.length - 1; } + canFlipBackward() { return !this.flip && this.current > 0; } + + flipForward() { + if (!this.canFlipForward()) return; + this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 1.15 }; + this.pages[this.current].group.position.z = 0.006; + } + + flipBackward() { + if (!this.canFlipBackward()) return; + this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 1.15 }; + this.pages[this.current - 1].group.position.z = 0.006; + } + + update(dt) { + if (this.flip) { + const f = this.flip; + f.t += dt / f.duration; + const k = clamp(f.t, 0, 1); + const e = easeInOutQuint(k); + const angle = f.from + (f.to - f.from) * e; + + const p = this.pages[f.index]; + p.angle = angle; + p.group.rotation.y = angle; + p.group.position.y = 0.05 * Math.sin(Math.PI * k); + this._updateCurvature(); + + if (k >= 1) { + if (f.to === -Math.PI) this.current += 1; else this.current -= 1; + this.flip = null; + this._restack(); + if (this.onChange) this.onChange(this.current); + } + } + } + + dispose() { + this.pages.forEach((p) => { + p.geometry.dispose(); + p.frontMat.map.dispose(); + p.frontMat.dispose(); + p.backMat.map.dispose(); + p.backMat.dispose(); + }); + } +} diff --git a/src/scene/globe.js b/src/scene/globe.js new file mode 100644 index 0000000..e3df606 --- /dev/null +++ b/src/scene/globe.js @@ -0,0 +1,202 @@ +import * as THREE from 'three'; +import { ENTRIES, CATEGORY_INFO } from '../data/entries.js'; +import { makeTextSprite } from '../utils/labels.js'; +import earthUrl from '../assets/earth-blue-marble.jpg'; + +const R = 1.6; + +export function latLngToVec3(lat, lng, r = R) { + const phi = ((90 - lat) * Math.PI) / 180; + const theta = ((lng + 180) * Math.PI) / 180; + const x = -r * Math.sin(phi) * Math.cos(theta); + const z = r * Math.sin(phi) * Math.sin(theta); + const y = r * Math.cos(phi); + return new THREE.Vector3(x, y, z); +} + +export class Globe { + constructor({ renderer } = {}) { + this.group = new THREE.Group(); + this.markers = []; + this.selected = -1; + this.renderer = renderer; + + this._buildSphere(); + this._buildGraticule(); + this._buildMarkers(); + } + + _buildSphere() { + const geo = new THREE.SphereGeometry(R, 64, 48); + const mat = new THREE.MeshStandardMaterial({ + color: 0x0b111d, roughness: 0.82, metalness: 0.0, + }); + const sphere = new THREE.Mesh(geo, mat); + sphere.receiveShadow = true; + this.group.add(sphere); + + // 球体表面:真实地球地形(NASA 蓝弹珠卫星影像,等距圆柱投影) + const loader = new THREE.TextureLoader(); + loader.load(earthUrl, (tex) => { + tex.colorSpace = THREE.SRGBColorSpace; + const maxAniso = this.renderer ? this.renderer.capabilities.getMaxAnisotropy() : 8; + tex.anisotropy = Math.min(8, maxAniso); + mat.map = tex; + mat.color.set(0xffffff); + mat.needsUpdate = true; + + // 贴图就绪后,按真实地形采样生成点阵,保证珠子与地图精准对齐 + this._buildDots(tex.image); + }); + + // 大气微光(背面发光) + const glowGeo = new THREE.SphereGeometry(R * 1.015, 64, 48); + const glowMat = new THREE.MeshBasicMaterial({ + color: 0x2a3b5c, transparent: true, opacity: 0.16, side: THREE.BackSide, + }); + this.group.add(new THREE.Mesh(glowGeo, glowMat)); + } + + _buildGraticule() { + const mat = new THREE.LineBasicMaterial({ color: 0x3d4f6a, transparent: true, opacity: 0.45 }); + // 纬线 + for (let lat = -60; lat <= 60; lat += 30) { + const pts = []; + for (let lng = 0; lng <= 360; lng += 4) { + pts.push(latLngToVec3(lat, lng - 180, R * 1.002)); + } + const g = new THREE.BufferGeometry().setFromPoints(pts); + this.group.add(new THREE.Line(g, mat)); + } + // 经线 + for (let lng = 0; lng < 180; lng += 30) { + const pts = []; + for (let lat = -90; lat <= 90; lat += 4) { + pts.push(latLngToVec3(lat, lng, R * 1.002)); + } + const g = new THREE.BufferGeometry().setFromPoints(pts); + this.group.add(new THREE.Line(g, mat)); + } + } + + _buildDots(image) { + // 用真实地形贴图采样:珠子经纬度直接从贴图像素反推,与地图精准对齐 + const W = 2048, H = 1024; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(image, 0, 0, W, H); + const px = ctx.getImageData(0, 0, W, H).data; + + // 蓝弹珠中海洋以蓝色为主导通道;非蓝色主导即为陆地/冰盖。 + // 采样坐标系与 SphereGeometry + flipY 的 UV 完全一致,保证珠子与贴图精准对齐。 + const isLand = (vx, vy, vz) => { + let phi = Math.atan2(vz, -vx); // 方位角 + if (phi < 0) phi += Math.PI * 2; // 归一化到 [0, 2π) + const theta = Math.acos(Math.max(-1, Math.min(1, vy))); // 极角,0=北极 + const x = Math.min(W - 1, Math.max(0, Math.round((phi / (Math.PI * 2)) * (W - 1)))); + const y = Math.min(H - 1, Math.max(0, Math.round((theta / Math.PI) * (H - 1)))); + const i = (y * W + x) * 4; + const r = px[i], g = px[i + 1], b = px[i + 2]; + // 海洋:蓝色明显占优(蓝>红且蓝>绿,且差距足够大以排除冰雪的微蓝白色) + return !(b > r && b > g && (b - r) > 6 && (b - g) > 4); + }; + + // 斐波那契球:单位球面上均匀分布的点 + const fib = (N) => { + const pts = []; + const phiG = Math.PI * (3 - Math.sqrt(5)); + for (let i = 0; i < N; i++) { + const y = 1 - (i / (N - 1)) * 2; + const rad = Math.sqrt(Math.max(0, 1 - y * y)); + const th = phiG * i; + pts.push([Math.cos(th) * rad, y, Math.sin(th) * rad]); + } + return pts; + }; + + // 陆地珠子:只保留落在真实陆地上的点 + const landPts = []; + for (const [vx, vy, vz] of fib(40000)) { + if (isLand(vx, vy, vz)) landPts.push(new THREE.Vector3(vx, vy, vz).multiplyScalar(R * 1.004)); + } + + // 海洋珠子:只保留落在真实海洋上的点(密度略低,作背景微光) + const oceanPts = []; + for (const [vx, vy, vz] of fib(9000)) { + if (!isLand(vx, vy, vz)) oceanPts.push(new THREE.Vector3(vx, vy, vz).multiplyScalar(R * 1.002)); + } + + this._buildDotBeads(landPts, { + radius: 0.0045, color: 0xe8d9b4, opacity: 0.95, emissiveIntensity: 0.5, + }); + this._buildDotBeads(oceanPts, { + radius: 0.003, color: 0x33415f, opacity: 0.5, emissiveIntensity: 0.15, + }); + } + + _buildDotBeads(positions, { radius, color, opacity, emissiveIntensity }) { + const geo = new THREE.SphereGeometry(radius, 8, 6); + const mat = new THREE.MeshStandardMaterial({ + color, + emissive: new THREE.Color(color), + emissiveIntensity, + roughness: 0.55, + metalness: 0.0, + transparent: true, + opacity, + }); + const mesh = new THREE.InstancedMesh(geo, mat, positions.length); + const dummy = new THREE.Object3D(); + positions.forEach((p, i) => { + dummy.position.copy(p); + dummy.updateMatrix(); + mesh.setMatrixAt(i, dummy.matrix); + }); + mesh.instanceMatrix.needsUpdate = true; + this.group.add(mesh); + } + + _buildMarkers() { + const sphereGeo = new THREE.SphereGeometry(0.024, 20, 16); + const ringGeo = new THREE.TorusGeometry(0.04, 0.006, 10, 28); + + ENTRIES.forEach((entry, i) => { + const info = CATEGORY_INFO[entry.category] || CATEGORY_INFO['事']; + const pos = latLngToVec3(entry.lat, entry.lng, R * 1.012); + + const mat = new THREE.MeshStandardMaterial({ + color: 0x111111, emissive: new THREE.Color(info.color), + emissiveIntensity: 0.9, roughness: 0.4, metalness: 0.3, + }); + const marker = new THREE.Mesh(sphereGeo, mat); + marker.position.copy(pos); + marker.userData = { view: 'globe', index: i }; + + // 选中环(切于球面) + const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ color: 0xe0c46a, transparent: true, opacity: 0.9 })); + ring.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), pos.clone().normalize()); + ring.position.copy(pos); + ring.visible = false; + + // 名称标签 + const label = makeTextSprite(entry.name, { fontSize: 40, color: '#e8d9b4' }); + label.scale.multiplyScalar(0.42); + label.position.copy(pos.clone().multiplyScalar(1.22)); + label.visible = false; + + this.group.add(marker, ring, label); + this.markers.push({ marker, ring, label, index: i }); + }); + } + + setSelected(index) { + this.selected = index; + this.markers.forEach((m) => { + const on = m.index === index; + m.ring.visible = on; + m.label.visible = on; + }); + } +} diff --git a/src/scene/timeline.js b/src/scene/timeline.js new file mode 100644 index 0000000..46cd527 --- /dev/null +++ b/src/scene/timeline.js @@ -0,0 +1,95 @@ +import * as THREE from 'three'; +import { ENTRIES } from '../data/entries.js'; +import { getPageTextures } from '../textures/pages.js'; +import { makeTextSprite } from '../utils/labels.js'; + +export const TIMELINE_SPREAD = 6.2; // 横轴范围 ±6.2(超出视口,可横向滚动) + +function cardX(i, n) { + return (i / (n - 1)) * 2 * TIMELINE_SPREAD - TIMELINE_SPREAD; +} + +export class Timeline { + constructor() { + this.group = new THREE.Group(); + this.cards = []; + this.selected = -1; + + this._buildLine(); + this._buildCards(); + } + + _buildLine() { + const n = ENTRIES.length; + const min = cardX(0, n) - 0.4; + const max = cardX(n - 1, n) + 0.4; + + const lineMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.7 }); + const lineGeo = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(min, -0.85, 0), new THREE.Vector3(max, -0.85, 0), + ]); + this.group.add(new THREE.Line(lineGeo, lineMat)); + + const glowMat = new THREE.LineBasicMaterial({ color: 0xe0c46a, transparent: true, opacity: 0.22 }); + const glowGeo = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(min, -0.855, -0.03), new THREE.Vector3(max, -0.855, -0.03), + ]); + this.group.add(new THREE.Line(glowGeo, glowMat)); + + // 刻度:向上指向卡片、向下指向时间标签 + const tickMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.5 }); + ENTRIES.forEach((e, i) => { + const x = cardX(i, n); + const g = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(x, -0.98, 0), new THREE.Vector3(x, -0.7, 0), + ]); + this.group.add(new THREE.Line(g, tickMat)); + }); + } + + _buildCards() { + const n = ENTRIES.length; + const cardGeo = new THREE.PlaneGeometry(0.4, 0.565); + + ENTRIES.forEach((entry, i) => { + const x = cardX(i, n); + const { front } = getPageTextures(entry); + + const mat = new THREE.MeshStandardMaterial({ map: front, roughness: 0.85, metalness: 0.0 }); + const card = new THREE.Mesh(cardGeo, mat); + card.position.set(x, 0, 0); + card.userData = { view: 'timeline', index: i }; + card.castShadow = true; + + const outline = new THREE.Mesh( + new THREE.PlaneGeometry(0.4 * 1.1, 0.565 * 1.1), + new THREE.MeshBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.9, side: THREE.BackSide }) + ); + outline.position.z = -0.01; + outline.visible = false; + card.add(outline); + + // 活页上方:主题(常显) + const name = makeTextSprite(entry.name, { fontSize: 30, color: '#e8d9b4' }); + name.scale.multiplyScalar(0.22); + name.position.set(x, 0.42, 0); + + // 轴下方:时间(常显) + const time = makeTextSprite(entry.time, { fontSize: 22, color: '#c9a227' }); + time.scale.multiplyScalar(0.18); + time.position.set(x, -1.06, 0); + + this.group.add(card, name, time); + this.cards.push({ card, outline, index: i }); + }); + } + + setSelected(index) { + this.selected = index; + this.cards.forEach((c) => { + const on = c.index === index; + c.outline.visible = on; + c.card.position.y = on ? 0.14 : 0; + }); + } +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..dfb332e --- /dev/null +++ b/src/styles.css @@ -0,0 +1,141 @@ +:root { + --paper: #e6d8b6; + --paper-dark: #cbb88d; + --ink: #3a2d22; + --ink-soft: #6b5740; + --leather: #5a3d2b; + --gold: #c9a227; + --gold-soft: #e0c46a; + --bg: #14100d; + --red: #a03a2a; + --blue: #2c4a6b; + --green: #3f5c3a; + --serif: "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif; + --kai: "Kaiti SC", "STKaiti", "KaiTi", "Segoe Script", cursive; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + overflow: hidden; + background: var(--bg); + font-family: var(--serif); + color: var(--paper); +} + +#app { position: fixed; inset: 0; } +#scene { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } + +/* 气氛遮罩 */ +.vignette { + position: absolute; inset: 0; pointer-events: none; + background: radial-gradient(ellipse at 50% 42%, transparent 40%, rgba(8, 5, 3, 0.55) 100%); + mix-blend-mode: multiply; +} +.grain { + position: absolute; inset: 0; pointer-events: none; opacity: 0.06; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E"); +} +#iris { + position: absolute; inset: 0; pointer-events: none; + background: #0c0906; opacity: 0; transition: opacity 0.45s ease; +} + +/* 顶部标题 */ +.hud { position: absolute; z-index: 5; } +.top-left { top: 22px; left: 26px; } +.title { + font-size: clamp(20px, 2.4vw, 34px); + letter-spacing: 0.12em; font-weight: 600; color: var(--gold-soft); + text-shadow: 0 1px 12px rgba(0,0,0,0.7); +} +.subtitle { + margin-top: 4px; font-size: 12px; letter-spacing: 0.18em; + color: rgba(230,216,182,0.65); font-family: Georgia, serif; +} + +/* 视图切换 */ +.view-switch { + top: 26px; right: 26px; display: flex; gap: 8px; +} +.view-btn { + font-family: var(--serif); font-size: 14px; letter-spacing: 0.1em; + padding: 8px 16px; color: var(--paper); cursor: pointer; + background: rgba(20,14,10,0.55); border: 1px solid rgba(201,162,39,0.35); + border-radius: 3px; backdrop-filter: blur(4px); + transition: all 0.25s ease; +} +.view-btn:hover { border-color: var(--gold); color: #fff; } +.view-btn.active { + background: linear-gradient(180deg, rgba(201,162,39,0.85), rgba(150,115,20,0.85)); + color: #1c1308; border-color: var(--gold); font-weight: 600; +} + +/* 底部导航 */ +.bottom-bar { + left: 50%; bottom: 22px; transform: translateX(-50%); + display: flex; align-items: center; gap: 16px; + padding: 10px 18px; 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%; + 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; +} +.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: Georgia, serif; } +.sep { color: rgba(230,216,182,0.5); } +#page-total { color: rgba(230,216,182,0.6); font-family: Georgia, serif; } +.page-name { font-size: 14px; color: var(--paper); letter-spacing: 0.05em; margin-left: 10px; } + +/* 登记卡详情 */ +.detail-card { + right: 24px; bottom: 96px; width: min(320px, 82vw); + padding: 18px 20px; border-radius: 4px; + background: linear-gradient(180deg, rgba(232,216,182,0.96), rgba(214,196,156,0.96)); + color: var(--ink); box-shadow: 0 18px 50px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(90,61,43,0.4); + transform: translateY(0); transition: transform 0.35s ease, opacity 0.35s ease; +} +.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: Georgia, serif; font-size: 13px; letter-spacing: 0.14em; 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; +} +.detail-name { + margin: 8px 0 12px; font-size: 22px; letter-spacing: 0.06em; font-weight: 600; +} +.detail-fields { border-top: 1px solid rgba(90,61,43,0.3); padding-top: 8px; } +.field { display: flex; gap: 12px; font-size: 13px; line-height: 1.9; } +.field dt { + flex: 0 0 34px; color: var(--ink-soft); letter-spacing: 0.1em; +} +.field dd { color: var(--ink); } +.detail-desc { + margin-top: 10px; font-size: 13.5px; line-height: 1.75; color: var(--ink); + border-left: 3px solid var(--gold); padding-left: 10px; +} +.detail-sketch { margin-top: 12px; border-top: 1px dashed rgba(90,61,43,0.4); padding-top: 10px; } +.detail-sketch canvas { width: 100%; height: auto; border-radius: 2px; opacity: 0.94; } + +/* 提示 */ +.hint { + left: 26px; bottom: 26px; font-size: 12px; letter-spacing: 0.06em; + color: rgba(230,216,182,0.55); +} + +@media (max-width: 760px) { + .detail-card { display: none; } + .subtitle { display: none; } + .page-name { display: none; } + .hint { display: none; } +} diff --git a/src/textures/pages.js b/src/textures/pages.js new file mode 100644 index 0000000..de0d694 --- /dev/null +++ b/src/textures/pages.js @@ -0,0 +1,349 @@ +import * as THREE from 'three'; +import { CATEGORY_INFO } from '../data/entries.js'; +import { renderSketch } from './sketch.js'; + +// —— 页面几何 / 纹理常量 —— +export const PAGE_W = 1.6; // 世界单位:页宽 +export const PAGE_H = 2.24; // 世界单位:页高 +export const TEX_W = 768; +export const TEX_H = 1075; +export const HOLE_U = [0.085, 0.24, 0.4, 0.56, 0.72, 0.855]; // 活页孔(自顶部归一化) +export const HOLE_X = 0.065; // 孔中心相对页内缘(环前交点 0.085 − 页缘空隙 0.02) +export const HOLE_R = 0.052; // 孔半径(世界) + +const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",Georgia,serif'; +const KAI = '"Kaiti SC","STKaiti","KaiTi","Noto Serif SC",serif'; +const NUM = 'Georgia,"Times New Roman",serif'; + +// 版面边距(左边缘避开活页孔,右/上/下为常规边距) +const MARGIN = { left: 86, right: 58, top: 50, bottom: 50 }; +const CONTENT_L = MARGIN.left + 18; // 内容左界 +const CONTENT_R = TEX_W - MARGIN.right - 18; // 内容右界 +const CONTENT_W = CONTENT_R - CONTENT_L; // 内容宽度 + +// —— 基础纸张 —— +export function makePaper(w, h, { tone = 0, ruled = false, margin = false, seed = 0 } = {}) { + const c = document.createElement('canvas'); + c.width = w; c.height = h; + const ctx = c.getContext('2d'); + + // 底色(暖黄纸) + const base = [232 - tone, 216 - tone, 182 - tone]; + const g = ctx.createLinearGradient(0, 0, 0, h); + g.addColorStop(0, `rgb(${base[0] + 6},${base[1] + 5},${base[2] + 4})`); + g.addColorStop(0.5, `rgb(${base[0]},${base[1]},${base[2]})`); + g.addColorStop(1, `rgb(${base[0] - 8},${base[1] - 7},${base[2] - 6})`); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + + // 随机纤维与斑点 + const rand = mulberry(seed + 1); + for (let i = 0; i < w * h / 90; i++) { + const x = rand() * w, y = rand() * h; + const dark = rand() > 0.5; + ctx.fillStyle = dark ? 'rgba(120,95,60,0.10)' : 'rgba(255,250,230,0.14)'; + ctx.fillRect(x, y, rand() * 2 + 0.4, rand() * 2 + 0.4); + } + // 纤维线条 + ctx.strokeStyle = 'rgba(140,110,70,0.07)'; + ctx.lineWidth = 0.7; + for (let i = 0; i < w * h / 2600; i++) { + const x = rand() * w, y = rand() * h, a = rand() * Math.PI, l = rand() * 22 + 6; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + Math.cos(a) * l, y + Math.sin(a) * l); + ctx.stroke(); + } + // 纸面晕染 + for (let i = 0; i < 16; i++) { + const x = rand() * w, y = rand() * h, r = rand() * 140 + 40; + const gr = ctx.createRadialGradient(x, y, 0, x, y, r); + const warm = rand() > 0.5; + gr.addColorStop(0, warm ? 'rgba(180,150,95,0.05)' : 'rgba(120,100,70,0.05)'); + gr.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = gr; + ctx.fillRect(0, 0, w, h); + } + + // 边缘做旧(暗角) + const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.4, w / 2, h / 2, Math.max(w, h) * 0.72); + edge.addColorStop(0, 'rgba(0,0,0,0)'); + edge.addColorStop(1, 'rgba(90,60,30,0.16)'); + ctx.fillStyle = edge; + ctx.fillRect(0, 0, w, h); + + // 横格线 + if (ruled) { + ctx.strokeStyle = 'rgba(120,95,60,0.28)'; + ctx.lineWidth = 0.8; + for (let y = 90; y < h - 60; y += 34) { + ctx.beginPath(); ctx.moveTo(margin ? 84 : 40, y); ctx.lineTo(w - 40, y); ctx.stroke(); + } + } + // 红色边线 + if (margin) { + ctx.strokeStyle = 'rgba(160,60,40,0.45)'; + ctx.lineWidth = 1.1; + ctx.beginPath(); ctx.moveTo(84, 40); ctx.lineTo(84, h - 40); ctx.stroke(); + } + return { canvas: c, ctx }; +} + +function mulberry(seed) { + let a = seed || 1; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// 画活页孔 +function drawHoles(ctx, w, h) { + const hx = (HOLE_X / PAGE_W) * w; + const hr = (HOLE_R / PAGE_W) * w; + for (const u of HOLE_U) { + const y = u * h; + // 孔内阴影(穿过去的感觉) + const g = ctx.createRadialGradient(hx, y, 0, hx, y, hr); + g.addColorStop(0, '#2b1f13'); + g.addColorStop(0.62, '#4a3826'); + g.addColorStop(0.82, '#6b5740'); + g.addColorStop(1, '#8a735a'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(hx, y, hr, 0, Math.PI * 2); ctx.fill(); + // 孔缘 + ctx.strokeStyle = 'rgba(70,50,32,0.85)'; + ctx.lineWidth = 2.2; + ctx.beginPath(); ctx.arc(hx, y, hr, 0, Math.PI * 2); ctx.stroke(); + ctx.strokeStyle = 'rgba(255,244,214,0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(hx, y, hr - 2, Math.PI * 0.15, Math.PI * 0.85); ctx.stroke(); + } +} + +// 描边 + 填充的通用文字 +function text(ctx, str, x, y, { font, size, color = '#3a2d22', align = 'left', baseline = 'alphabetic', weight = 'normal', ls = 0 } = {}) { + ctx.font = `${weight} ${size}px ${font}`; + ctx.fillStyle = color; + ctx.textAlign = align; + ctx.textBaseline = baseline; + ctx.fillText(str, x, y); +} + +// 双线装饰框(左边缘避开活页孔) +function drawBorder(ctx, w, h, color = 'rgba(90,61,43,0.55)') { + const m = MARGIN; + ctx.strokeStyle = color; + ctx.lineWidth = 1.4; + ctx.strokeRect(m.left, m.top, w - m.left - m.right, h - m.top - m.bottom); + ctx.lineWidth = 0.7; + ctx.strokeRect(m.left + 8, m.top + 8, w - m.left - m.right - 16, h - m.top - m.bottom - 16); + // 角饰 + ctx.fillStyle = color; + const corners = [[m.left, m.top], [w - m.right, m.top], [m.left, h - m.bottom], [w - m.right, h - m.bottom]]; + for (const [cx, cy] of corners) { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(Math.PI / 4); + ctx.fillRect(-3, -3, 6, 6); + ctx.restore(); + } +} + +// 类别印章 +function drawSeal(ctx, category, x, y, size = 52) { + const info = CATEGORY_INFO[category] || CATEGORY_INFO['事']; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(-0.05); + ctx.fillStyle = info.color; + ctx.globalAlpha = 0.92; + ctx.fillRect(-size / 2, -size / 2, size, size); + ctx.strokeStyle = 'rgba(255,240,210,0.85)'; + ctx.lineWidth = 2; + ctx.strokeRect(-size / 2 + 4, -size / 2 + 4, size - 8, size - 8); + ctx.fillStyle = '#f5e8cf'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = `${size * 0.62}px ${KAI}`; + ctx.fillText(category, 0, 2); + ctx.restore(); + ctx.globalAlpha = 1; +} + +// —— 正面:登记卡 —— +export function renderFront(entry) { + const w = TEX_W, h = TEX_H; + const { canvas, ctx } = makePaper(w, h, { seed: hash(entry.id) }); + const info = CATEGORY_INFO[entry.category] || CATEGORY_INFO['事']; + + drawBorder(ctx, w, h); + drawHoles(ctx, w, h); + + // 页眉:编号 + 印章 + text(ctx, '编 号 · ' + entry.id, CONTENT_L, 92, { font: NUM, size: 26, color: '#5a432c' }); + drawSeal(ctx, entry.category, CONTENT_R - 30, 86, 58); + text(ctx, info.en, CONTENT_R - 30, 128, { font: NUM, size: 15, color: '#7a6147', align: 'center' }); + + // 顶部分隔线 + ctx.strokeStyle = 'rgba(90,61,43,0.4)'; + 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)}px ${KAI}`; + 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(); + + // 登记字段 + 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.category} · ${info.en}`], + ]; + 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 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(); + + ctx.font = `400 24px ${SERIF}`; + ctx.fillStyle = '#33271a'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + const maxW = CONTENT_W; + let line = '', yy = dy + 40; + for (const ch of entry.description) { + const test = line + ch; + if (ctx.measureText(test).width > maxW && line) { + ctx.fillText(line, CONTENT_L, yy); + line = ch; yy += 40; + } else line = test; + } + if (line) ctx.fillText(line, CONTENT_L, yy); + + // 页脚 + text(ctx, '人类文明里程碑 · 活页编年史', w / 2, h - 52, { font: SERIF, size: 19, color: '#7a6147', align: 'center' }); + text(ctx, entry.id, CONTENT_R, h - 52, { font: NUM, size: 18, color: '#8a735a', align: 'right' }); + + return canvas; +} + +// —— 背面:素描 —— +export function renderBack(entry) { + const w = TEX_W, h = TEX_H; + const { canvas, ctx } = makePaper(w, h, { seed: hash(entry.id) + 7 }); + + drawBorder(ctx, w, h); + drawHoles(ctx, w, h); + + // 内容水平镜像(背面用 BackSide 渲染,镜像后正读)——关于竖直中线镜像 + ctx.save(); + ctx.translate(w / 2, 0); + ctx.scale(-1, 1); + ctx.translate(-w / 2, 0); + + // 页眉(居中) + text(ctx, '素 描 · SKETCH', w / 2, 92, { font: NUM, size: 24, color: '#6b5740', align: 'center' }); + text(ctx, entry.id, w / 2, 124, { font: NUM, size: 20, color: '#8a735a', align: 'center' }); + ctx.strokeStyle = 'rgba(90,61,43,0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(w / 2 - 90, 152); ctx.lineTo(w / 2 + 90, 152); ctx.stroke(); + + // 速写(居中) + const sketch = renderSketch(entry, 520); + const sx = (w - 520) / 2; + const sy = 170; + ctx.drawImage(sketch, sx, sy); + ctx.strokeStyle = 'rgba(90,61,43,0.3)'; + ctx.lineWidth = 0.8; + ctx.strokeRect(sx - 8, sy - 8, 520 + 16, 520 + 16); + + // 题注(居中) + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = `700 ${fitFont(ctx, entry.name, CONTENT_W - 40, 32, 78)}px ${KAI}`; + ctx.fillStyle = '#33271a'; + ctx.fillText(entry.name, w / 2, 772); + + ctx.font = `400 25px ${SERIF}`; + ctx.fillStyle = '#6b5740'; + ctx.fillText(`${entry.time} · ${entry.region}`, w / 2, 828); + + // 波浪线(居中) + ctx.strokeStyle = 'rgba(90,61,43,0.5)'; + ctx.lineWidth = 1.4; + ctx.beginPath(); + for (let x = w / 2 - 130; x <= w / 2 + 130; x += 10) { + const yy = 868 + Math.sin((x - w / 2) / 26) * 5; + if (x === w / 2 - 130) ctx.moveTo(x, yy); else ctx.lineTo(x, yy); + } + ctx.stroke(); + + ctx.restore(); + return canvas; +} + +// 名称自适应字号 +function fitFont(ctx, str, maxW, minSize, maxSize) { + if (minSize > maxSize) [minSize, maxSize] = [maxSize, minSize]; // 容错:确保 min < max + let size = maxSize; + ctx.font = `700 ${size}px ${KAI}`; + while (ctx.measureText(str).width > maxW && size > minSize) { + size -= 2; + ctx.font = `700 ${size}px ${KAI}`; + } + return size; +} + +function hash(str) { + let h = 0; + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0; + return h; +} + +// 生成页面的两个纹理 +const texCache = new Map(); +export function getPageTextures(entry) { + if (texCache.has(entry.id)) return texCache.get(entry.id); + const front = new THREE.CanvasTexture(renderFront(entry)); + front.colorSpace = THREE.SRGBColorSpace; + front.anisotropy = 8; + + const back = new THREE.CanvasTexture(renderBack(entry)); + back.colorSpace = THREE.SRGBColorSpace; + back.anisotropy = 8; + + const pair = { front, back }; + texCache.set(entry.id, pair); + return pair; +} diff --git a/src/textures/sketch.js b/src/textures/sketch.js new file mode 100644 index 0000000..98fd499 --- /dev/null +++ b/src/textures/sketch.js @@ -0,0 +1,587 @@ +// 程序化"素描"生成器:用带手绘抖动的线条绘制各条目的速写。 +// 同一词条每次渲染结果一致(基于名称的种子),供 3D 页背与 HTML 面板复用。 + +const INK = '#3a2d22'; +const INK_SOFT = 'rgba(58,45,34,0.55)'; +const INK_FAINT = 'rgba(58,45,34,0.28)'; +const WASH = 'rgba(150,120,80,0.10)'; + +// 确定性伪随机数(mulberry32) +function prng(seedStr) { + let a = 0; + for (let i = 0; i < seedStr.length; i++) { + a = (a * 31 + seedStr.charCodeAt(i)) >>> 0; + } + a = a || 123456789; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function renderSketch(entry, size = 760) { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + const rand = prng(entry.name + entry.sketch); + + ctx.clearRect(0, 0, size, size); + const cx = size / 2; + const cy = size / 2; + const r = size * 0.42; + + const P = (x, y) => [cx + x * r, cy + y * r]; + + // 手绘折线 + function line(pts, { color = INK, lw = 2.4, passes = 2, wobble = 0.015, close = false, dash = null } = {}) { + ctx.strokeStyle = color; + ctx.lineWidth = lw * (size / 760); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + if (dash) ctx.setLineDash(dash); + else ctx.setLineDash([]); + for (let p = 0; p < passes; p++) { + ctx.beginPath(); + pts.forEach((pt, i) => { + const [x, y] = P(pt[0], pt[1]); + const jx = wobble * r * (rand() - 0.5) * 2; + const jy = wobble * r * (rand() - 0.5) * 2; + if (i === 0) ctx.moveTo(x + jx, y + jy); + else ctx.lineTo(x + jx, y + jy); + }); + if (close) ctx.closePath(); + ctx.stroke(); + } + ctx.setLineDash([]); + } + + // 形状填充(可选淡淡水彩底) + function fill(pts, color = WASH) { + ctx.fillStyle = color; + ctx.beginPath(); + pts.forEach((pt, i) => { + const [x, y] = P(pt[0], pt[1]); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + }); + ctx.closePath(); + ctx.fill(); + } + + // 阴影排线(在裁剪区域内) + function hatch(pts, { spacing = 0.035, angle = -45, color = INK_SOFT, lw = 1.1, box = 2.4 } = {}) { + ctx.save(); + ctx.beginPath(); + pts.forEach((pt, i) => { + const [x, y] = P(pt[0], pt[1]); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + }); + ctx.closePath(); + ctx.clip(); + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate((angle * Math.PI) / 180); + ctx.strokeStyle = color; + ctx.lineWidth = lw * (size / 760); + ctx.beginPath(); + const L = box * r; + const s = spacing * r; + for (let i = -60; i < 60; i++) { + ctx.moveTo(i * s, -L); + ctx.lineTo(i * s, L); + } + ctx.stroke(); + ctx.restore(); + ctx.restore(); + } + + function circle(cx0, cy0, rad, opts = {}) { + const { color = INK, lw = 2.2, passes = 1, fill: f = null } = opts; + const [x, y] = P(cx0, cy0); + const rr = rad * r; + if (f) { ctx.fillStyle = f; ctx.beginPath(); ctx.arc(x, y, rr, 0, Math.PI * 2); ctx.fill(); } + ctx.strokeStyle = color; + ctx.lineWidth = lw * (size / 760); + for (let p = 0; p < passes; p++) { + ctx.beginPath(); + const j = (rand() - 0.5) * r * 0.02; + ctx.arc(x + j, y + j, rr, 0, Math.PI * 2); + ctx.stroke(); + } + } + + function dot(x, y, rad, color = INK) { + const [px, py] = P(x, y); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(px, py, rad * r, 0, Math.PI * 2); + ctx.fill(); + } + + // 各类型速写 + const types = { + fossil() { + // 头骨 + fill([[0, 0.35], [-0.55, 0.05], [-0.5, -0.5], [-0.2, -0.78], [0.3, -0.75], [0.58, -0.4], [0.5, 0.0], [0.3, 0.1]], 'rgba(150,120,80,0.12)'); + line([[0, 0.32], [-0.52, 0.05], [-0.5, -0.42], [-0.3, -0.66], [0.0, -0.74], [0.32, -0.68], [0.55, -0.4], [0.5, 0.0], [0.32, 0.12]], { lw: 2.8 }); + // 眼窝 + circle(-0.22, -0.18, 0.16, { lw: 2 }); + circle(0.28, -0.18, 0.15, { lw: 2 }); + // 鼻腔 + line([[-0.02, -0.05], [0.0, 0.14], [-0.16, 0.14]], { lw: 2 }); + line([[-0.02, -0.05], [0.0, 0.14], [0.14, 0.14]], { lw: 1.4 }); + // 上颌与牙齿 + line([[0.05, 0.12], [0.22, 0.1], [0.32, 0.12]], { lw: 2.2 }); + for (let i = 0; i < 5; i++) { + line([[-0.42 + i * 0.16, 0.02], [-0.42 + i * 0.16, 0.16]], { lw: 1.2, passes: 1, wobble: 0.004 }); + } + // 骨裂纹理 + line([[-0.1, -0.7], [-0.16, -0.55], [-0.05, -0.45]], { color: INK_FAINT, lw: 1 }); + line([[0.2, -0.66], [0.1, -0.55], [0.18, -0.42]], { color: INK_FAINT, lw: 1 }); + // 下方地平线与年代波纹 + line([[-0.6, 0.5], [0.6, 0.5]], { color: INK_FAINT, lw: 1.2, passes: 1 }); + line([[-0.45, 0.62], [0.45, 0.62]], { color: INK_FAINT, lw: 1, passes: 1 }); + }, + + tablet() { + fill([[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]], 'rgba(170,140,95,0.16)'); + line([[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5], [-0.5, -0.5]], { lw: 3, wobble: 0.01 }); + // 楔形文字符号:小三角 + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 6; col++) { + const x = -0.36 + col * 0.15; + const y = -0.34 + row * 0.18; + const s = 0.04 + rand() * 0.02; + line([[x, y], [x - s, y - s * 1.4]], { lw: 1.6, passes: 1, wobble: 0.005 }); + line([[x, y], [x + s, y - s * 1.4]], { lw: 1.6, passes: 1, wobble: 0.005 }); + line([[x, y], [x, y - s * 1.8]], { lw: 1.6, passes: 1, wobble: 0.005 }); + } + } + // 裂缝 + line([[-0.1, 0.44], [0.02, 0.2], [-0.08, -0.05], [0.05, -0.3]], { color: INK_SOFT, lw: 1.4, passes: 1 }); + line([[-0.44, -0.1], [-0.25, -0.15]], { color: INK_SOFT, lw: 1.2, passes: 1 }); + }, + + pyramid() { + // 地平线 + line([[-0.72, 0.42], [0.72, 0.42]], { lw: 2 }); + // 主金字塔 + fill([[-0.52, 0.42], [0.0, -0.62], [0.55, 0.42]], 'rgba(160,120,70,0.12)'); + line([[-0.52, 0.42], [0.0, -0.62], [0.55, 0.42], [-0.52, 0.42]], { lw: 3 }); + // 石砌纹理 + for (let i = 1; i <= 5; i++) { + const t = i / 6; + const xl = -0.52 + (0.52) * t; + const xr = 0.55 - (0.55) * t; + const y = 0.42 - (1.04) * t; + line([[xl, y], [xr, y]], { color: INK_SOFT, lw: 1.1, passes: 1, wobble: 0.004 }); + } + // 小金字塔 + line([[-0.72, 0.42], [-0.5, 0.0], [-0.28, 0.42]], { lw: 2 }); + line([[-0.72, 0.42], [-0.5, 0.0], [-0.28, 0.42], [-0.72, 0.42]], { lw: 2, color: INK_SOFT }); + // 太阳 + circle(0.55, -0.55, 0.16, { lw: 2.4 }); + for (let i = 0; i < 10; i++) { + const a = (i / 10) * Math.PI * 2 + 0.3; + line([[0.55 + Math.cos(a) * 0.2, -0.55 + Math.sin(a) * 0.2], [0.55 + Math.cos(a) * 0.28, -0.55 + Math.sin(a) * 0.28]], { lw: 1.6, passes: 1 }); + } + // 沙丘波纹 + line([[-0.7, 0.62], [-0.2, 0.62]], { color: INK_FAINT, lw: 1.2, passes: 1 }); + line([[0.1, 0.66], [0.65, 0.66]], { color: INK_FAINT, lw: 1.2, passes: 1 }); + }, + + figure() { + // 圆形徽章背景 + circle(0, 0, 0.85, { color: INK_FAINT, lw: 1.4, passes: 1 }); + circle(0, 0, 0.72, { color: INK_FAINT, lw: 1, passes: 1 }); + // 头部轮廓(侧影) + fill([[0.18, -0.28], [0.18, -0.1], [0.24, 0.04], [0.3, 0.14], [0.26, 0.24], [0.2, 0.3], [0.24, 0.5], [0.05, 0.55], [-0.05, 0.5], [-0.02, 0.34], [0.0, 0.24], [-0.05, 0.12], [-0.04, -0.1], [-0.05, -0.4], [0.02, -0.6], [0.12, -0.66], [0.18, -0.28]], 'rgba(120,95,70,0.12)'); + line([[0.05, -0.62], [0.16, -0.56], [0.18, -0.3], [0.2, -0.12], [0.25, 0.02], [0.3, 0.12], [0.27, 0.22], [0.21, 0.3], [0.25, 0.46], [0.14, 0.55], [0.02, 0.56], [-0.04, 0.5], [-0.02, 0.34], [0.0, 0.24], [-0.05, 0.14], [-0.05, -0.08], [-0.04, -0.3], [0.0, -0.52], [0.05, -0.62]], { lw: 2.8 }); + // 发髻 / 头发 + line([[0.05, -0.62], [-0.02, -0.5], [0.02, -0.38], [-0.03, -0.26], [0.0, -0.14]], { lw: 2, color: INK_SOFT }); + hatch([[0.02, -0.6], [0.16, -0.56], [0.18, -0.3], [0.0, -0.52], [0.02, -0.6]], { spacing: 0.03, angle: 60, color: INK_SOFT, lw: 1 }); + // 眼睛、眉、耳 + line([[0.2, -0.02], [0.28, -0.02], [0.25, 0.04], [0.2, -0.02]], { lw: 1.8, passes: 1 }); + line([[0.19, -0.1], [0.3, -0.07]], { lw: 1.8, passes: 1 }); + line([[-0.05, -0.08], [-0.02, -0.2]], { lw: 1.6, passes: 1 }); + // 肩部与衣领 + line([[-0.28, 0.55], [0.0, 0.55]], { lw: 2.6 }); + line([[-0.55, 0.85], [0.0, 0.55], [0.6, 0.85]], { lw: 2.4 }); + line([[-0.42, 0.7], [0.05, 0.58]], { lw: 1.6, color: INK_SOFT, passes: 1 }); + }, + + temple() { + // 台基 + line([[-0.7, 0.6], [0.7, 0.6]], { lw: 2.6 }); + line([[-0.64, 0.66], [0.64, 0.66]], { lw: 2 }); + // 柱子 + for (let i = 0; i < 6; i++) { + const x = -0.5 + i * 0.2; + line([[x, 0.58], [x, -0.1]], { lw: 2 }); + line([[x - 0.02, 0.58], [x - 0.02, -0.1]], { lw: 1, color: INK_SOFT, passes: 1 }); + } + // 柱顶横梁 + line([[-0.6, -0.1], [0.6, -0.1]], { lw: 2.4 }); + line([[-0.58, -0.18], [0.58, -0.18]], { lw: 2 }); + // 三角楣 + fill([[-0.58, -0.18], [0.0, -0.66], [0.58, -0.18]], 'rgba(140,110,70,0.10)'); + line([[-0.58, -0.18], [0.0, -0.66], [0.58, -0.18], [-0.58, -0.18]], { lw: 2.6 }); + // 楣内浮雕圆 + circle(0, -0.4, 0.1, { lw: 1.8, passes: 1 }); + // 山门台阶 + for (let i = 1; i <= 3; i++) { + line([[-0.3 - i * 0.08, 0.66 + i * 0.05], [0.3 + i * 0.08, 0.66 + i * 0.05]], { lw: 1.6, color: INK_SOFT, passes: 1 }); + } + }, + + crown() { + // 冠身 + fill([[-0.55, 0.5], [-0.55, -0.1], [0.55, -0.1], [0.55, 0.5]], 'rgba(160,120,50,0.14)'); + line([[-0.55, 0.5], [-0.55, -0.1], [0.55, -0.1], [0.55, 0.5]], { lw: 3 }); + // 齿尖与宝珠 + const peaks = [-0.55, -0.28, 0.0, 0.28, 0.55]; + peaks.forEach((px, i) => { + const h = 0.5 + (i % 2 === 0 ? 0.26 : 0.4); + line([[px, 0.5], [px + (i % 2 === 0 ? 0.02 : 0), h]], { lw: 2.4 }); + dot(px, h + 0.07, 0.05, '#a03a2a'); + }); + // 冠带 + line([[-0.58, 0.28], [0.58, 0.28]], { lw: 2, color: INK_SOFT }); + // 中央纹饰 + circle(0, 0.12, 0.14, { lw: 2, passes: 1 }); + dot(0, 0.12, 0.05, '#a03a2a'); + // 底部绒边 + for (let i = 0; i < 14; i++) { + const x = -0.55 + i * 0.085; + dot(x, -0.12, 0.02, INK_SOFT); + } + hatch([[-0.55, 0.5], [-0.55, -0.1], [0.55, -0.1], [0.55, 0.5]], { spacing: 0.035, angle: -60, color: INK_FAINT, lw: 1 }); + }, + + colosseum() { + // 底层地面 + line([[-0.75, 0.6], [0.75, 0.6]], { lw: 2 }); + // 三层椭圆拱券 + const tiers = [ + { y: 0.12, rw: 0.68, rh: 0.16, n: 7 }, + { y: -0.1, rw: 0.74, rh: 0.15, n: 8 }, + { y: -0.32, rw: 0.8, rh: 0.14, n: 9 }, + ]; + tiers.forEach((t) => { + // 顶部线(略残缺) + line([[-t.rw, t.y - t.rh], [t.rw, t.y - t.rh]], { lw: 2.2 }); + }); + tiers.forEach((t) => { + for (let i = 0; i <= t.n; i++) { + const a = -0.9 + (i / t.n) * 1.8; // 参数 -0.9..0.9 + const x = a * t.rw; + const y = t.y; + // 拱券(简化小竖线 + 弧) + line([[x, y], [x, y + t.rh * 0.7]], { lw: 1.6, color: INK_SOFT, passes: 1 }); + if (i < t.n) { + const x2 = (a + 0.9 / t.n) * t.rw; + const mx = (x + x2) / 2; + line([[x, y + t.rh * 0.7], [mx, y + t.rh * 1.15], [x2, y + t.rh * 0.7]], { lw: 1.4, color: INK_SOFT, passes: 1 }); + } + } + }); + // 残破上缘 + line([[-0.82, -0.48], [-0.6, -0.42], [-0.4, -0.55], [-0.1, -0.4], [0.2, -0.52], [0.5, -0.4], [0.82, -0.46]], { lw: 2.2, passes: 1 }); + line([[-0.82, 0.56], [-0.85, -0.3]], { lw: 2, passes: 1 }); + line([[0.82, 0.56], [0.85, -0.3]], { lw: 2, passes: 1 }); + }, + + scroll() { + // 卷轴两端 + circle(-0.52, 0, 0.09, { lw: 2.2 }); + circle(0.52, 0, 0.09, { lw: 2.2 }); + // 展开的纸 + fill([[-0.52, -0.28], [0.52, -0.28], [0.52, 0.28], [-0.52, 0.28]], 'rgba(210,190,150,0.20)'); + line([[-0.52, -0.28], [0.52, -0.28]], { lw: 2.4 }); + line([[-0.52, 0.28], [0.52, 0.28]], { lw: 2.4 }); + // 竖排文字行 + for (let i = 0; i < 7; i++) { + const x = -0.38 + i * 0.13; + line([[x, -0.18], [x, 0.18]], { lw: 1.3, color: INK_SOFT, passes: 1, wobble: 0.005 }); + } + // 毛笔 + line([[0.05, -0.6], [0.02, -0.9]], { lw: 2.2 }); + line([[0.0, -0.92], [0.14, -0.98], [0.05, -0.72]], { lw: 2, color: INK_SOFT, passes: 1 }); + // 墨滴 + dot(0.3, 0.42, 0.03, INK_SOFT); + dot(0.36, 0.46, 0.02, INK_FAINT); + }, + + horseman() { + // 地平线 + line([[-0.75, 0.55], [0.75, 0.55]], { lw: 1.8, color: INK_FAINT }); + // 马(剪影线条) + fill([[0.2, 0.5], [0.32, 0.05], [0.55, 0.0], [0.6, 0.3], [0.42, 0.42], [0.2, 0.5]], 'rgba(90,65,45,0.10)'); + line([[0.25, 0.5], [0.35, 0.15], [0.42, 0.05], [0.58, 0.05], [0.6, 0.28], [0.45, 0.4], [0.25, 0.5]], { lw: 2.6 }); + // 马腿 + line([[0.3, 0.45], [0.26, 0.55]], { lw: 2 }); + line([[0.44, 0.42], [0.42, 0.55]], { lw: 2 }); + line([[0.55, 0.3], [0.6, 0.55]], { lw: 2 }); + // 马尾 + line([[0.25, 0.5], [0.08, 0.35], [0.02, 0.5]], { lw: 2, color: INK_SOFT }); + // 骑手 + line([[-0.05, 0.4], [-0.02, 0.15], [0.05, 0.05], [0.12, 0.15], [0.1, 0.4]], { lw: 2.4 }); + circle(-0.02, -0.02, 0.09, { lw: 1.8, passes: 1 }); + // 弓 + line([[-0.05, 0.18], [-0.3, 0.28]], { lw: 1.6, passes: 1 }); + line([[-0.3, 0.28], [-0.32, 0.1]], { lw: 1.6, color: INK_SOFT, passes: 1 }); + // 箭 + line([[-0.28, 0.24], [-0.55, 0.14]], { lw: 1.4, passes: 1 }); + // 旗帜 + line([[0.1, 0.15], [0.4, -0.3]], { lw: 1.8 }); + line([[0.4, -0.3], [0.32, -0.32], [0.38, -0.4], [0.42, -0.28]], { lw: 1.6, passes: 1 }); + }, + + ship() { + // 波浪 + line([[-0.75, 0.5], [-0.4, 0.5], [-0.2, 0.42], [0.0, 0.5], [0.3, 0.42], [0.55, 0.5], [0.75, 0.5]], { lw: 1.6, color: INK_SOFT, passes: 1 }); + line([[-0.7, 0.66], [0.7, 0.66]], { lw: 1.4, color: INK_FAINT, passes: 1 }); + // 船体 + fill([[-0.5, 0.12], [0.5, 0.12], [0.38, 0.4], [-0.4, 0.4]], 'rgba(80,60,40,0.14)'); + line([[-0.5, 0.12], [0.5, 0.12], [0.4, 0.38], [-0.42, 0.38], [-0.5, 0.12]], { lw: 2.6 }); + // 桅杆 + line([[0.0, 0.1], [0.0, -0.7]], { lw: 2.2 }); + line([[-0.35, 0.14], [-0.35, -0.45]], { lw: 1.8 }); + line([[0.35, 0.14], [0.35, -0.45]], { lw: 1.8 }); + // 帆(鼓风) + fill([[0.0, -0.62], [0.42, -0.5], [0.3, -0.2], [0.0, -0.28]], 'rgba(200,180,140,0.22)'); + line([[0.0, -0.62], [0.4, -0.5], [0.3, -0.2], [0.0, -0.28], [0.0, -0.62]], { lw: 1.8 }); + fill([[-0.35, -0.4], [-0.05, -0.45], [-0.08, -0.2], [-0.35, -0.15]], 'rgba(200,180,140,0.18)'); + line([[-0.35, -0.4], [-0.06, -0.44], [-0.1, -0.18], [-0.35, -0.14], [-0.35, -0.4]], { lw: 1.6 }); + fill([[0.35, -0.4], [0.65, -0.48], [0.62, -0.2], [0.35, -0.15]], 'rgba(200,180,140,0.18)'); + line([[0.35, -0.4], [0.62, -0.48], [0.6, -0.18], [0.35, -0.14], [0.35, -0.4]], { lw: 1.6 }); + // 旗帜 + line([[0.0, -0.7], [0.0, -0.78]], { lw: 1.6 }); + line([[0.0, -0.76], [0.2, -0.74], [0.0, -0.7]], { lw: 1.4, passes: 1 }); + // 海鸟 + line([[-0.6, -0.4], [-0.5, -0.46], [-0.4, -0.4]], { lw: 1.2, color: INK_SOFT, passes: 1 }); + line([[-0.3, -0.55], [-0.2, -0.61], [-0.1, -0.55]], { lw: 1.2, color: INK_SOFT, passes: 1 }); + }, + + globe() { + circle(0, 0, 0.66, { lw: 2.6 }); + // 经纬线 + circle(0, 0, 0.66, { lw: 1, color: INK_FAINT, passes: 1 }); + for (const k of [0.3, 0.6]) { + ctx.save(); + ctx.translate(cx, cy); + ctx.strokeStyle = INK_FAINT; + ctx.lineWidth = 1 * (size / 760); + ctx.beginPath(); + ctx.ellipse(0, 0, 0.66 * r * Math.sqrt(1 - k * k), 0.66 * r, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.ellipse(0, 0, 0.66 * r, 0.66 * r * Math.sqrt(1 - k * k), 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + // 大陆点阵 + for (let i = 0; i < 60; i++) { + const a = rand() * Math.PI * 2; + const rr = Math.sqrt(rand()) * 0.6; + dot(Math.cos(a) * rr, Math.sin(a) * rr, 0.012, INK_SOFT); + } + // 航线(虚线)与箭头 + line([[0.3, -0.3], [0.55, -0.15], [0.6, 0.1], [0.45, 0.35], [0.15, 0.45]], { color: '#a03a2a', lw: 2, passes: 1, dash: [14, 9] }); + line([[0.15, 0.45], [0.02, 0.52]], { color: '#a03a2a', lw: 2, passes: 1 }); + line([[0.15, 0.45], [0.22, 0.55]], { color: '#a03a2a', lw: 2, passes: 1 }); + // 起止点 + dot(0.3, -0.3, 0.04, '#a03a2a'); + dot(0.15, 0.45, 0.04, '#a03a2a'); + }, + + apple() { + // 苹果 + fill([[0, -0.55], [0.35, -0.42], [0.42, -0.1], [0.28, 0.22], [0.0, 0.42], [-0.3, 0.2], [-0.4, -0.15], [-0.3, -0.44]], 'rgba(150,60,40,0.14)'); + line([[0.02, -0.5], [0.36, -0.4], [0.44, -0.08], [0.28, 0.24], [0.0, 0.44], [-0.3, 0.2], [-0.42, -0.16], [-0.32, -0.42]], { lw: 2.6 }); + line([[0.02, -0.5], [0.36, -0.4]], { lw: 2.6 }); + // 咬痕 + circle(-0.52, -0.05, 0.18, { lw: 2, passes: 1, color: INK_SOFT }); + // 果柄与叶 + line([[0.0, -0.5], [0.02, -0.72]], { lw: 2.2 }); + fill([[0.02, -0.72], [0.28, -0.78], [0.2, -0.58]], 'rgba(60,90,50,0.16)'); + line([[0.02, -0.72], [0.28, -0.76], [0.2, -0.6], [0.02, -0.72]], { lw: 1.8 }); + // 下落的轨迹(引力) + line([[-0.1, 0.55], [-0.25, 0.72]], { color: INK_SOFT, lw: 1.6, passes: 1, dash: [10, 8] }); + dot(-0.3, 0.78, 0.03, INK_SOFT); + line([[0.55, 0.5], [0.62, 0.62]], { color: INK_FAINT, lw: 1.2, passes: 1 }); + }, + + machine() { + // 底座 + line([[-0.7, 0.55], [0.7, 0.55]], { lw: 2.4 }); + // 锅炉 + fill([[-0.45, -0.2], [0.1, -0.2], [0.1, 0.1], [-0.45, 0.1]], 'rgba(90,70,50,0.14)'); + line([[-0.45, -0.2], [0.1, -0.2], [0.1, 0.1], [-0.45, 0.1], [-0.45, -0.2]], { lw: 2.4 }); + // 铆钉 + for (let i = 0; i < 5; i++) dot(-0.4 + i * 0.12, -0.2, 0.02, INK_SOFT); + for (let i = 0; i < 5; i++) dot(-0.4 + i * 0.12, 0.1, 0.02, INK_SOFT); + // 活塞杆 + line([[0.1, -0.05], [0.42, -0.05]], { lw: 2.2 }); + line([[0.42, -0.1], [0.42, 0.0]], { lw: 1.6 }); + // 飞轮 + circle(0.5, -0.05, 0.3, { lw: 2.4 }); + circle(0.5, -0.05, 0.08, { lw: 1.8, passes: 1 }); + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2; + line([[0.5 + Math.cos(a) * 0.08, -0.05 + Math.sin(a) * 0.08], [0.5 + Math.cos(a) * 0.3, -0.05 + Math.sin(a) * 0.3]], { lw: 1.4, passes: 1 }); + } + // 蒸汽 + line([[-0.3, -0.32], [-0.2, -0.45], [-0.32, -0.6]], { color: INK_SOFT, lw: 1.8, passes: 1 }); + line([[-0.1, -0.4], [0.0, -0.55], [-0.08, -0.72]], { color: INK_SOFT, lw: 1.8, passes: 1 }); + line([[0.1, -0.34], [0.22, -0.48], [0.14, -0.62]], { color: INK_FAINT, lw: 1.6, passes: 1 }); + // 传动带 + line([[0.1, -0.05], [0.2, -0.1], [0.5, -0.32]], { lw: 1.2, color: INK_SOFT, passes: 1, dash: [9, 7] }); + }, + + bell() { + // 钟轭 + line([[-0.5, -0.55], [0.5, -0.55]], { lw: 2.6 }); + line([[-0.5, -0.55], [-0.42, -0.28]], { lw: 2 }); + line([[0.5, -0.55], [0.42, -0.28]], { lw: 2 }); + // 钟身 + fill([[-0.42, -0.28], [0.42, -0.28], [0.55, 0.45], [0.3, 0.5], [-0.3, 0.5], [-0.55, 0.45]], 'rgba(120,95,70,0.12)'); + line([[-0.42, -0.28], [0.42, -0.28], [0.55, 0.45], [0.3, 0.5], [-0.3, 0.5], [-0.55, 0.45], [-0.42, -0.28]], { lw: 2.6 }); + // 底口线 + line([[-0.55, 0.45], [0.55, 0.45]], { lw: 2 }); + // 裂纹(自由钟标志) + line([[-0.05, -0.1], [0.02, 0.05], [-0.08, 0.22], [0.0, 0.42]], { lw: 2, color: '#a03a2a', passes: 1 }); + // 铭文带 + line([[-0.36, 0.05], [0.36, 0.05]], { lw: 1.4, color: INK_SOFT, passes: 1 }); + // 钟舌 + line([[0.0, -0.1], [0.0, 0.3]], { lw: 1.8 }); + circle(0, 0.32, 0.07, { lw: 1.6, passes: 1 }); + }, + + tree() { + // 树干 + line([[-0.0, 0.55], [0.0, 0.2], [0.02, -0.1]], { lw: 2.6 }); + // 树根 + line([[-0.15, 0.55], [0.0, 0.35]], { lw: 2 }); + line([[0.15, 0.55], [0.0, 0.35]], { lw: 2 }); + // 递归枝干 + const branch = (x, y, ang, len, depth) => { + const ex = x + Math.cos(ang) * len; + const ey = y + Math.sin(ang) * len; + line([[x, y], [ex, ey]], { lw: Math.max(0.8, 2.4 * (len / 0.4)), passes: 1, wobble: 0.006 }); + if (depth > 0) { + branch(ex, ey, ang + 0.5, len * 0.68, depth - 1); + branch(ex, ey, ang - 0.5, len * 0.68, depth - 1); + branch(ex, ey, ang + 0.2, len * 0.55, depth - 1); + } + }; + branch(0.02, -0.1, -Math.PI / 2, 0.42, 3); + // 叶点 + for (let i = 0; i < 90; i++) { + const a = rand() * Math.PI * 2; + const rr = Math.sqrt(rand()) * 0.5; + dot(Math.cos(a) * rr * 1.2, -0.5 + Math.sin(a) * rr * 0.8, 0.012 + rand() * 0.012, INK_SOFT); + } + // 地面 + line([[-0.6, 0.6], [0.6, 0.6]], { lw: 1.8, color: INK_FAINT }); + }, + + atom() { + // 原子核 + circle(0, 0, 0.1, { lw: 2 }); + dot(0.03, 0.03, 0.03, '#a03a2a'); + dot(-0.04, -0.02, 0.02, '#a03a2a'); + // 轨道 + const orbit = (rx, ry, rot) => { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(rot); + ctx.strokeStyle = INK_SOFT; + ctx.lineWidth = 1.6 * (size / 760); + ctx.beginPath(); + ctx.ellipse(0, 0, rx * r, ry * r, 0, 0, Math.PI * 2); + ctx.stroke(); + // 电子 + const ea = rand() * Math.PI * 2; + const ex = Math.cos(ea) * rx * r; + const ey = Math.sin(ea) * ry * r; + ctx.fillStyle = '#a03a2a'; + ctx.beginPath(); + ctx.arc(ex, ey, 0.045 * r, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }; + orbit(0.66, 0.3, 0.2); + orbit(0.66, 0.3, 2.2); + orbit(0.5, 0.66, 1.2); + // 公式 + ctx.save(); + ctx.translate(cx, cy + 0.86 * r); + ctx.fillStyle = INK; + ctx.font = `${Math.round(0.16 * r)}px Georgia, serif`; + ctx.textAlign = 'center'; + ctx.fillText('E = mc²', 0, 0); + ctx.restore(); + }, + + dove() { + // 鸽身(飞翔剪影) + fill([[-0.4, 0.1], [-0.2, -0.1], [0.0, -0.12], [0.2, -0.1], [0.35, 0.05], [0.1, 0.18], [-0.2, 0.2]], 'rgba(200,190,170,0.22)'); + line([[-0.42, 0.08], [-0.2, -0.1], [0.0, -0.14], [0.2, -0.1], [0.34, 0.05], [0.1, 0.18], [-0.2, 0.2], [-0.42, 0.08]], { lw: 2.4 }); + // 翅膀 + line([[-0.1, -0.06], [-0.3, -0.5], [-0.05, -0.62]], { lw: 2.2 }); + line([[-0.3, -0.5], [-0.02, -0.28]], { lw: 1.4, color: INK_SOFT }); + // 眼与喙 + dot(-0.34, 0.02, 0.03, INK); + line([[-0.42, 0.06], [-0.52, 0.12], [-0.42, 0.12]], { lw: 1.6, passes: 1 }); + // 橄榄枝 + line([[-0.52, 0.12], [-0.62, 0.3]], { lw: 1.8 }); + for (let i = 0; i < 5; i++) { + const a = -0.4 + i * 0.5; + const bx = -0.62 + Math.cos(a) * 0.1; + const by = 0.3 + Math.sin(a) * 0.05 + i * 0.01; + line([[-0.62 + (i * 0.02), 0.28 + i * 0.005], [bx, by]], { lw: 1.2, passes: 1 }); + } + // 光芒 + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2; + line([[Math.cos(a) * 0.72, Math.sin(a) * 0.72], [Math.cos(a) * 0.82, Math.sin(a) * 0.82]], { lw: 1.4, color: INK_FAINT, passes: 1 }); + } + }, + + rocket() { + // 星空 + for (let i = 0; i < 30; i++) { + dot(-0.85 + rand() * 1.7, -0.85 + rand() * 1.4, 0.008 + rand() * 0.01, INK_FAINT); + } + // 火箭主体 + fill([[-0.14, -0.7], [0.14, -0.7], [0.14, 0.5], [-0.14, 0.5]], 'rgba(200,200,205,0.20)'); + line([[-0.14, -0.7], [0.14, -0.7], [0.14, 0.5], [-0.14, 0.5], [-0.14, -0.7]], { lw: 2.4 }); + // 头锥 + line([[-0.14, -0.7], [0.0, -0.92], [0.14, -0.7]], { lw: 2.4 }); + // 舷窗 + circle(0, -0.3, 0.07, { lw: 1.6, passes: 1 }); + // 尾翼 + line([[-0.14, 0.2], [-0.3, 0.5]], { lw: 2 }); + line([[-0.14, 0.2], [-0.16, 0.5]], { lw: 1.2, color: INK_SOFT }); + line([[0.14, 0.2], [0.3, 0.5]], { lw: 2 }); + line([[0.14, 0.2], [0.16, 0.5]], { lw: 1.2, color: INK_SOFT }); + // 喷焰 + line([[-0.08, 0.5], [0.0, 0.72], [0.08, 0.5]], { lw: 2, color: '#a03a2a' }); + line([[-0.05, 0.5], [0.0, 0.62], [0.05, 0.5]], { lw: 1.4, color: '#c9a227' }); + // 月球 + circle(0.62, -0.6, 0.22, { lw: 2.2, fill: 'rgba(210,200,180,0.16)' }); + circle(0.55, -0.66, 0.05, { lw: 1.2, passes: 1, color: INK_FAINT }); + circle(0.68, -0.55, 0.035, { lw: 1.2, passes: 1, color: INK_FAINT }); + // 旗帜 + line([[0.4, 0.5], [0.4, 0.3]], { lw: 1.4 }); + line([[0.4, 0.3], [0.56, 0.34], [0.4, 0.4]], { lw: 1.4, passes: 1 }); + }, + }; + + const draw = types[entry.sketch] || types.fossil; + draw(); + return canvas; +} diff --git a/src/ui/overlay.js b/src/ui/overlay.js new file mode 100644 index 0000000..ea4406e --- /dev/null +++ b/src/ui/overlay.js @@ -0,0 +1,81 @@ +import { CATEGORY_INFO } from '../data/entries.js'; +import { renderSketch } from '../textures/sketch.js'; + +// HTML 覆盖层:标题、视图切换、底部导航、登记卡详情 +export class Overlay { + constructor({ onView, onPrev, onNext, onFlip }) { + this.onView = onView; + this.onPrev = onPrev; + this.onNext = onNext; + this.onFlip = onFlip; + + this.els = { + iris: document.getElementById('iris'), + pageNum: document.getElementById('page-num'), + pageTotal: document.getElementById('page-total'), + pageName: document.getElementById('page-name'), + detailNo: document.getElementById('detail-no'), + detailCat: document.getElementById('detail-cat'), + detailName: document.getElementById('detail-name'), + detailName2: document.getElementById('detail-name2'), + detailTime: document.getElementById('detail-time'), + detailRegion: document.getElementById('detail-region'), + detailDesc: document.getElementById('detail-desc'), + detailSketch: document.getElementById('detail-sketch-canvas'), + detailCard: document.getElementById('detail-card'), + hint: document.getElementById('hint'), + }; + + this._wire(); + } + + _wire() { + document.querySelectorAll('.view-btn').forEach((btn) => { + btn.addEventListener('click', () => this.onView(btn.dataset.view)); + }); + document.getElementById('btn-prev').addEventListener('click', () => this.onPrev()); + document.getElementById('btn-next').addEventListener('click', () => this.onNext()); + document.getElementById('btn-flip').addEventListener('click', () => this.onFlip()); + } + + 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); + }); + const HINTS = { + binder: '拖动旋转 · 滚轮缩放 · 点击书页选中 · ← → 翻页(翻面查看素描)', + timeline: '拖拽左右滑动 · 滚轮缩放 · 点击卡片查看详情', + globe: '拖动旋转 · 滚轮缩放 · 点击标记查看详情', + }; + this.els.hint.textContent = HINTS[view] || ''; + } + + setEntry(entry, index, total) { + const info = CATEGORY_INFO[entry.category] || CATEGORY_INFO['事']; + this.els.pageNum.textContent = String(index + 1).padStart(2, '0'); + this.els.pageName.textContent = entry.name; + this.els.detailNo.textContent = entry.id; + this.els.detailCat.textContent = entry.category; + this.els.detailCat.style.background = info.color; + this.els.detailName.textContent = entry.name; + this.els.detailName2.textContent = entry.name; + this.els.detailTime.textContent = entry.time; + this.els.detailRegion.textContent = entry.region; + this.els.detailDesc.textContent = entry.description; + + // 素描缩略图 + const c = this.els.detailSketch; + const ctx = c.getContext('2d'); + ctx.clearRect(0, 0, c.width, c.height); + const sketch = renderSketch(entry, 560); + ctx.drawImage(sketch, (c.width - c.height) / 2, 0, c.height, c.height); + } + + irisTo(visible) { + this.els.iris.style.opacity = visible ? '1' : '0'; + } +} diff --git a/src/utils/easing.js b/src/utils/easing.js new file mode 100644 index 0000000..6818324 --- /dev/null +++ b/src/utils/easing.js @@ -0,0 +1,39 @@ +// 缓动函数与小工具 + +export const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); +export const lerp = (a, b, t) => a + (b - a) * t; + +export const easeInOutCubic = (t) => + t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + +export const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3); +export const easeOutQuint = (t) => 1 - Math.pow(1 - t, 5); +export const easeInOutQuint = (t) => + t < 0.5 ? 16 * t * t * t * t * t : 1 - Math.pow(-2 * t + 2, 5) / 2; + +// 简单的补间器:由调用方按帧推进 +export class Tween { + constructor({ from = 0, to = 1, duration = 1, ease = easeInOutCubic, onUpdate, onDone } = {}) { + this.from = from; + this.to = to; + this.duration = duration; + this.ease = ease; + this.onUpdate = onUpdate; + this.onDone = onDone; + this.t = 0; + this.done = false; + } + + update(dt) { + if (this.done) return true; + this.t += dt / this.duration; + let k = clamp(this.t, 0, 1); + const e = this.ease(k); + if (this.onUpdate) this.onUpdate(lerp(this.from, this.to, e), k); + if (this.t >= 1) { + this.done = true; + if (this.onDone) this.onDone(); + } + return this.done; + } +} diff --git a/src/utils/labels.js b/src/utils/labels.js new file mode 100644 index 0000000..4d659a2 --- /dev/null +++ b/src/utils/labels.js @@ -0,0 +1,48 @@ +import * as THREE from 'three'; + +// 用 Canvas 生成文字精灵(始终面向相机的标签) +export function makeTextSprite(text, { + fontSize = 42, + color = '#e0c46a', + stroke = 'rgba(20,12,6,0.9)', + strokeWidth = 6, + padding = 24, + font = 'Georgia, "Noto Serif SC", serif', + weight = '600', + letterSpacing = '0.08em', +} = {}) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + ctx.font = `${weight} ${fontSize}px ${font}`; + const w = Math.ceil(ctx.measureText(text).width) + padding * 2; + const h = Math.ceil(fontSize * 1.6); + canvas.width = w; + canvas.height = h; + + const c2 = canvas.getContext('2d'); + c2.font = ctx.font; + c2.textBaseline = 'middle'; + c2.textAlign = 'center'; + c2.lineJoin = 'round'; + if (stroke) { + c2.lineWidth = strokeWidth; + c2.strokeStyle = stroke; + c2.strokeText(text, w / 2, h / 2); + } + c2.fillStyle = color; + c2.fillText(text, w / 2, h / 2); + + const tex = new THREE.CanvasTexture(canvas); + tex.colorSpace = THREE.SRGBColorSpace; + tex.anisotropy = 4; + const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); + const sprite = new THREE.Sprite(mat); + const aspect = w / h; + sprite.scale.set(aspect, 1, 1); + return sprite; +} + +export function setSpriteText(sprite, text) { + // 惰性:直接重建内部(简单实现:返回新精灵) + return makeTextSprite(text); +}