Files
Hist/src/core/App.js
T

590 lines
22 KiB
JavaScript

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 { CesiumGlobe } from '../scene/CesiumGlobe.js';
import { Overlay } from '../ui/overlay.js';
import { clamp, easeInOutCubic } from '../utils/easing.js';
// Three.js 相机的初始位(仅活页本/时间轴;地理视图用 Cesium 自己的相机)
const CAMERA_POSES = {
binder: { pos: new THREE.Vector3(0.2, 0.75, 2.95), target: new THREE.Vector3(0, 0.02, 0), max: 4.4, min: 2.4 },
timeline: { pos: new THREE.Vector3(0, 1.4, 6.86), target: new THREE.Vector3(0, 0, 0), max: 8, min: 1.2 },
};
const VIEWS = ['binder', 'timeline', 'globe'];
export class App {
constructor() {
this.view = 'binder';
this.selected = 0;
this.timelineOffset = 0;
this.timelineSlide = null;
this.zoomTarget = null;
this.cameraMemory = {};
this._needsRender = true; // 按需渲染:首帧必渲染
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._applyRotationLimits(CAMERA_POSES.binder);
// 默认新打开时:活页本放到最小(相机沿默认视角拉到最远)
{
const bp = CAMERA_POSES.binder;
const dir = bp.pos.clone().sub(bp.target).normalize();
this.camera.position.copy(bp.target).addScaledVector(dir, bp.max);
this.controls.target.copy(bp.target);
this.controls.update();
}
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);
// 首屏渲染后,分帧生成时间轴文字标签(避免阻塞首屏加载环)
requestAnimationFrame(() => requestAnimationFrame(() => this.timeline.initLabels()));
}
// 首屏加载完成后由 main.js 调用:后台预载地理视图,就绪后激活“地理”按钮
preloadGlobe() {
const btn = document.querySelector('.view-btn[data-view="globe"]');
this.globe.viewerReady.then(() => { if (btn) btn.disabled = false; });
this.globe.ensureCreated();
}
_initRenderer() {
const canvas = document.getElementById('scene');
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
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.enableZoom = false; // 关闭 OrbitControls 自带缩放,使用自定义平滑缩放
this.controls.minDistance = CAMERA_POSES.binder.min;
this.controls.maxDistance = CAMERA_POSES.binder.max;
this.controls.maxPolarAngle = Math.PI * 0.82;
// 禁用中键(缩放)与右键(平移),仅左键旋转 + 滚轮缩放
this.controls.mouseButtons.MIDDLE = -1;
this.controls.mouseButtons.RIGHT = -1;
this.controls.rotateSpeed = 0.4; // 整体降速(无边缘减速)
this.controls.addEventListener('change', () => { this._needsRender = true; });
this.controls.update();
}
_initLights() {
const hemi = new THREE.HemisphereLight(0xfff1d8, 0x2a1f14, 0.8);
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 = -3.5;
key.shadow.camera.right = 3.5;
key.shadow.camera.top = 4;
key.shadow.camera.bottom = -4;
key.shadow.bias = -0.0005;
this.scene.add(key);
this.scene.add(key.target);
this.keyLight = key;
const rim = new THREE.DirectionalLight(0x8fa0d0, 0.7);
rim.position.set(-2.5, 1.2, -3.5);
this.scene.add(rim);
// 低角度掠射光:横扫皮面,凸显皮革纹理凹凸
const graze = new THREE.DirectionalLight(0xffd9a8, 1.1);
graze.position.set(-4.5, 0.7, 2.6);
this.scene.add(graze);
// 背面补光:让外侧牛皮从背面看也可见
const backLight = new THREE.DirectionalLight(0xffd9a8, 0.55);
backLight.position.set(0.5, 1.2, -3.5);
this.scene.add(backLight);
}
_initScene() {
this.binder = new Binder();
this.binder.onChange = (i) => this._select(i, { syncBinder: false });
this.timeline = new Timeline();
this.globe = new CesiumGlobe({ onPick: (i) => this._pickGlobe(i) });
// Cesium 懒加载:首次进入“地理”视图时才真正创建 viewer
this.scene.add(this.binder.group);
this.scene.add(this.timeline.group);
this._buildGround();
this.timeline.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.mesh));
// 地理视图由 Cesium 自行拾取,不参与 Three.js 射线检测
}
_initEvents() {
this.raycaster = new THREE.Raycaster();
this.pointer = new THREE.Vector2();
this._drag = null;
this._hoverDirty = false;
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.timelineSlide = null; // 手动拖拽时取消居中动画
this.timeline.setPausedGeneration(true); // 拖拽期间暂停纹理生成,保持流畅
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';
this._needsRender = true;
}
return;
}
this._hoverDirty = true; // 射线检测移到 _animate 中每帧至多执行一次
});
canvas.addEventListener('pointerup', (e) => {
if (this._drag && this._drag.active) {
const wasClick = !this._drag.moved;
this._drag.active = false;
canvas.style.cursor = 'grab';
this.timeline.setPausedGeneration(false); // 拖拽结束,恢复纹理生成
if (wasClick) this._pick();
}
});
canvas.addEventListener('pointercancel', () => {
if (this._drag) this._drag.active = false;
canvas.style.cursor = 'grab';
this.timeline.setPausedGeneration(false);
});
window.addEventListener('resize', () => this._onResize());
window.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
const dir = e.key === 'ArrowRight' ? 1 : -1;
if (this.view === 'timeline') this._stepTimeline(dir);
else this._step(dir);
}
else if (e.key === '1') this.switchView('binder');
else if (e.key === '2') this.switchView('timeline');
else if (e.key === '3') this.switchView('globe');
});
// 滚轮缩放:目标累积(连续滚动合并为一次平滑过渡)
canvas.addEventListener('wheel', (e) => {
if (this.view === 'globe') return;
e.preventDefault();
const dist = this.camera.position.distanceTo(this.controls.target);
const base = this.zoomTarget !== null ? this.zoomTarget : dist;
const factor = Math.pow(0.9, -e.deltaY * 0.01);
this.zoomTarget = clamp(base * factor, this.controls.minDistance, this.controls.maxDistance);
}, { passive: false });
}
// 当前相机视野下,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;
}
// 时间轴:按当前相机可视区域刷新纹理窗口(拖拽/缩放/居中动画时连续跟随)
_updateTimelineTextures() {
if (this.view !== 'timeline') return;
const dist = this.camera.position.distanceTo(this.controls.target);
const halfFov = THREE.MathUtils.degToRad(this.camera.fov / 2);
const el = this.renderer.domElement;
const aspect = el.clientWidth / el.clientHeight;
const halfW = Math.tan(halfFov) * dist * aspect;
// 时间轴相机始终在 z 轴看向原点(旋转已锁定),可见世界 x ∈ [-halfW, halfW]
this.timeline.setVisibleWorldRange(-halfW, halfW);
}
_hitTest() {
let list;
if (this.view === 'binder') {
// 只检测顶层可点击页(左右页 + 翻页缓冲),避免对全部 182 页求交
list = [];
const cur = this.binder.current;
for (let d = -1; d <= 1; d++) {
const i = cur + d;
if (i >= 0 && i < this.binder.pages.length) list.push(this.binder.pages[i].mesh);
}
} else if (this.view === 'timeline') {
list = this.timeline.pickableMeshes();
} else {
return null; // 地理视图由 Cesium 自行拾取
}
if (!list.length) return null;
this.raycaster.setFromCamera(this.pointer, this.camera);
const hits = this.raycaster.intersectObjects(list, false);
return hits.length ? hits[0] : null;
}
_pickGlobe(index) {
this._select(index, { syncBinder: false });
// 点击地理标记时,显示右下角登记卡详情
document.getElementById('detail-card').classList.remove('hidden');
this.binder.syncTo(index);
}
_pick() {
const hit = this._hitTest();
if (!hit) return;
const { view, index } = hit.object.userData;
if (view === 'binder') {
const cur = this.binder.current;
const f = this.binder.flip;
// 翻页进行中:把“正在翻的页”与“即将露出的下一页”都视为向前翻的目标,点击不阻断
if (index === cur || (f && index === f.nextCurrent)) {
this.binder.flipForward();
} else if (index === cur - 1) {
this.binder.flipBackward();
} else {
this._select(index, { syncBinder: true });
}
} else {
this._select(index, { syncBinder: false });
// 点击时间轴卡片 / 地理标记时,显示右下角登记卡详情
document.getElementById('detail-card').classList.remove('hidden');
this.binder.syncTo(index);
// 时间轴:点击后滑动使该卡片居中
if (view === 'timeline') {
this._centerTimeline(index);
this.timeline.flip(index);
}
}
}
// 时间轴:点击卡片后横向滑动,使其居中
_centerTimeline(index) {
const limit = TIMELINE_SPREAD + 0.6;
const target = clamp(-this.timeline.getCardX(index), -limit, limit);
this.timelineSlide = { from: this.timelineOffset, to: target, t: 0, dur: 0.55 };
}
_step(dir) {
if (dir > 0) this.binder.flipForward();
else this.binder.flipBackward();
}
// 时间轴:左右键选择上一张/下一张,动画与点击一致(居中 + 翻面)
_stepTimeline(dir) {
const next = clamp(this.selected + dir, 0, ENTRIES.length - 1);
if (next === this.selected) return;
this._select(next, { syncBinder: true });
this._centerTimeline(next);
this.timeline.flip(next);
}
_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 (this.view === 'globe') this.globe.flyToEntry(this.selected); // 地球页切换时自动居中
if (syncBinder) this.binder.syncTo(this.selected);
this._needsRender = true;
}
switchView(view) {
if (view === this.view || !VIEWS.includes(view)) return;
// 地理视图尚未就绪(按钮仍置灰)时忽略切换,键盘也无法绕过
if (view === 'globe' && !this.globe.viewer) return;
// 离开当前视图前,记住它的相机位置
if (this.view !== 'globe') {
this.cameraMemory[this.view] = {
pos: this.camera.position.clone(),
target: this.controls.target.clone(),
};
}
const iris = document.getElementById('iris');
iris.style.opacity = '1';
const doSwap = () => {
this._setView(view);
if (view === 'globe') {
// 地理视图:等影像/地形瓦片加载完成再淡出,让球体与标记/标签一起出现
this.globe.waitUntilViewReady(4000).then(() => { iris.style.opacity = '0'; });
} else {
// 等新视图渲染一两帧后再淡出,避免半透明时看到切换瞬间
requestAnimationFrame(() => {
requestAnimationFrame(() => { iris.style.opacity = '0'; });
});
}
};
setTimeout(doSwap, 200); // 与 0.18s 的淡入匹配,完全变黑后再切换
}
_setView(view) {
this.view = view;
this.overlay.setView(view);
const isGlobe = view === 'globe';
this.binder.group.visible = view === 'binder';
this.timeline.group.visible = view === 'timeline';
// 阴影仅在活页本视图启用:时间轴/地理无投影,降低滑动时阴影贴图重绘开销
this.renderer.shadowMap.enabled = view === 'binder';
// Cesium 地球:地理视图显示 Cesium 画布,其余视图显示 Three.js 画布
document.getElementById('scene').style.display = isGlobe ? 'none' : 'block';
document.getElementById('cesium-container').classList.toggle('visible', isGlobe);
if (isGlobe) {
this.globe.ensureCreated();
this.globe.setVisible(true);
this.globe.setSelected(this.selected);
this.globe.resize();
// 进入地理视图时,自动对准当前导航条选中的标记
this.globe.flyToEntry(this.selected);
} else {
this.globe.setVisible(false);
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
// 登记卡详情在视图切换时保持显示,不隐藏
document.getElementById('detail-card').classList.remove('hidden');
if (!isGlobe) {
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 = false; // 滚轮缩放改为自定义渐变动画
this.controls.autoRotate = false;
// 左键旋转限制:以各视图初始视角为中心,各方向 ±10°
this._applyRotationLimits(pose);
// 切换视图:直接定位(有记忆则恢复上次的相机位置)
this.cameraTween = null;
this.zoomTarget = null;
const mem = this.cameraMemory[view];
if (mem) {
this.camera.position.copy(mem.pos);
this.controls.target.copy(mem.target);
} else {
this.camera.position.copy(pose.pos);
this.controls.target.copy(pose.target);
}
}
// 进入时间轴时:自动定位到当前选中的活页居中
if (view === 'timeline') {
const limit = TIMELINE_SPREAD + 0.6;
this.timelineOffset = clamp(-this.timeline.getCardX(this.selected), -limit, limit);
this.timeline.group.position.x = this.timelineOffset;
this.timeline.refreshTextures();
}
this._needsRender = true;
}
_applyRotationLimits(pose) {
const LIMIT = (10 * Math.PI) / 180; // ±10°
const dx = pose.pos.x - pose.target.x;
const dy = pose.pos.y - pose.target.y;
const dz = pose.pos.z - pose.target.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
const polar = Math.acos(Math.max(-1, Math.min(1, dy / dist)));
const azimuth = Math.atan2(dx, dz);
this.controls.minPolarAngle = Math.max(0, polar - LIMIT);
this.controls.maxPolarAngle = Math.min(Math.PI - 0.01, polar + LIMIT);
this.controls.minAzimuthAngle = azimuth - LIMIT;
this.controls.maxAzimuthAngle = azimuth + LIMIT;
// 保存中心与限度,供边缘减速使用
this._rotCenter = { polar, azimuth };
this._rotLimit = LIMIT;
}
_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);
if (this.globe) this.globe.resize();
this._needsRender = true;
}
_animate() {
requestAnimationFrame(this._animate);
if (this.view === 'globe') return; // 地理视图由 Cesium 渲染,跳过 Three.js 每帧逻辑
const dt = Math.min(this.clock.getDelta(), 0.05);
// 是否有进行中的动画(决定本帧是否需要重绘)
const animating =
this.binder.flip !== null ||
this.binder.isSettling() ||
this.timeline.hasActiveFlip() ||
this.cameraTween !== null ||
this.zoomTarget !== null ||
this.timelineSlide !== null;
if (animating) this._needsRender = true;
this.binder.update(dt);
this.timeline.update(dt);
this.timeline.drainPending();
this._updateTimelineTextures();
if (this.timeline.consumeDirty()) this._needsRender = true;
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;
}
// 滚轮缩放渐变动画:相机距离平滑趋近目标(指数平滑,更平顺)
if (this.zoomTarget !== null && !this.cameraTween) {
const offset = this.camera.position.clone().sub(this.controls.target);
const dist = offset.length();
const k = 1 - Math.exp(-dt * 2.2);
const newDist = dist + (this.zoomTarget - dist) * k;
offset.normalize().multiplyScalar(newDist);
this.camera.position.copy(this.controls.target).add(offset);
if (Math.abs(newDist - this.zoomTarget) < 0.002) this.zoomTarget = null;
}
// 时间轴点击居中:横向滑动动画
if (this.timelineSlide) {
const s = this.timelineSlide;
s.t += dt / s.dur;
const k = clamp(s.t, 0, 1);
const e = easeInOutCubic(k);
this.timelineOffset = s.from + (s.to - s.from) * e;
this.timeline.group.position.x = this.timelineOffset;
if (k >= 1) this.timelineSlide = null;
}
// 鼠标悬停检测:每帧至多一次(pointermove 高频事件被合帧)
if (this._hoverDirty) {
this._hoverDirty = false;
const hit = this._hitTest();
this.renderer.domElement.style.cursor = hit ? 'pointer' : 'grab';
}
this.controls.update();
// 按需渲染:仅在状态/动画/交互变化时重绘,静止时停止渲染
if (this._needsRender) {
this._needsRender = false;
this.renderer.render(this.scene, this.camera);
}
}
}