Tune camera and timeline controls, add globe fly-to
- Clamp binder/timeline zoom ranges and disable OrbitControls zoom, using custom smooth zoom instead - Disable middle/right mouse buttons; reduce rotate speed - Fly camera to picked marker on globe click - Rebuild leather surfaces with a regular grid for smooth curvature - Refine suede and kraft paper textures, punch real page holes - Reposition axis, time labels, and detail card; speed up iris fade
This commit is contained in:
@@ -7,6 +7,9 @@ const IMAGERY_URL =
|
||||
const TERRAIN_URL =
|
||||
'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer';
|
||||
|
||||
// 点击标记后相机飞到的目标高度(米,约 1500 km 区域尺度)
|
||||
const FLY_TO_HEIGHT = 1.5e6;
|
||||
|
||||
export class CesiumGlobe {
|
||||
constructor({ onPick } = {}) {
|
||||
this.onPick = onPick;
|
||||
@@ -70,6 +73,9 @@ export class CesiumGlobe {
|
||||
},
|
||||
];
|
||||
|
||||
// 阻止右键弹出浏览器菜单
|
||||
scene.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
// 精细化渲染:质量与性能的平衡
|
||||
scene.globe.maximumScreenSpaceError = 2.0; // 默认细分,最省性能
|
||||
scene.globe.tileCacheSize = 600; // 更大的瓦片缓存,减少细节刷新
|
||||
@@ -146,12 +152,29 @@ export class CesiumGlobe {
|
||||
handler.setInputAction((movement) => {
|
||||
const picked = this.viewer.scene.pick(movement.position);
|
||||
if (Cesium.defined(picked) && picked.id && picked.id.entryIndex !== undefined) {
|
||||
this.onPick?.(picked.id.entryIndex);
|
||||
const index = picked.id.entryIndex;
|
||||
this.onPick?.(index);
|
||||
this.flyToEntry(index);
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
this._handler = handler;
|
||||
}
|
||||
|
||||
// 相机平滑飞至某个标记上空,并将其居中(正俯视,北朝上)
|
||||
flyToEntry(index) {
|
||||
const entry = ENTRIES[index];
|
||||
if (!entry || !this.viewer) return;
|
||||
this.viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat, FLY_TO_HEIGHT),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(-90),
|
||||
roll: 0,
|
||||
},
|
||||
duration: 1.2,
|
||||
});
|
||||
}
|
||||
|
||||
setSelected(index) {
|
||||
this.selected = index;
|
||||
if (!this.viewer) return;
|
||||
|
||||
+107
-88
@@ -50,7 +50,7 @@ function makeLeatherTexture({ emblem = false } = {}) {
|
||||
|
||||
// 内侧绒面(粗糙、哑光、模糊绒毛)
|
||||
function makeSuedeTexture() {
|
||||
const w = 512, h = 640;
|
||||
const w = 1024, h = 1280;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const ctx = c.getContext('2d');
|
||||
@@ -60,15 +60,17 @@ function makeSuedeTexture() {
|
||||
g.addColorStop(1, '#866244');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
for (let i = 0; i < 90000; i++) {
|
||||
// 密集绒毛噪点(更细更密)
|
||||
for (let i = 0; i < 420000; i++) {
|
||||
const x = Math.random() * w, y = Math.random() * h;
|
||||
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(40,22,10,0.10)' : 'rgba(230,200,160,0.08)';
|
||||
ctx.fillRect(x, y, Math.random() * 1.6 + 0.3, Math.random() * 1.6 + 0.3);
|
||||
ctx.fillRect(x, y, Math.random() * 1.1 + 0.22, Math.random() * 1.1 + 0.22);
|
||||
}
|
||||
// 绒毛短划痕(更细密)
|
||||
ctx.strokeStyle = 'rgba(30,16,8,0.10)';
|
||||
ctx.lineWidth = 0.8;
|
||||
for (let i = 0; i < 16000; i++) {
|
||||
const x = Math.random() * w, y = Math.random() * h, a = Math.random() * Math.PI, l = Math.random() * 5 + 1;
|
||||
ctx.lineWidth = 0.65;
|
||||
for (let i = 0; i < 70000; i++) {
|
||||
const x = Math.random() * w, y = Math.random() * h, a = Math.random() * Math.PI, l = Math.random() * 7 + 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(a) * l, y + Math.sin(a) * l); ctx.stroke();
|
||||
}
|
||||
const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.4, w / 2, h / 2, Math.max(w, h) * 0.72);
|
||||
@@ -78,7 +80,7 @@ function makeSuedeTexture() {
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.anisotropy = 8;
|
||||
tex.anisotropy = 16;
|
||||
return tex;
|
||||
}
|
||||
|
||||
@@ -107,46 +109,53 @@ function roundedRectShape(w, h, r, edgeSeg = 40, arcSeg = 12) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// 随机牛皮弯曲:低频平滑起伏(更柔和、更自然),正反面/皮边共用同一函数保证一致
|
||||
function leatherWarp(x, y) {
|
||||
const s = Math.sin(x * 1.3 + 0.7) * Math.cos(y * 1.1 + 0.9)
|
||||
+ Math.sin(x * 0.8 + y * 1.5 + 0.4) * 0.6
|
||||
+ Math.sin(x * 2.1 + 1.6) * Math.sin(y * 1.7 + 0.5) * 0.3;
|
||||
return s * 0.002;
|
||||
}
|
||||
|
||||
// 软皮曲面:ShapeGeometry 三角化(圆角正确、无缺口)+ 书脊处弧度弯曲
|
||||
function makeLeatherSurface(w, h, crXY, curve, flip) {
|
||||
const shape = roundedRectShape(w, h, crXY);
|
||||
const geo = new THREE.ShapeGeometry(shape);
|
||||
const pos = geo.attributes.position;
|
||||
// ShapeGeometry 的 UV 是原始坐标,需归一化到 [0,1]
|
||||
const uv0 = geo.attributes.uv;
|
||||
for (let i = 0; i < uv0.count; i++) {
|
||||
uv0.setXY(i, (uv0.getX(i) + w / 2) / w, (uv0.getY(i) + h / 2) / h);
|
||||
}
|
||||
uv0.needsUpdate = true;
|
||||
const hh = h / 2, sigma = 0.5;
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const x = pos.getX(i), y = pos.getY(i);
|
||||
const v = y / hh;
|
||||
const z = -curve * Math.exp(-(x * x) / (2 * sigma * sigma)) * (1 - 0.25 * v * v) + leatherWarp(x, y);
|
||||
pos.setZ(i, z);
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
if (flip) {
|
||||
// 背面:反转索引绕向(法线朝 -z),并镜像 UV
|
||||
const idx = geo.index;
|
||||
for (let i = 0; i < idx.count; i += 3) {
|
||||
const t = idx.getX(i + 1);
|
||||
idx.setX(i + 1, idx.getX(i + 2));
|
||||
idx.setX(i + 2, t);
|
||||
// 规则网格(含内部顶点,曲面平滑无对角褶痕)+ 角区收缩到圆弧
|
||||
const segX = 128, segY = 96;
|
||||
const hw = w / 2, hh = h / 2;
|
||||
const bx = hw - crXY, by = hh - crXY;
|
||||
const sigma = 0.5;
|
||||
const pos = [], uvs = [], idx = [];
|
||||
const g = [];
|
||||
|
||||
for (let j = 0; j <= segY; j++) {
|
||||
g[j] = [];
|
||||
const y = -hh + (j / segY) * h;
|
||||
for (let i = 0; i <= segX; i++) {
|
||||
const x = -hw + (i / segX) * w;
|
||||
let px = x, py = y;
|
||||
const ax = Math.abs(x), ay = Math.abs(y);
|
||||
// 角区:外部点投影到圆弧上(内部点保留)
|
||||
if (ax > bx && ay > by) {
|
||||
const dx = ax - bx, dy = ay - by;
|
||||
const dl = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dl > crXY) {
|
||||
const k = crXY / dl;
|
||||
px = Math.sign(x) * (bx + dx * k);
|
||||
py = Math.sign(y) * (by + dy * k);
|
||||
}
|
||||
}
|
||||
const v = py / hh;
|
||||
const z = -curve * Math.exp(-(px * px) / (2 * sigma * sigma)) * (1 - 0.25 * v * v);
|
||||
g[j].push(pos.length / 3);
|
||||
pos.push(px, py, z);
|
||||
uvs.push(flip ? (hw - px) / w : (px + hw) / w, (py + hh) / h);
|
||||
}
|
||||
idx.needsUpdate = true;
|
||||
const uv = geo.attributes.uv;
|
||||
for (let i = 0; i < uv.count; i++) uv.setX(i, 1 - uv.getX(i));
|
||||
uv.needsUpdate = true;
|
||||
}
|
||||
|
||||
for (let j = 0; j < segY; j++) {
|
||||
for (let i = 0; i < segX; i++) {
|
||||
const a = g[j][i], b = g[j][i + 1], c = g[j + 1][i], d = g[j + 1][i + 1];
|
||||
if (flip) idx.push(a, c, b, b, c, d);
|
||||
else idx.push(a, b, c, b, d, c);
|
||||
}
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
||||
geo.setIndex(idx);
|
||||
geo.computeVertexNormals();
|
||||
return geo;
|
||||
}
|
||||
@@ -160,7 +169,7 @@ function makeSideRim(w, h, crXY, thick, curve) {
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const px = pts[i].x, py = pts[i].y;
|
||||
const v = py / hh;
|
||||
const z = -curve * Math.exp(-(px * px) / (2 * sigma * sigma)) * (1 - 0.25 * v * v) + leatherWarp(px, py);
|
||||
const z = -curve * Math.exp(-(px * px) / (2 * sigma * sigma)) * (1 - 0.25 * v * v);
|
||||
pos.push(px, py, z - thick / 2, px, py, z + thick / 2);
|
||||
if (i > 0) {
|
||||
const a = (i - 1) * 2, b = a + 1, c = i * 2, d = i * 2 + 1;
|
||||
@@ -223,7 +232,7 @@ function makeLeatherNormalMap() {
|
||||
|
||||
// 内侧绒面法线:密集细绒毛(高频噪点)
|
||||
function makeSuedeNormalMap() {
|
||||
const w = 512, h = 640;
|
||||
const w = 1024, h = 1280;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const ctx = c.getContext('2d', { willReadFrequently: true });
|
||||
@@ -240,7 +249,7 @@ function makeSuedeNormalMap() {
|
||||
// 高度 → 法线
|
||||
const src = ctx.getImageData(0, 0, w, h);
|
||||
const out = ctx.createImageData(w, h);
|
||||
const S = 4.5;
|
||||
const S = 3.2;
|
||||
const hAt = (yy, xx) => src.data[(Math.min(h - 1, Math.max(0, yy)) * w + Math.min(w - 1, Math.max(0, xx))) * 4];
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
@@ -257,7 +266,7 @@ function makeSuedeNormalMap() {
|
||||
ctx.putImageData(out, 0, 0);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.NoColorSpace;
|
||||
tex.anisotropy = 8;
|
||||
tex.anisotropy = 16;
|
||||
return tex;
|
||||
}
|
||||
|
||||
@@ -269,8 +278,6 @@ export class Binder {
|
||||
this.flip = null; // { index, from, to, t, duration }
|
||||
this.onChange = null;
|
||||
|
||||
this.coverTex = makeLeatherTexture({ emblem: true });
|
||||
this.leatherNormalTex = makeLeatherNormalMap();
|
||||
this.suedeTex = makeSuedeTexture();
|
||||
this.suedeNormalTex = makeSuedeNormalMap();
|
||||
this._buildCovers();
|
||||
@@ -286,17 +293,9 @@ export class Binder {
|
||||
const coverW = PAGE_W + 0.2;
|
||||
const coverH = PAGE_H + 0.2;
|
||||
const spineW = 0.12;
|
||||
const thick = 0.017; // 牛皮厚度(减 1/3)
|
||||
const thick = 0.017; // 牛皮厚度
|
||||
|
||||
// 外侧:光滑深棕小牛皮(纯色,微光泽)
|
||||
const outerMat = new THREE.MeshStandardMaterial({
|
||||
map: this.coverTex,
|
||||
normalMap: this.leatherNormalTex,
|
||||
normalScale: new THREE.Vector2(0.3, 0.3),
|
||||
roughness: 0.25,
|
||||
metalness: 0.0,
|
||||
});
|
||||
// 内侧:粗糙绒面(哑光)
|
||||
// 内侧:粗糙绒面(哑光)——外皮不渲染,只保留内侧
|
||||
const innerMat = new THREE.MeshStandardMaterial({
|
||||
map: this.suedeTex,
|
||||
normalMap: this.suedeNormalTex,
|
||||
@@ -305,24 +304,21 @@ export class Binder {
|
||||
metalness: 0.0,
|
||||
});
|
||||
|
||||
// 一张牛皮:正反两张弯曲曲面 + 封闭皮边(UV 正确无拉伸)
|
||||
// 一张牛皮:仅内侧曲面 + 封闭皮边
|
||||
const totalW = coverW * 2 + spineW;
|
||||
const frontGeo = makeLeatherSurface(totalW, coverH, 0.06, 0.07, false);
|
||||
const backGeo = makeLeatherSurface(totalW, coverH, 0.06, 0.07, true);
|
||||
const rimGeo = makeSideRim(totalW, coverH, 0.06, thick, 0.07);
|
||||
const rimMat = new THREE.MeshStandardMaterial({ color: 0x6b4524, roughness: 0.7, metalness: 0.0, side: THREE.DoubleSide });
|
||||
|
||||
const front = new THREE.Mesh(frontGeo, innerMat); // +z 朝书页(绒面)
|
||||
front.position.z = thick / 2;
|
||||
const back = new THREE.Mesh(backGeo, outerMat); // -z 朝外(光滑)
|
||||
back.position.z = -thick / 2;
|
||||
const rim = new THREE.Mesh(rimGeo, rimMat);
|
||||
|
||||
this.cover = new THREE.Group();
|
||||
this.cover.add(front, back, rim);
|
||||
this.cover.add(front, rim);
|
||||
this.cover.position.set(0, 0, -0.095);
|
||||
front.receiveShadow = back.receiveShadow = rim.receiveShadow = true;
|
||||
front.castShadow = back.castShadow = rim.castShadow = true;
|
||||
front.receiveShadow = rim.receiveShadow = true;
|
||||
front.castShadow = rim.castShadow = true;
|
||||
|
||||
this.group.add(this.cover);
|
||||
}
|
||||
@@ -332,18 +328,18 @@ export class Binder {
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0xd8d2c6, metalness: 0.95, roughness: 0.25,
|
||||
});
|
||||
// 薄片(有可见厚度的金属条),贴在牛皮内面(书脊处曲面 z≈-0.122)上
|
||||
const plate = new THREE.Mesh(new THREE.BoxGeometry(0.09, 1.85, 0.04), mat);
|
||||
plate.position.set(0, 0.07, -0.135);
|
||||
// 薄片(有可见厚度的金属条),贴在牛皮内面(书脊处曲面 z≈-0.157)上
|
||||
const plate = new THREE.Mesh(new THREE.BoxGeometry(0.09, 1.95, 0.04), mat);
|
||||
plate.position.set(0, 0, -0.137);
|
||||
plate.castShadow = true;
|
||||
plate.receiveShadow = true;
|
||||
|
||||
// 铆钉:把薄片钉在牛皮上
|
||||
// 铆钉:把薄片钉在牛皮上(覆盖新的铁环范围 ±0.896)
|
||||
const rivetGeo = new THREE.SphereGeometry(0.013, 12, 10);
|
||||
const rivets = [];
|
||||
for (const ry of [-0.72, -0.36, 0, 0.36, 0.72]) {
|
||||
for (const ry of [-0.78, -0.39, 0, 0.39, 0.78]) {
|
||||
const rivet = new THREE.Mesh(rivetGeo, mat);
|
||||
rivet.position.set(0, ry, -0.114);
|
||||
rivet.position.set(0, ry, -0.116);
|
||||
rivet.scale.z = 0.5;
|
||||
rivet.castShadow = true;
|
||||
rivets.push(rivet);
|
||||
@@ -356,7 +352,7 @@ export class Binder {
|
||||
|
||||
_buildRings() {
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0xd8d2c6, metalness: 0.95, roughness: 0.22,
|
||||
color: 0xd4d7dc, metalness: 0.95, roughness: 0.2,
|
||||
});
|
||||
// 更平滑的活页环
|
||||
const geo = new THREE.TorusGeometry(RING_R, RING_TUBE, 18, 56);
|
||||
@@ -393,9 +389,11 @@ export class Binder {
|
||||
|
||||
const frontMat = new THREE.MeshStandardMaterial({
|
||||
map: front, roughness: 0.88, metalness: 0.0, side: THREE.FrontSide,
|
||||
transparent: true, alphaTest: 0.5,
|
||||
});
|
||||
const backMat = new THREE.MeshStandardMaterial({
|
||||
map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide,
|
||||
transparent: true, alphaTest: 0.5,
|
||||
});
|
||||
|
||||
const frontMesh = new THREE.Mesh(geo, frontMat);
|
||||
@@ -418,23 +416,28 @@ export class Binder {
|
||||
backMat,
|
||||
angle: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
targetZ: 0,
|
||||
});
|
||||
|
||||
this.group.add(g);
|
||||
});
|
||||
|
||||
this._restack();
|
||||
this._restack(true);
|
||||
}
|
||||
|
||||
// 各页休息位置:角度 + 世界 z(堆叠深度)
|
||||
_restack() {
|
||||
// 各页休息位置:角度 + 目标 z(堆叠深度);z 由 update 里平滑动画过渡
|
||||
_restack(instant = false) {
|
||||
this.pages.forEach((p, j) => {
|
||||
const angle = j < this.current ? -Math.PI : 0;
|
||||
p.angle = angle;
|
||||
p.group.rotation.y = angle;
|
||||
const depth = j < this.current ? (this.current - 1 - j) : (j - this.current);
|
||||
const z = -depth * STACK_GAP;
|
||||
p.group.position.z = z;
|
||||
p.targetZ = -depth * STACK_GAP;
|
||||
if (instant) {
|
||||
p.z = p.targetZ;
|
||||
p.group.position.z = p.z;
|
||||
}
|
||||
p.group.position.y = 0;
|
||||
p.group.position.x = 0;
|
||||
// 下层页略暗
|
||||
@@ -445,9 +448,10 @@ export class Binder {
|
||||
this._updateCurvature();
|
||||
}
|
||||
|
||||
_updateCurvature() {
|
||||
_updateCurvature(index = null) {
|
||||
// 翻页时整页柔软弧弯:自由边最高、中段有“腹”、书脊侧贴环;顶边略强于底边;静止时完全平整
|
||||
this.pages.forEach((p) => {
|
||||
const pages = index === null ? this.pages : [this.pages[index]];
|
||||
pages.forEach((p) => {
|
||||
const pos = p.geometry.attributes.position;
|
||||
const arr = pos.array;
|
||||
const theta = Math.abs(p.angle); // 0..π
|
||||
@@ -472,23 +476,29 @@ export class Binder {
|
||||
syncTo(index) {
|
||||
this.current = clamp(index, 0, this.pages.length - 1);
|
||||
this.flip = null;
|
||||
this._restack();
|
||||
this._restack(true);
|
||||
if (this.onChange) this.onChange(this.current);
|
||||
}
|
||||
|
||||
canFlipForward() { return !this.flip && this.current < this.pages.length - 1; }
|
||||
canFlipForward() { return !this.flip; }
|
||||
canFlipBackward() { return !this.flip && this.current > 0; }
|
||||
|
||||
flipForward() {
|
||||
if (!this.canFlipForward()) return;
|
||||
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.6 };
|
||||
this.pages[this.current].group.position.z = 0.006;
|
||||
const p = this.pages[this.current];
|
||||
p.z = 0.006; p.targetZ = 0.006; p.group.position.z = 0.006;
|
||||
// 优先更新底部导航与信息卡(翻页开始即切换目标页)
|
||||
if (this.onChange) this.onChange((this.current + 1) % this.pages.length);
|
||||
}
|
||||
|
||||
flipBackward() {
|
||||
if (!this.canFlipBackward()) return;
|
||||
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.6 };
|
||||
this.pages[this.current - 1].group.position.z = 0.006;
|
||||
const p = this.pages[this.current - 1];
|
||||
p.z = 0.006; p.targetZ = 0.006; p.group.position.z = 0.006;
|
||||
// 优先更新底部导航与信息卡
|
||||
if (this.onChange) this.onChange(this.current - 1);
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
@@ -502,15 +512,24 @@ export class Binder {
|
||||
const p = this.pages[f.index];
|
||||
p.angle = angle;
|
||||
p.group.rotation.y = angle;
|
||||
this._updateCurvature();
|
||||
this._updateCurvature(f.index);
|
||||
|
||||
if (k >= 1) {
|
||||
if (f.to === -Math.PI) this.current += 1; else this.current -= 1;
|
||||
if (f.to === -Math.PI) {
|
||||
// 最后一页翻完后循环回第一页
|
||||
this.current = (this.current + 1) % this.pages.length;
|
||||
} else this.current -= 1;
|
||||
this.flip = null;
|
||||
this._restack();
|
||||
if (this.onChange) this.onChange(this.current);
|
||||
this._restack(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面堆叠进退动画:z 平滑过渡到目标
|
||||
const lerpK = Math.min(1, dt * 14);
|
||||
this.pages.forEach((p) => {
|
||||
p.z += (p.targetZ - p.z) * lerpK;
|
||||
p.group.position.z = p.z;
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
+14
-13
@@ -7,6 +7,8 @@ export const TIMELINE_SPREAD = 6.2; // 横轴范围 ±6.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 * TIMELINE_SPREAD - TIMELINE_SPREAD;
|
||||
@@ -24,27 +26,21 @@ export class Timeline {
|
||||
|
||||
_buildLine() {
|
||||
const n = ENTRIES.length;
|
||||
const min = cardX(0, n) - 0.4;
|
||||
const max = cardX(n - 1, n) + 0.4;
|
||||
// 轴线向两侧延伸足够远(并关闭雾化),滚动到边缘也不见端点,视觉上无限长
|
||||
const extent = 50;
|
||||
|
||||
const lineMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.7 });
|
||||
const lineMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.7, fog: false });
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(min, -0.85, 0), new THREE.Vector3(max, -0.85, 0),
|
||||
new THREE.Vector3(-extent, AXIS_Y, 0), new THREE.Vector3(extent, AXIS_Y, 0),
|
||||
]);
|
||||
this.group.add(new THREE.Line(lineGeo, lineMat));
|
||||
|
||||
const glowMat = new THREE.LineBasicMaterial({ color: 0xe0c46a, transparent: true, opacity: 0.22 });
|
||||
const glowGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(min, -0.855, -0.03), new THREE.Vector3(max, -0.855, -0.03),
|
||||
]);
|
||||
this.group.add(new THREE.Line(glowGeo, glowMat));
|
||||
|
||||
// 刻度:向上指向卡片、向下指向时间标签
|
||||
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, -0.98, 0), new THREE.Vector3(x, -0.7, 0),
|
||||
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));
|
||||
});
|
||||
@@ -73,12 +69,12 @@ export class Timeline {
|
||||
card.add(outline);
|
||||
|
||||
// 活页上方:主题(常显,随选中上浮,超宽居中换行)
|
||||
const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.13, maxWidth: 1.0 });
|
||||
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, -1.06, 0);
|
||||
time.position.set(x, TIME_Y, 0);
|
||||
|
||||
this.group.add(card, name, time);
|
||||
this.cards.push({ card, outline, name, nameBaseY: NAME_Y, index: i });
|
||||
@@ -94,4 +90,9 @@ export class Timeline {
|
||||
c.name.position.y = c.nameBaseY + (on ? CARD_FLOAT : 0);
|
||||
});
|
||||
}
|
||||
|
||||
// 返回某张卡片在时间轴组内的横向坐标(用于点击后居中)
|
||||
getCardX(index) {
|
||||
return cardX(index, ENTRIES.length);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user