Tune camera and timeline controls, add globe fly-to
- Clamp binder/timeline zoom ranges and disable OrbitControls zoom, using custom smooth zoom instead - Disable middle/right mouse buttons; reduce rotate speed - Fly camera to picked marker on globe click - Rebuild leather surfaces with a regular grid for smooth curvature - Refine suede and kraft paper textures, punch real page holes - Reposition axis, time labels, and detail card; speed up iris fade
This commit is contained in:
+116
-13
@@ -8,8 +8,8 @@ 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: 1.5 },
|
||||
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: 6 },
|
||||
globe: { pos: new THREE.Vector3(0, 0.35, 3.7), target: new THREE.Vector3(0, 0, 0), max: 8, min: 2.2 },
|
||||
};
|
||||
|
||||
@@ -18,6 +18,9 @@ export class App {
|
||||
this.view = 'binder';
|
||||
this.selected = 0;
|
||||
this.timelineOffset = 0;
|
||||
this.timelineSlide = null;
|
||||
this.zoomTarget = null;
|
||||
this.cameraMemory = {};
|
||||
|
||||
this._initRenderer();
|
||||
this._initLights();
|
||||
@@ -32,6 +35,7 @@ export class App {
|
||||
this.overlay.setTotal(ENTRIES.length);
|
||||
|
||||
this._initEvents();
|
||||
this._applyRotationLimits(CAMERA_POSES.binder);
|
||||
this._select(0, { syncBinder: false });
|
||||
this.binder.syncTo(0);
|
||||
|
||||
@@ -62,9 +66,14 @@ export class App {
|
||||
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.update();
|
||||
}
|
||||
|
||||
@@ -75,7 +84,7 @@ export class App {
|
||||
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.mapSize.set(1024, 1024);
|
||||
key.shadow.camera.near = 0.5;
|
||||
key.shadow.camera.far = 30;
|
||||
key.shadow.camera.left = -5;
|
||||
@@ -176,6 +185,7 @@ export class App {
|
||||
setPointer(e);
|
||||
if (this.view === 'timeline') {
|
||||
// 时间轴:按下开始拖拽左右滑动;未移动的抬起才视为点击选中
|
||||
this.timelineSlide = null; // 手动拖拽时取消居中动画
|
||||
this._drag = { active: true, startX: e.clientX, startY: e.clientY, moved: false, offset: this.timelineOffset };
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
} else {
|
||||
@@ -226,6 +236,16 @@ export class App {
|
||||
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 像素对应的世界宽度(拖拽跟手)
|
||||
@@ -257,15 +277,31 @@ export class App {
|
||||
if (!hit) return;
|
||||
const { view, index } = hit.object.userData;
|
||||
if (view === 'binder') {
|
||||
this._select(index, { syncBinder: true });
|
||||
const cur = this.binder.current;
|
||||
if (index === cur) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 时间轴:点击卡片后横向滑动,使其居中
|
||||
_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();
|
||||
@@ -282,13 +318,23 @@ export class App {
|
||||
|
||||
switchView(view) {
|
||||
if (view === this.view || !CAMERA_POSES[view]) 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);
|
||||
setTimeout(() => { iris.style.opacity = '0'; }, 180);
|
||||
// 等新视图渲染一两帧后再淡出,避免半透明时看到切换瞬间
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => { iris.style.opacity = '0'; });
|
||||
});
|
||||
};
|
||||
setTimeout(doSwap, 240);
|
||||
setTimeout(doSwap, 200); // 与 0.18s 的淡入匹配,完全变黑后再切换
|
||||
}
|
||||
|
||||
_setView(view) {
|
||||
@@ -309,13 +355,15 @@ export class App {
|
||||
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.toggle('hidden', view !== 'binder');
|
||||
// 登记卡详情在视图切换时保持显示,不隐藏
|
||||
document.getElementById('detail-card').classList.remove('hidden');
|
||||
|
||||
const pose = CAMERA_POSES[view];
|
||||
this.controls.minDistance = pose.min;
|
||||
@@ -324,16 +372,49 @@ export class App {
|
||||
const locked = view === 'timeline';
|
||||
this.controls.enableRotate = !locked;
|
||||
this.controls.enablePan = !locked;
|
||||
this.controls.enableZoom = true;
|
||||
this.controls.enableZoom = false; // 滚轮缩放改为自定义渐变动画
|
||||
this.controls.autoRotate = false;
|
||||
|
||||
// 进入时间轴时复位横向偏移
|
||||
// 左键旋转限制:以各视图初始视角为中心,各方向 ±10°
|
||||
this._applyRotationLimits(pose);
|
||||
|
||||
// 进入时间轴时:自动定位到当前选中的活页居中
|
||||
if (view === 'timeline') {
|
||||
this.timelineOffset = 0;
|
||||
this.timeline.group.position.x = 0;
|
||||
const limit = TIMELINE_SPREAD + 0.6;
|
||||
this.timelineOffset = clamp(-this.timeline.getCardX(this.selected), -limit, limit);
|
||||
this.timeline.group.position.x = this.timelineOffset;
|
||||
}
|
||||
|
||||
if (!isGlobe) this._tweenCamera(pose.pos, pose.target);
|
||||
if (!isGlobe) {
|
||||
// 切换视图:直接定位(有记忆则恢复上次的相机位置)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_applyRotationLimits(pose) {
|
||||
const LIMIT = (20 * Math.PI) / 180; // ±20°
|
||||
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) {
|
||||
@@ -368,6 +449,28 @@ export class App {
|
||||
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;
|
||||
}
|
||||
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user