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:
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