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); } } }