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