611 lines
23 KiB
JavaScript
611 lines
23 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._stepByView(-1),
|
|
onNext: () => this._stepByView(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() {
|
|
this.globe.viewerReady.then(() => this.overlay.setGlobeReady(true));
|
|
this.globe.ensureCreated();
|
|
}
|
|
|
|
_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.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);
|
|
this.rimLight = rim;
|
|
|
|
// 低角度掠射光:横扫皮面,凸显皮革纹理凹凸
|
|
const graze = new THREE.DirectionalLight(0xffd9a8, 1.1);
|
|
graze.position.set(-4.5, 0.7, 2.6);
|
|
this.scene.add(graze);
|
|
this.grazeLight = graze;
|
|
|
|
// 背面补光:让外侧牛皮从背面看也可见
|
|
const backLight = new THREE.DirectionalLight(0xffd9a8, 0.55);
|
|
backLight.position.set(0.5, 1.2, -3.5);
|
|
this.scene.add(backLight);
|
|
this.backLight = 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, view: 'timeline', startX: e.clientX, startY: e.clientY, moved: false, offset: this.timelineOffset };
|
|
canvas.setPointerCapture(e.pointerId);
|
|
} else if (this.view === 'binder') {
|
|
// 活页本:按下仅记录,松开且未拖拽(<4px)时才翻页
|
|
this._drag = { active: true, view: 'binder', startX: e.clientX, startY: e.clientY, moved: false };
|
|
}
|
|
});
|
|
|
|
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.view === 'timeline' && 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;
|
|
const view = this._drag.view;
|
|
this._drag.active = false;
|
|
canvas.style.cursor = 'grab';
|
|
if (view === 'timeline') {
|
|
this.timeline.setPausedGeneration(false); // 拖拽结束,恢复纹理生成
|
|
if (wasClick) this._pick();
|
|
} else if (view === 'binder' && wasClick) {
|
|
setPointer(e); // 用松开位置做射线,松开时触发翻页
|
|
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;
|
|
this._stepByView(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') {
|
|
// 只检测顶层可点击页(左右顶页 + 正反翻页缓冲),避免对全部页求交
|
|
list = [];
|
|
const cur = this.binder.current;
|
|
for (let d = -2; 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') {
|
|
// 按点击位置的空间左右侧判断翻页方向(翻页过程中索引与左右侧会错位,不能按索引)
|
|
if (hit.point.x >= 0) {
|
|
this.binder.flipForward(); // 点了右侧 → 向前翻
|
|
} else {
|
|
this.binder.flipBackward(); // 点了左侧 → 向后翻
|
|
}
|
|
} 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();
|
|
}
|
|
|
|
// 上一页/下一页(底部按钮与左右键共用):时间轴为选择+居中+翻面,其它为活页本翻页
|
|
_stepByView(dir) {
|
|
if (this.view === 'timeline') this._stepTimeline(dir);
|
|
else this._step(dir);
|
|
}
|
|
|
|
// 时间轴:左右键选择上一张/下一张,动画与点击一致(居中 + 翻面)
|
|
_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 } = {}) {
|
|
const isEnd = index >= ENTRIES.length;
|
|
this.selected = clamp(index, 0, ENTRIES.length - 1);
|
|
if (isEnd) {
|
|
this.overlay.setEnd(ENTRIES[this.selected]);
|
|
} else {
|
|
this.overlay.setEntry(ENTRIES[this.selected], 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';
|
|
|
|
// 灯光:时间轴只用半球光 + 主方向光;活页本保留全部灯光(减少 PBR 每像素光照计算)
|
|
const fullLights = view === 'binder';
|
|
this.rimLight.visible = fullLights;
|
|
this.grazeLight.visible = fullLights;
|
|
this.backLight.visible = fullLights;
|
|
|
|
// 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.resetTween !== 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);
|
|
}
|
|
}
|
|
}
|