Expand chronicle to 300 entries with world data
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.
This commit is contained in:
+59
-20
@@ -2,8 +2,11 @@ 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';
|
||||
|
||||
export const TIMELINE_SPREAD = 6.2; // 横轴范围 ±6.2(超出视口,可横向滚动)
|
||||
// 活页之间固定间距(世界单位),横向可滚动
|
||||
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; // 主题标签基准高度(活页上方)
|
||||
@@ -11,7 +14,7 @@ const AXIS_Y = -0.40; // 时间轴基线高度
|
||||
const TIME_Y = -0.56; // 时间标签高度(轴下方)
|
||||
|
||||
function cardX(i, n) {
|
||||
return (i / (n - 1)) * 2 * TIMELINE_SPREAD - TIMELINE_SPREAD;
|
||||
return (i - (n - 1) / 2) * CARD_GAP;
|
||||
}
|
||||
|
||||
export class Timeline {
|
||||
@@ -27,7 +30,7 @@ export class Timeline {
|
||||
_buildLine() {
|
||||
const n = ENTRIES.length;
|
||||
// 轴线向两侧延伸足够远(并关闭雾化),滚动到边缘也不见端点,视觉上无限长
|
||||
const extent = 50;
|
||||
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([
|
||||
@@ -48,25 +51,30 @@ export class Timeline {
|
||||
|
||||
_buildCards() {
|
||||
const n = ENTRIES.length;
|
||||
const cardGeo = new THREE.PlaneGeometry(0.4, 0.565);
|
||||
|
||||
ENTRIES.forEach((entry, i) => {
|
||||
const x = cardX(i, n);
|
||||
const { front } = getPageTextures(entry);
|
||||
const { front, back } = getPageTextures(entry);
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({ map: front, roughness: 0.85, metalness: 0.0 });
|
||||
const card = new THREE.Mesh(cardGeo, mat);
|
||||
card.position.set(x, 0, 0);
|
||||
card.userData = { view: 'timeline', index: i };
|
||||
card.castShadow = true;
|
||||
// 每张卡片独立几何(带分段,便于翻面时弯曲出纸感)
|
||||
const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 24, 6);
|
||||
const base = Float32Array.from(cardGeo.attributes.position.array);
|
||||
|
||||
const outline = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(0.4 * 1.1, 0.565 * 1.1),
|
||||
new THREE.MeshBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.9, side: THREE.BackSide })
|
||||
);
|
||||
outline.position.z = -0.01;
|
||||
outline.visible = false;
|
||||
card.add(outline);
|
||||
// 翻转容器:正面登记卡 + 背面素描
|
||||
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 });
|
||||
@@ -76,8 +84,8 @@ export class Timeline {
|
||||
const time = makeTextSprite(entry.time, { fontSize: 13, color: '#c9a227', scale: 0.11 });
|
||||
time.position.set(x, TIME_Y, 0);
|
||||
|
||||
this.group.add(card, name, time);
|
||||
this.cards.push({ card, outline, name, nameBaseY: NAME_Y, index: i });
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,7 +93,6 @@ export class Timeline {
|
||||
this.selected = index;
|
||||
this.cards.forEach((c) => {
|
||||
const on = c.index === index;
|
||||
c.outline.visible = on;
|
||||
c.card.position.y = on ? CARD_FLOAT : 0;
|
||||
c.name.position.y = c.nameBaseY + (on ? CARD_FLOAT : 0);
|
||||
});
|
||||
@@ -95,4 +102,36 @@ export class Timeline {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user