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.
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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/ 截图与验证辅助(开发用)
|
||||||
|
```
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>人类文明里程碑 · 活页编年史</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%2316110c'/%3E%3Crect x='8' y='5' width='16' height='22' rx='2' fill='%23c9a227'/%3E%3Cline x1='12' y1='10' x2='20' y2='10' stroke='%2316110c' stroke-width='1.6'/%3E%3Cline x1='12' y1='15' x2='20' y2='15' stroke='%2316110c' stroke-width='1.6'/%3E%3Cline x1='12' y1='20' x2='17' y2='20' stroke='%2316110c' stroke-width='1.6'/%3E%3C/svg%3E" />
|
||||||
|
<meta name="description" content="以活页本、时间轴与地理视图,艺术化地呈现世界历史中里程碑式的人、事、物。" />
|
||||||
|
<link rel="stylesheet" href="/src/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<canvas id="scene"></canvas>
|
||||||
|
|
||||||
|
<!-- 气氛遮罩:暗角 + 颗粒 -->
|
||||||
|
<div class="vignette" aria-hidden="true"></div>
|
||||||
|
<div class="grain" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<!-- 转场淡入遮罩 -->
|
||||||
|
<div id="iris" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<!-- 顶部:标题 -->
|
||||||
|
<header class="hud top-left">
|
||||||
|
<h1 class="title">人类文明里程碑</h1>
|
||||||
|
<p class="subtitle">活页编年史 · The Loose-leaf Chronicle of Civilisation</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 视图切换 -->
|
||||||
|
<nav class="hud view-switch" aria-label="视图切换">
|
||||||
|
<button class="view-btn active" data-view="binder">活页本</button>
|
||||||
|
<button class="view-btn" data-view="timeline">时间轴</button>
|
||||||
|
<button class="view-btn" data-view="globe">地理</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 底部:导航 -->
|
||||||
|
<footer class="hud bottom-bar">
|
||||||
|
<button class="nav-btn" id="btn-prev" title="上一页 (←)">◀</button>
|
||||||
|
<div class="page-indicator">
|
||||||
|
<span id="page-num">01</span><span class="sep">/</span><span id="page-total">20</span>
|
||||||
|
<span class="page-name" id="page-name">露西 · 南方古猿</span>
|
||||||
|
</div>
|
||||||
|
<button class="nav-btn" id="btn-next" title="下一页 (→)">▶</button>
|
||||||
|
<button class="nav-btn flip-hint" id="btn-flip" title="翻面查看素描">翻面</button>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- 右侧:登记卡详情 -->
|
||||||
|
<aside class="hud detail-card" id="detail-card">
|
||||||
|
<div class="detail-head">
|
||||||
|
<span class="detail-no" id="detail-no">No.001</span>
|
||||||
|
<span class="detail-cat" id="detail-cat">人</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="detail-name" id="detail-name">露西 · 南方古猿</h2>
|
||||||
|
<dl class="detail-fields">
|
||||||
|
<div class="field"><dt>时间</dt><dd id="detail-time">约公元前320万年</dd></div>
|
||||||
|
<div class="field"><dt>地区</dt><dd id="detail-region">东非 · 埃塞俄比亚</dd></div>
|
||||||
|
<div class="field"><dt>名称</dt><dd id="detail-name2">露西 · 南方古猿</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="detail-desc" id="detail-desc"></p>
|
||||||
|
<div class="detail-sketch">
|
||||||
|
<canvas id="detail-sketch-canvas" width="560" height="320"></canvas>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 提示 -->
|
||||||
|
<div class="hud hint" id="hint">拖动旋转 · 滚轮缩放 · 时间轴视图拖拽左右滑动 · 点击选中 · ← → 翻页</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1525
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
+344
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { App } from './core/App.js';
|
||||||
|
|
||||||
|
// 启动应用
|
||||||
|
const app = new App();
|
||||||
|
window.__app = app; // 便于调试
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+141
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user