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:
2026-09-18 19:19:53 +08:00
commit 4e818d53e3
24 changed files with 4374 additions and 0 deletions
+344
View File
@@ -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);
}
}