- Cesium 改为动态加载(vite 插件不再注入 6MB 脚本),首屏零 Cesium 请求 - 首帧渲染后后台预载地理视图,loading 展示 8 秒期间并行,就绪前地理按钮置灰 - Three.js 按需渲染:静止停止重绘,动画/交互/状态变化时渲染 - 射线检测合帧节流,binder 只测当前页±1、时间轴只测可视窗口 - 移除 vignette 的 mix-blend-mode 全屏合成 - 7342 个城市从 entity 改为 PointPrimitiveCollection + LabelCollection - 时间轴懒加载窗口跟随可视区域(拖拽/缩放实时跟随)
239 lines
8.2 KiB
JavaScript
239 lines
8.2 KiB
JavaScript
import * as THREE from 'three';
|
|
import { ENTRIES } from '../data/entries.js';
|
|
import { acquirePageTextures, releasePageTextures, getPlaceholderTextures, makeTwoSidedMaterial } from '../textures/pages.js';
|
|
import { makeTextSprite } from '../utils/labels.js';
|
|
import { clamp, easeInOutCubic } from '../utils/easing.js';
|
|
|
|
// 活页之间固定间距(世界单位),横向可滚动
|
|
const CARD_GAP = 0.65;
|
|
export const TIMELINE_SPREAD = ((ENTRIES.length - 1) * CARD_GAP) / 2;
|
|
|
|
const CARD_FLOAT = 0.14; // 选中时上浮高度
|
|
const NAME_Y = 0.42; // 主题标签基准高度(活页上方)
|
|
const AXIS_Y = -0.40; // 时间轴基线高度
|
|
const TIME_Y = -0.56; // 时间标签高度(轴下方)
|
|
|
|
function cardX(i, n) {
|
|
return (i - (n - 1) / 2) * CARD_GAP;
|
|
}
|
|
|
|
export class Timeline {
|
|
constructor() {
|
|
this.group = new THREE.Group();
|
|
this.cards = [];
|
|
this.selected = -1;
|
|
this.placeholder = getPlaceholderTextures();
|
|
this._textured = new Set();
|
|
this._visRange = null;
|
|
this._ready = false;
|
|
this._pending = [];
|
|
this._dirty = false;
|
|
|
|
this._buildLine();
|
|
this._buildCards();
|
|
}
|
|
|
|
_buildLine() {
|
|
const n = ENTRIES.length;
|
|
// 轴线向两侧延伸足够远(并关闭雾化),滚动到边缘也不见端点,视觉上无限长
|
|
const extent = TIMELINE_SPREAD + 5;
|
|
|
|
const lineMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.7, fog: false });
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(-extent, AXIS_Y, 0), new THREE.Vector3(extent, AXIS_Y, 0),
|
|
]);
|
|
this.group.add(new THREE.Line(lineGeo, lineMat));
|
|
|
|
// 刻度:向上指向卡片、向下指向时间标签
|
|
const tickMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.5 });
|
|
ENTRIES.forEach((e, i) => {
|
|
const x = cardX(i, n);
|
|
const g = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(x, AXIS_Y - 0.14, 0), new THREE.Vector3(x, AXIS_Y + 0.12, 0),
|
|
]);
|
|
this.group.add(new THREE.Line(g, tickMat));
|
|
});
|
|
}
|
|
|
|
_buildCards() {
|
|
const n = ENTRIES.length;
|
|
|
|
ENTRIES.forEach((entry, i) => {
|
|
const x = cardX(i, n);
|
|
|
|
// 每张卡片独立几何(带分段,便于翻面时弯曲出纸感)
|
|
const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 24, 6);
|
|
const base = Float32Array.from(cardGeo.attributes.position.array);
|
|
|
|
// 翻转容器:单 mesh + 双面材质(翻面时 gl_FrontFacing 自动切换正/背)
|
|
const flipGroup = new THREE.Group();
|
|
flipGroup.position.set(x, 0, 0);
|
|
|
|
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, { roughness: 0.86 });
|
|
const cardMesh = new THREE.Mesh(cardGeo, mat);
|
|
cardMesh.userData = { view: 'timeline', index: i };
|
|
cardMesh.castShadow = true;
|
|
|
|
flipGroup.add(cardMesh);
|
|
|
|
// 活页上方:主题(常显,随选中上浮,超宽居中换行)
|
|
const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 });
|
|
name.position.set(x, NAME_Y, 0);
|
|
|
|
// 轴下方:时间(常显,固定不动)
|
|
const time = makeTextSprite(entry.time, { fontSize: 13, color: '#c9a227', scale: 0.11 });
|
|
time.position.set(x, TIME_Y, 0);
|
|
|
|
this.group.add(flipGroup, name, time);
|
|
this.cards.push({ card: flipGroup, mesh: cardMesh, material: mat, geo: cardGeo, base, name, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null, isPlaceholder: true });
|
|
});
|
|
}
|
|
|
|
setSelected(index) {
|
|
this.selected = index;
|
|
this.cards.forEach((c) => {
|
|
const on = c.index === index;
|
|
c.card.position.y = on ? CARD_FLOAT : 0;
|
|
c.name.position.y = c.nameBaseY + (on ? CARD_FLOAT : 0);
|
|
});
|
|
}
|
|
|
|
// 首次进入时间轴视图时由 App 调用,此后由 App 每帧按可视区域驱动窗口
|
|
refreshTextures() {
|
|
this._ready = true;
|
|
}
|
|
|
|
// App 每帧根据可视区域调用:minI/maxI 为可见卡片索引范围(可为小数)
|
|
setVisibleRange(minI, maxI) {
|
|
const len = this.cards.length;
|
|
const PAD = 4; // 缓冲:可视范围外多生成几页,拖拽时提前就绪
|
|
const lo = Math.max(0, Math.floor(minI) - PAD);
|
|
const hi = Math.min(len - 1, Math.ceil(maxI) + PAD);
|
|
if (this._visRange && this._visRange.min === lo && this._visRange.max === hi) return;
|
|
this._visRange = { min: lo, max: hi };
|
|
this._refreshTextures();
|
|
}
|
|
|
|
// 世界坐标可视范围 → 索引范围(内部减去 group 偏移;卡片组内坐标 = cardX(i))
|
|
setVisibleWorldRange(minX, maxX) {
|
|
const n = this.cards.length;
|
|
const offset = this.group.position.x;
|
|
const lo = (minX - offset) / CARD_GAP + (n - 1) / 2;
|
|
const hi = (maxX - offset) / CARD_GAP + (n - 1) / 2;
|
|
this.setVisibleRange(lo, hi);
|
|
}
|
|
|
|
_window() {
|
|
const win = new Set();
|
|
if (!this._visRange) return win;
|
|
for (let i = this._visRange.min; i <= this._visRange.max; i++) win.add(i);
|
|
return win;
|
|
}
|
|
|
|
_refreshTextures() {
|
|
if (!this._ready) return;
|
|
const win = this._window();
|
|
|
|
// 离开窗口:释放真实纹理,换回占位
|
|
for (const i of [...this._textured]) {
|
|
if (win.has(i)) continue;
|
|
const c = this.cards[i];
|
|
releasePageTextures(ENTRIES[i]);
|
|
c.material.setPageMaps(this.placeholder.front, this.placeholder.back);
|
|
c.isPlaceholder = true;
|
|
this._textured.delete(i);
|
|
this._dirty = true;
|
|
}
|
|
|
|
// 进入窗口:分帧异步生成,避免一次性生成 25 页阻塞主线程
|
|
const next = [...win].filter((i) => !this._textured.has(i) && this.cards[i].isPlaceholder);
|
|
if (next.length) this._dirty = true;
|
|
this._pending = next;
|
|
this.drainPending();
|
|
}
|
|
|
|
// 每帧由 App._animate 驱动,逐批生成窗口内纹理(避免阻塞)
|
|
drainPending() {
|
|
if (!this._pending || !this._pending.length) return false;
|
|
const budget = 2; // 每帧最多生成 2 页
|
|
let did = false;
|
|
for (let k = 0; k < budget && this._pending.length; k++) {
|
|
const i = this._pending.shift();
|
|
const c = this.cards[i];
|
|
if (!c || this._textured.has(i)) continue;
|
|
const { front, back } = acquirePageTextures(ENTRIES[i]);
|
|
c.material.setPageMaps(front, back);
|
|
c.isPlaceholder = false;
|
|
this._textured.add(i);
|
|
this._dirty = true;
|
|
did = true;
|
|
}
|
|
return did;
|
|
}
|
|
|
|
hasActiveFlip() {
|
|
return this.cards.some((c) => c.flipAnim);
|
|
}
|
|
|
|
// App 每帧消费“纹理窗口变化”标志,用于按需渲染
|
|
consumeDirty() {
|
|
const d = this._dirty;
|
|
this._dirty = false;
|
|
return d;
|
|
}
|
|
|
|
// 返回某张卡片在时间轴组内的横向坐标(用于点击后居中)
|
|
getCardX(index) {
|
|
return cardX(index, ENTRIES.length);
|
|
}
|
|
|
|
// 射线检测用:只返回可视窗口内的卡片 mesh(减少求交对象)
|
|
pickableMeshes() {
|
|
if (!this._visRange) return this.cards.map((c) => c.mesh);
|
|
const list = [];
|
|
for (let i = this._visRange.min; i <= this._visRange.max; i++) {
|
|
if (this.cards[i]) list.push(this.cards[i].mesh);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
// 点击翻转:每次点击切换正/反面;选中另一张时,其它翻到背面的卡片先翻回正面
|
|
flip(index) {
|
|
this.cards.forEach((o) => {
|
|
if (o.index !== index && o.flipped) {
|
|
o.flipped = false;
|
|
o.flipAnim = { from: o.card.rotation.y, to: 0, t: 0, dur: 0.6 };
|
|
}
|
|
});
|
|
|
|
const c = this.cards[index];
|
|
if (!c) return;
|
|
c.flipped = !c.flipped;
|
|
c.flipAnim = { from: c.card.rotation.y, to: c.flipped ? Math.PI : 0, t: 0, dur: 0.6 };
|
|
}
|
|
|
|
// 翻面动画(由 App 每帧调用)
|
|
update(dt) {
|
|
this.cards.forEach((c) => {
|
|
if (!c.flipAnim) return;
|
|
const a = c.flipAnim;
|
|
a.t += dt / a.dur;
|
|
const k = clamp(a.t, 0, 1);
|
|
c.card.rotation.y = a.from + (a.to - a.from) * easeInOutCubic(k);
|
|
|
|
// 纸张弯曲:翻面中间段边缘向后微弯,带柔软感
|
|
const bend = Math.sin(k * Math.PI) * 0.06;
|
|
const pos = c.geo.attributes.position;
|
|
const arr = pos.array;
|
|
const base = c.base;
|
|
for (let j = 0; j < arr.length; j += 3) {
|
|
const xn = base[j] / 0.4; // -0.5 .. 0.5
|
|
arr[j + 2] = base[j + 2] - bend * (xn * 2) * (xn * 2);
|
|
}
|
|
pos.needsUpdate = true;
|
|
|
|
if (k >= 1) c.flipAnim = null;
|
|
});
|
|
}
|
|
}
|