Add a generator script that produces the entries dataset, dedupe entries sharing coordinates, and ship world city and country GeoJSON assets for the globe view. Make the timeline card spacing derive from entry count, move the view switcher into the bottom bar, and align card and label fonts with the timeline titles.
138 lines
4.9 KiB
JavaScript
138 lines
4.9 KiB
JavaScript
import * as THREE from 'three';
|
|
import { ENTRIES } from '../data/entries.js';
|
|
import { getPageTextures } 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._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 { front, back } = getPageTextures(entry);
|
|
|
|
// 每张卡片独立几何(带分段,便于翻面时弯曲出纸感)
|
|
const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 24, 6);
|
|
const base = Float32Array.from(cardGeo.attributes.position.array);
|
|
|
|
// 翻转容器:正面登记卡 + 背面素描
|
|
const flipGroup = new THREE.Group();
|
|
flipGroup.position.set(x, 0, 0);
|
|
|
|
const frontMat = new THREE.MeshStandardMaterial({ map: front, roughness: 0.85, metalness: 0.0, side: THREE.FrontSide });
|
|
const frontMesh = new THREE.Mesh(cardGeo, frontMat);
|
|
frontMesh.userData = { view: 'timeline', index: i };
|
|
frontMesh.castShadow = true;
|
|
|
|
const backMat = new THREE.MeshStandardMaterial({ map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide });
|
|
const backMesh = new THREE.Mesh(cardGeo, backMat);
|
|
backMesh.position.z = -0.001;
|
|
backMesh.userData = { view: 'timeline', index: i };
|
|
|
|
flipGroup.add(frontMesh, backMesh);
|
|
|
|
// 活页上方:主题(常显,随选中上浮,超宽居中换行)
|
|
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, front: frontMesh, back: backMesh, geo: cardGeo, base, name, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null });
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
// 返回某张卡片在时间轴组内的横向坐标(用于点击后居中)
|
|
getCardX(index) {
|
|
return cardX(index, ENTRIES.length);
|
|
}
|
|
|
|
// 点击翻转:每次点击切换正/反面,带旋转动画
|
|
flip(index) {
|
|
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;
|
|
});
|
|
}
|
|
}
|