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:
+8
-3
@@ -9,7 +9,7 @@ import { clamp, easeInOutCubic } from '../utils/easing.js';
|
||||
|
||||
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: 6 },
|
||||
timeline: { pos: new THREE.Vector3(0, 1.4, 6.86), target: new THREE.Vector3(0, 0, 0), max: 8, min: 1.2 },
|
||||
globe: { pos: new THREE.Vector3(0, 0.35, 3.7), target: new THREE.Vector3(0, 0, 0), max: 8, min: 2.2 },
|
||||
};
|
||||
|
||||
@@ -164,7 +164,7 @@ export class App {
|
||||
this.interactives.binder.push(m);
|
||||
});
|
||||
});
|
||||
this.timeline.cards.forEach((c) => this.interactives.timeline.push(c.card));
|
||||
this.timeline.cards.forEach((c) => this.interactives.timeline.push(c.front, c.back));
|
||||
// 地理视图由 Cesium 自行拾取,不参与 Three.js 射线检测
|
||||
}
|
||||
|
||||
@@ -291,7 +291,10 @@ export class App {
|
||||
document.getElementById('detail-card').classList.remove('hidden');
|
||||
this.binder.syncTo(index);
|
||||
// 时间轴:点击后滑动使该卡片居中
|
||||
if (view === 'timeline') this._centerTimeline(index);
|
||||
if (view === 'timeline') {
|
||||
this._centerTimeline(index);
|
||||
this.timeline.flip(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +316,7 @@ export class App {
|
||||
this.overlay.setEntry(entry, 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);
|
||||
}
|
||||
|
||||
@@ -438,6 +442,7 @@ export class App {
|
||||
const dt = Math.min(this.clock.getDelta(), 0.05);
|
||||
|
||||
this.binder.update(dt);
|
||||
this.timeline.update(dt);
|
||||
|
||||
if (this.cameraTween) {
|
||||
const t = this.cameraTween;
|
||||
|
||||
+1874
-92
File diff suppressed because it is too large
Load Diff
+210
-21
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { ENTRIES, CATEGORY_INFO } from '../data/entries.js';
|
||||
import { ENTRIES } from '../data/entries.js';
|
||||
|
||||
// 免费、无需 Ion 令牌的影像与地形源
|
||||
const IMAGERY_URL =
|
||||
@@ -7,8 +7,59 @@ const IMAGERY_URL =
|
||||
const TERRAIN_URL =
|
||||
'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer';
|
||||
|
||||
// 点击标记后相机飞到的目标高度(米,约 1500 km 区域尺度)
|
||||
const FLY_TO_HEIGHT = 1.5e6;
|
||||
// 点击标记后飞到的目标高度(米,与初始视图一致,保证左键拖拽始终是“整球旋转”)
|
||||
const FLY_TO_HEIGHT = 7.2e6;
|
||||
|
||||
// 计算多边形集合的球面面积(km²),用于按国土面积分级国家名字号
|
||||
function sphericalAreaKm2(polys) {
|
||||
let area = 0;
|
||||
for (let p = 0; p < polys.length; p++) {
|
||||
const rings = polys[p];
|
||||
for (let r = 0; r < rings.length; r++) {
|
||||
const ring = rings[r];
|
||||
let s = 0;
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const [lng1, lat1] = ring[i];
|
||||
const [lng2, lat2] = ring[(i + 1) % ring.length];
|
||||
let dlng = ((lng2 - lng1) * Math.PI) / 180;
|
||||
while (dlng > Math.PI) dlng -= 2 * Math.PI;
|
||||
while (dlng < -Math.PI) dlng += 2 * Math.PI;
|
||||
const f1 = (lat1 * Math.PI) / 180;
|
||||
const f2 = (lat2 * Math.PI) / 180;
|
||||
s += dlng * (2 + Math.sin(f1) + Math.sin(f2));
|
||||
}
|
||||
const ringArea = Math.abs(s) / 2;
|
||||
area += (r === 0 ? 1 : -1) * ringArea;
|
||||
}
|
||||
}
|
||||
const R = 6371; // km
|
||||
return area * R * R;
|
||||
}
|
||||
|
||||
// 生成圆角黑色背景 canvas(尺寸按文字实际测量宽度)
|
||||
function makeRoundedBg(text, fontSize) {
|
||||
const padX = 12, padY = 7, radius = 10;
|
||||
const m = document.createElement('canvas');
|
||||
const mctx = m.getContext('2d');
|
||||
mctx.font = `${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`;
|
||||
const textW = mctx.measureText(text).width;
|
||||
const w = Math.max(1, Math.ceil(textW + padX * 2));
|
||||
const h = Math.ceil(fontSize + padY * 2);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(radius, 0);
|
||||
ctx.arcTo(w, 0, w, h, radius);
|
||||
ctx.arcTo(w, h, 0, h, radius);
|
||||
ctx.arcTo(0, h, 0, 0, radius);
|
||||
ctx.arcTo(0, 0, w, 0, radius);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(20, 16, 13, 0.88)';
|
||||
ctx.fill();
|
||||
return { canvas: c, textW };
|
||||
}
|
||||
|
||||
export class CesiumGlobe {
|
||||
constructor({ onPick } = {}) {
|
||||
@@ -61,6 +112,7 @@ export class CesiumGlobe {
|
||||
controller.maximumZoomDistance = 8e6; // 最远约 8000 km
|
||||
|
||||
// 限制鼠标按钮:只保留左键旋转 + 滚轮缩放;禁用中键倾斜、右键缩放
|
||||
controller.rotateEventTypes = Cesium.CameraEventType.LEFT_DRAG;
|
||||
controller.zoomEventTypes = [
|
||||
Cesium.CameraEventType.WHEEL,
|
||||
Cesium.CameraEventType.PINCH,
|
||||
@@ -104,6 +156,8 @@ export class CesiumGlobe {
|
||||
|
||||
this._buildMarkers();
|
||||
this._setupPick();
|
||||
this._loadBoundaries();
|
||||
this._buildCityLabels();
|
||||
|
||||
viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(35, 12, 7.2e6),
|
||||
@@ -117,40 +171,64 @@ export class CesiumGlobe {
|
||||
const ink = Cesium.Color.fromCssColorString('#14100d');
|
||||
|
||||
ENTRIES.forEach((entry, i) => {
|
||||
const info = CATEGORY_INFO[entry.category] || CATEGORY_INFO['事'];
|
||||
const color = Cesium.Color.fromCssColorString(info.color);
|
||||
|
||||
const entity = viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat),
|
||||
point: {
|
||||
pixelSize: 8,
|
||||
color,
|
||||
outlineColor: gold,
|
||||
outlineWidth: 1.5,
|
||||
pixelSize: 10, // 标记宽一点
|
||||
color: gold, // 金色圆形
|
||||
outlineColor: ink, // 外侧 1px 黑边
|
||||
outlineWidth: 1,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
|
||||
},
|
||||
label: {
|
||||
text: entry.name,
|
||||
font: '17px Georgia, "Noto Serif SC", "Songti SC", "Microsoft YaHei", serif',
|
||||
fillColor: ivory,
|
||||
font: '19px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif',
|
||||
fillColor: gold,
|
||||
outlineColor: ink,
|
||||
outlineWidth: 3,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.LEFT,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
pixelOffset: new Cesium.Cartesian2(10, 0),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
show: false,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
|
||||
show: true,
|
||||
},
|
||||
});
|
||||
|
||||
entity.entryIndex = i;
|
||||
this.markers.push({ entity, index: i });
|
||||
|
||||
// 选中时显示的圆角黑色背景
|
||||
const bg = makeRoundedBg(entry.name, 19);
|
||||
const bgEntity = viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat),
|
||||
billboard: {
|
||||
image: bg.canvas,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
pixelOffset: new Cesium.Cartesian2(10 + bg.textW / 2, 0),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
show: false,
|
||||
},
|
||||
});
|
||||
|
||||
this.markers.push({ entity, bg: bgEntity, index: i });
|
||||
});
|
||||
}
|
||||
|
||||
_setupPick() {
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas);
|
||||
const viewer = this.viewer;
|
||||
// 移除 Viewer 默认的单击选中与双击“追踪实体”:否则双击后会锁定相机(定住)
|
||||
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||||
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction((movement) => {
|
||||
const picked = this.viewer.scene.pick(movement.position);
|
||||
const picked = viewer.scene.pick(movement.position);
|
||||
if (Cesium.defined(picked) && picked.id && picked.id.entryIndex !== undefined) {
|
||||
const index = picked.id.entryIndex;
|
||||
this.onPick?.(index);
|
||||
@@ -160,10 +238,13 @@ export class CesiumGlobe {
|
||||
this._handler = handler;
|
||||
}
|
||||
|
||||
// 相机平滑飞至某个标记上空,并将其居中(正俯视,北朝上)
|
||||
// 相机平滑飞至某个标记上空并居中:回到与初始一致的高度与北朝上方向,左键拖拽手感不变
|
||||
flyToEntry(index) {
|
||||
const entry = ENTRIES[index];
|
||||
if (!entry || !this.viewer) return;
|
||||
const now = Date.now();
|
||||
if (this._lastFly && now - this._lastFly < 400) return; // 双击/连点去抖,避免重复飞
|
||||
this._lastFly = now;
|
||||
this.viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat, FLY_TO_HEIGHT),
|
||||
orientation: {
|
||||
@@ -175,13 +256,121 @@ export class CesiumGlobe {
|
||||
});
|
||||
}
|
||||
|
||||
// 加载行政区划(国家边界,自然地球 110m):把多边形轮廓转成贴合地形的金色细线 + 国家名标签
|
||||
async _loadBoundaries() {
|
||||
try {
|
||||
const res = await fetch('/world-countries.geojson');
|
||||
const geojson = await res.json();
|
||||
const textGold = Cesium.Color.fromCssColorString('#e0c46a');
|
||||
const black = Cesium.Color.fromCssColorString('#14100d');
|
||||
|
||||
const lines = [];
|
||||
for (const feature of geojson.features || []) {
|
||||
const props = feature.properties || {};
|
||||
const geom = feature.geometry;
|
||||
const isPoly = geom && (geom.type === 'Polygon' || geom.type === 'MultiPolygon');
|
||||
const polys = isPoly ? (geom.type === 'MultiPolygon' ? geom.coordinates : [geom.coordinates]) : [];
|
||||
const areaKm2 = isPoly ? sphericalAreaKm2(polys) : 0;
|
||||
|
||||
// 国家名(优先中文),落在 NATURAL EARTH 提供的标注点上,字号按国土面积分级
|
||||
const cname = props.NAME_ZH || props.NAME;
|
||||
if (cname && props.LABEL_X != null && props.LABEL_Y != null) {
|
||||
const fontSize = areaKm2 > 5e6 ? 30 : areaKm2 > 1.5e6 ? 19 : areaKm2 > 3e5 ? 15 : 12;
|
||||
this.viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(props.LABEL_X, props.LABEL_Y),
|
||||
label: {
|
||||
text: cname,
|
||||
font: `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`,
|
||||
fillColor: black,
|
||||
style: Cesium.LabelStyle.FILL,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(1e6, 1e7),
|
||||
scaleByDistance: new Cesium.NearFarScalar(1e4, 20 / 15, 6e6, 1.0),
|
||||
translucencyByDistance: new Cesium.NearFarScalar(9e6, 1.0, 1e7, 0.0),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!isPoly) continue;
|
||||
for (const poly of polys) {
|
||||
for (const ring of poly) {
|
||||
if (ring.length < 2) continue;
|
||||
const flat = [];
|
||||
for (const [lng, lat] of ring) flat.push(lng, lat);
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(flat);
|
||||
// 行政区划:一根黑线
|
||||
lines.push(new Cesium.GeometryInstance({ geometry: new Cesium.GroundPolylineGeometry({ positions, width: 2.0 }) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length) {
|
||||
this.viewer.scene.groundPrimitives.add(
|
||||
new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: lines,
|
||||
appearance: new Cesium.PolylineMaterialAppearance({
|
||||
material: Cesium.Material.fromType('Color', { color: black }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[CesiumGlobe] 行政区划加载失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 全部城市:Natural Earth 10m 人口稠密点(7342 个),中文名直接用数据自带的 name_zh
|
||||
async _buildCityLabels() {
|
||||
const viewer = this.viewer;
|
||||
const textGold = Cesium.Color.fromCssColorString('#e0c46a');
|
||||
const black = Cesium.Color.fromCssColorString('#14100d');
|
||||
const ddc = new Cesium.DistanceDisplayCondition(0, 4e6);
|
||||
const tdc = new Cesium.NearFarScalar(2e6, 1.0, 4e6, 0.0);
|
||||
|
||||
let cities = [];
|
||||
try {
|
||||
const res = await fetch('/world-cities-zh.json');
|
||||
cities = await res.json();
|
||||
} catch (err) {
|
||||
console.warn('[CesiumGlobe] 城市数据加载失败:', err);
|
||||
cities = [];
|
||||
}
|
||||
|
||||
for (const c of cities) {
|
||||
viewer.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(c.lng, c.lat),
|
||||
point: {
|
||||
pixelSize: 3,
|
||||
color: textGold,
|
||||
outlineColor: black,
|
||||
outlineWidth: 1,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
distanceDisplayCondition: ddc,
|
||||
translucencyByDistance: tdc,
|
||||
},
|
||||
label: {
|
||||
text: c.name,
|
||||
font: 'bold 14px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif',
|
||||
fillColor: black,
|
||||
style: Cesium.LabelStyle.FILL,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -6),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
distanceDisplayCondition: ddc,
|
||||
translucencyByDistance: tdc,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSelected(index) {
|
||||
this.selected = index;
|
||||
if (!this.viewer) return;
|
||||
this.markers.forEach(({ entity, index: i }) => {
|
||||
this.markers.forEach(({ entity, bg, index: i }) => {
|
||||
const on = i === index;
|
||||
entity.point.pixelSize = on ? 12 : 8;
|
||||
entity.label.show = on;
|
||||
entity.point.outlineWidth = on ? 3 : 1; // 选中加粗黑圈,不放大
|
||||
if (bg) bg.show = on;
|
||||
});
|
||||
this.viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
+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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+13
-8
@@ -61,12 +61,12 @@ html, body {
|
||||
}
|
||||
.subtitle {
|
||||
margin-top: 4px; font-size: 12px; letter-spacing: 0.18em;
|
||||
color: rgba(230,216,182,0.65); font-family: Georgia, serif;
|
||||
color: rgba(230,216,182,0.65); font-family: var(--serif);
|
||||
}
|
||||
|
||||
/* 视图切换 */
|
||||
/* 视图切换(位于底部导航栏内) */
|
||||
.view-switch {
|
||||
top: 26px; right: 26px; display: flex; gap: 8px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.view-btn {
|
||||
font-family: var(--serif); font-size: 14px; letter-spacing: 0.1em;
|
||||
@@ -100,10 +100,15 @@ html, body {
|
||||
.nav-btn:active { transform: scale(0.96); }
|
||||
.flip-hint { width: auto; border-radius: 20px; padding: 0 18px; letter-spacing: 0.12em; font-size: 14px; }
|
||||
.page-indicator { display: flex; align-items: baseline; gap: 6px; min-width: 200px; justify-content: center; }
|
||||
#page-num { font-size: 24px; color: var(--gold-soft); font-family: Georgia, serif; }
|
||||
#page-num { font-size: 24px; color: var(--gold-soft); font-family: var(--serif); }
|
||||
.sep { color: rgba(230,216,182,0.5); }
|
||||
#page-total { color: rgba(230,216,182,0.6); font-family: Georgia, serif; }
|
||||
.page-name { font-size: 14px; color: var(--paper); letter-spacing: 0.05em; margin-left: 10px; }
|
||||
#page-total { color: rgba(230,216,182,0.6); font-family: var(--serif); }
|
||||
.page-name {
|
||||
font-size: 14px; color: var(--paper); letter-spacing: 0.05em; margin-left: 10px;
|
||||
flex: 0 0 160px; /* 固定宽度,容纳多数词条名,避免翻页时布局抖动 */
|
||||
text-align: center;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 登记卡详情 */
|
||||
.detail-card {
|
||||
@@ -115,7 +120,7 @@ html, body {
|
||||
}
|
||||
.detail-card.hidden { opacity: 0; pointer-events: none; transform: translateY(14px); }
|
||||
.detail-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.detail-no { font-family: Georgia, serif; font-size: 13px; letter-spacing: 0.14em; color: var(--ink-soft); }
|
||||
.detail-no { font-family: var(--serif); font-size: 13px; letter-spacing: 0.14em; color: var(--ink-soft); }
|
||||
.detail-cat {
|
||||
font-size: 12px; padding: 2px 10px; border-radius: 2px;
|
||||
background: var(--leather); color: var(--gold-soft); letter-spacing: 0.2em;
|
||||
@@ -134,7 +139,7 @@ html, body {
|
||||
border-left: 3px solid var(--gold); padding-left: 10px;
|
||||
}
|
||||
.detail-sketch { margin-top: 12px; border-top: 1px dashed rgba(90,61,43,0.4); padding-top: 10px; }
|
||||
.detail-sketch canvas { width: 100%; height: auto; border-radius: 2px; opacity: 0.94; }
|
||||
.detail-sketch canvas { width: 100%; height: 170px; object-fit: contain; border-radius: 2px; opacity: 0.94; }
|
||||
|
||||
/* 提示 */
|
||||
.hint {
|
||||
|
||||
+49
-49
@@ -7,13 +7,17 @@ export const PAGE_W = 1.6; // 世界单位:页宽
|
||||
export const PAGE_H = 2.24; // 世界单位:页高
|
||||
export const TEX_W = 768;
|
||||
export const TEX_H = 1075;
|
||||
// 输出纹理与绘制分辨率一致,保证文字清晰
|
||||
export const TEX_OUT_W = 768;
|
||||
export const TEX_OUT_H = 1075;
|
||||
export const HOLE_U = [0.1, 0.26, 0.42, 0.58, 0.74, 0.9]; // 活页孔(自顶部归一化,6孔等距)
|
||||
export const HOLE_X = 0.065; // 孔中心相对页内缘(环前交点 0.085 − 页缘空隙 0.02)
|
||||
export const HOLE_R = 0.029; // 孔半径 ≈5.5mm 直径(真实活页本孔径)
|
||||
|
||||
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",Georgia,serif';
|
||||
const SERIF = 'Georgia, "Noto Serif SC", serif'; // 正文与标题字体一致(时间轴字体)
|
||||
const KAI = '"Kaiti SC","STKaiti","KaiTi","Noto Serif SC",serif';
|
||||
const NUM = 'Georgia,"Times New Roman",serif';
|
||||
const TITLE_FONT = '"Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif'; // 与时间轴标题字体一致
|
||||
|
||||
// 版面边距(左边缘避开活页孔,右/上/下为常规边距)
|
||||
const MARGIN = { left: 86, right: 58, top: 50, bottom: 50 };
|
||||
@@ -65,6 +69,28 @@ export function makePaper(w, h, { tone = 0, ruled = false, margin = false, seed
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
// 烫金斑点:不规则金色斑点(淡、纯色、硬边)
|
||||
ctx.fillStyle = 'rgba(212, 168, 64, 0.5)';
|
||||
const goldBlob = (x, y, r) => {
|
||||
const pts = 8;
|
||||
ctx.beginPath();
|
||||
for (let p = 0; p < pts; p++) {
|
||||
const a = (p / pts) * Math.PI * 2;
|
||||
const rr = r * (0.55 + rand() * 0.9);
|
||||
const px = x + Math.cos(a) * rr;
|
||||
const py = y + Math.sin(a) * rr;
|
||||
if (p === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
};
|
||||
for (let i = 0; i < 70; i++) {
|
||||
goldBlob(rand() * w, rand() * h, rand() * 8 + 3);
|
||||
}
|
||||
for (let i = 0; i < 320; i++) {
|
||||
goldBlob(rand() * w, rand() * h, rand() * 2.4 + 0.8);
|
||||
}
|
||||
|
||||
// 边缘做旧(暗角)
|
||||
const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.4, w / 2, h / 2, Math.max(w, h) * 0.72);
|
||||
edge.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
@@ -174,20 +200,18 @@ export function renderFront(entry) {
|
||||
drawBorder(ctx, w, h);
|
||||
drawHoles(ctx, w, h);
|
||||
|
||||
// 页眉:编号 + 印章
|
||||
// 页眉:编号(去掉右上角印章标记)
|
||||
text(ctx, '编 号 · ' + entry.id, CONTENT_L, 92, { font: NUM, size: 26, color: '#5a432c' });
|
||||
drawSeal(ctx, entry.category, CONTENT_R - 30, 86, 58);
|
||||
text(ctx, info.en, CONTENT_R - 30, 128, { font: NUM, size: 15, color: '#7a6147', align: 'center' });
|
||||
|
||||
// 顶部分隔线
|
||||
ctx.strokeStyle = 'rgba(90,61,43,0.4)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(CONTENT_L, 160); ctx.lineTo(CONTENT_R, 160); ctx.stroke();
|
||||
|
||||
// 名称(大字,居中)
|
||||
// 名称(大字,居中,标题字体与时间轴一致)
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `700 ${fitFont(ctx, entry.name, CONTENT_W - 40, 36, 86)}px ${KAI}`;
|
||||
ctx.font = `700 ${fitFont(ctx, entry.name, CONTENT_W - 40, 36, 86, TITLE_FONT)}px ${TITLE_FONT}`;
|
||||
ctx.fillStyle = '#33271a';
|
||||
ctx.fillText(entry.name, w / 2, 248);
|
||||
// 名称下划线装饰
|
||||
@@ -205,7 +229,6 @@ export function renderFront(entry) {
|
||||
const fields = [
|
||||
['时间', entry.time],
|
||||
['地区', entry.region],
|
||||
['类别', `${entry.category} · ${info.en}`],
|
||||
];
|
||||
fields.forEach(([label, value], i) => {
|
||||
const y = fy + i * rowH;
|
||||
@@ -243,10 +266,6 @@ export function renderFront(entry) {
|
||||
}
|
||||
if (line) ctx.fillText(line, CONTENT_L, yy);
|
||||
|
||||
// 页脚
|
||||
text(ctx, '人类文明里程碑 · 活页编年史', w / 2, h - 52, { font: SERIF, size: 19, color: '#7a6147', align: 'center' });
|
||||
text(ctx, entry.id, CONTENT_R, h - 52, { font: NUM, size: 18, color: '#8a735a', align: 'right' });
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -264,55 +283,24 @@ export function renderBack(entry) {
|
||||
ctx.scale(-1, 1);
|
||||
ctx.translate(-w / 2, 0);
|
||||
|
||||
// 页眉(居中)
|
||||
text(ctx, '素 描 · SKETCH', w / 2, 92, { font: NUM, size: 24, color: '#6b5740', align: 'center' });
|
||||
text(ctx, entry.id, w / 2, 124, { font: NUM, size: 20, color: '#8a735a', align: 'center' });
|
||||
ctx.strokeStyle = 'rgba(90,61,43,0.35)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(w / 2 - 90, 152); ctx.lineTo(w / 2 + 90, 152); ctx.stroke();
|
||||
|
||||
// 速写(居中)
|
||||
// 速写(居中,无文字与装饰线)
|
||||
const sketch = renderSketch(entry, 520);
|
||||
const sx = (w - 520) / 2;
|
||||
const sy = 170;
|
||||
const sy = (h - 520) / 2;
|
||||
ctx.drawImage(sketch, sx, sy);
|
||||
ctx.strokeStyle = 'rgba(90,61,43,0.3)';
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.strokeRect(sx - 8, sy - 8, 520 + 16, 520 + 16);
|
||||
|
||||
// 题注(居中)
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `700 ${fitFont(ctx, entry.name, CONTENT_W - 40, 32, 78)}px ${KAI}`;
|
||||
ctx.fillStyle = '#33271a';
|
||||
ctx.fillText(entry.name, w / 2, 772);
|
||||
|
||||
ctx.font = `400 25px ${SERIF}`;
|
||||
ctx.fillStyle = '#6b5740';
|
||||
ctx.fillText(`${entry.time} · ${entry.region}`, w / 2, 828);
|
||||
|
||||
// 波浪线(居中)
|
||||
ctx.strokeStyle = 'rgba(90,61,43,0.5)';
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.beginPath();
|
||||
for (let x = w / 2 - 130; x <= w / 2 + 130; x += 10) {
|
||||
const yy = 868 + Math.sin((x - w / 2) / 26) * 5;
|
||||
if (x === w / 2 - 130) ctx.moveTo(x, yy); else ctx.lineTo(x, yy);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// 名称自适应字号
|
||||
function fitFont(ctx, str, maxW, minSize, maxSize) {
|
||||
function fitFont(ctx, str, maxW, minSize, maxSize, font = KAI) {
|
||||
if (minSize > maxSize) [minSize, maxSize] = [maxSize, minSize]; // 容错:确保 min < max
|
||||
let size = maxSize;
|
||||
ctx.font = `700 ${size}px ${KAI}`;
|
||||
ctx.font = `700 ${size}px ${font}`;
|
||||
while (ctx.measureText(str).width > maxW && size > minSize) {
|
||||
size -= 2;
|
||||
ctx.font = `700 ${size}px ${KAI}`;
|
||||
ctx.font = `700 ${size}px ${font}`;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
@@ -323,15 +311,27 @@ function hash(str) {
|
||||
return h;
|
||||
}
|
||||
|
||||
// 将高分辨率画布缩小到目标尺寸(保持版面不变,降低显存占用)
|
||||
function downscale(src) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = TEX_OUT_W;
|
||||
c.height = TEX_OUT_H;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(src, 0, 0, TEX_OUT_W, TEX_OUT_H);
|
||||
return c;
|
||||
}
|
||||
|
||||
// 生成页面的两个纹理
|
||||
const texCache = new Map();
|
||||
export function getPageTextures(entry) {
|
||||
if (texCache.has(entry.id)) return texCache.get(entry.id);
|
||||
const front = new THREE.CanvasTexture(renderFront(entry));
|
||||
const front = new THREE.CanvasTexture(downscale(renderFront(entry)));
|
||||
front.colorSpace = THREE.SRGBColorSpace;
|
||||
front.anisotropy = 8;
|
||||
|
||||
const back = new THREE.CanvasTexture(renderBack(entry));
|
||||
const back = new THREE.CanvasTexture(downscale(renderBack(entry)));
|
||||
back.colorSpace = THREE.SRGBColorSpace;
|
||||
back.anisotropy = 8;
|
||||
|
||||
|
||||
+66
-1
@@ -31,7 +31,7 @@ export function renderSketch(entry, size = 760) {
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size * 0.42;
|
||||
const r = size * 0.48;
|
||||
|
||||
const P = (x, y) => [cx + x * r, cy + y * r];
|
||||
|
||||
@@ -579,6 +579,71 @@ export function renderSketch(entry, size = 760) {
|
||||
line([[0.4, 0.5], [0.4, 0.3]], { lw: 1.4 });
|
||||
line([[0.4, 0.3], [0.56, 0.34], [0.4, 0.4]], { lw: 1.4, passes: 1 });
|
||||
},
|
||||
|
||||
book() {
|
||||
// 摊开的书
|
||||
fill([[-0.5, -0.1], [0.0, -0.2], [0.5, -0.1], [0.5, 0.4], [0.0, 0.5], [-0.5, 0.4]], 'rgba(210,190,150,0.18)');
|
||||
line([[-0.5, -0.1], [0.0, -0.2], [0.5, -0.1], [0.5, 0.4], [0.0, 0.5], [-0.5, 0.4], [-0.5, -0.1]], { lw: 2.4 });
|
||||
line([[0.0, -0.2], [0.0, 0.5]], { lw: 2 });
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const y = -0.05 + i * 0.14;
|
||||
line([[-0.4, y], [-0.08, y]], { lw: 1.2, color: INK_SOFT, passes: 1, wobble: 0.004 });
|
||||
line([[0.08, y], [0.4, y]], { lw: 1.2, color: INK_SOFT, passes: 1, wobble: 0.004 });
|
||||
}
|
||||
hatch([[-0.5, -0.1], [0.0, -0.2], [0.5, -0.1], [0.5, 0.4], [0.0, 0.5], [-0.5, 0.4]], { spacing: 0.04, angle: -45, color: INK_FAINT, lw: 1 });
|
||||
},
|
||||
|
||||
bridge() {
|
||||
line([[-0.7, 0.45], [0.7, 0.45]], { lw: 1.6, color: INK_SOFT, passes: 1 });
|
||||
line([[-0.6, 0.6], [0.6, 0.6]], { lw: 1.2, color: INK_FAINT, passes: 1 });
|
||||
fill([[-0.5, 0.45], [-0.5, -0.05], [0.5, -0.05], [0.5, 0.45]], 'rgba(150,140,130,0.10)');
|
||||
line([[-0.5, 0.45], [-0.5, 0.05], [-0.35, -0.2], [0.0, -0.35], [0.35, -0.2], [0.5, 0.05], [0.5, 0.45]], { lw: 2.6 });
|
||||
line([[-0.55, 0.45], [-0.55, 0.28], [0.55, 0.28], [0.55, 0.45]], { lw: 1.6, color: INK_SOFT });
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const x = -0.45 + i * 0.15;
|
||||
line([[x, 0.28], [x, 0.4]], { lw: 1.2, passes: 1, color: INK_SOFT });
|
||||
}
|
||||
line([[-0.3, 0.52], [0.0, 0.56], [0.3, 0.52]], { lw: 1, color: INK_FAINT, passes: 1 });
|
||||
},
|
||||
|
||||
flag() {
|
||||
line([[-0.4, 0.6], [-0.4, -0.7]], { lw: 2.4 });
|
||||
fill([[-0.4, -0.65], [-0.1, -0.6], [0.05, -0.45], [-0.25, -0.5]], 'rgba(160,58,42,0.16)');
|
||||
line([[-0.4, -0.65], [-0.1, -0.6], [0.05, -0.45], [-0.25, -0.5], [-0.4, -0.65]], { lw: 2.2 });
|
||||
line([[-0.32, -0.55], [0.0, -0.48]], { lw: 1.4, color: INK_SOFT, passes: 1 });
|
||||
line([[-0.25, -0.5], [-0.15, -0.32]], { lw: 1.4, color: INK_SOFT, passes: 1 });
|
||||
line([[-0.6, 0.62], [0.6, 0.62]], { lw: 1.6, color: INK_FAINT });
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const a = -0.5 + i * 0.3;
|
||||
line([[0.05 + Math.cos(a) * 0.3, -0.3 + Math.sin(a) * 0.2], [0.05 + Math.cos(a) * 0.42, -0.3 + Math.sin(a) * 0.3]], { lw: 1.2, color: INK_FAINT, passes: 1 });
|
||||
}
|
||||
},
|
||||
|
||||
scale() {
|
||||
line([[0.0, 0.6], [0.0, -0.15]], { lw: 2.4 });
|
||||
line([[-0.55, -0.05], [0.55, -0.05]], { lw: 2.2 });
|
||||
circle(0.0, -0.1, 0.06, { lw: 1.8, passes: 1 });
|
||||
line([[-0.55, -0.05], [-0.55, 0.2]], { lw: 1.6, passes: 1 });
|
||||
line([[0.55, -0.05], [0.55, 0.2]], { lw: 1.6, passes: 1 });
|
||||
line([[-0.72, 0.2], [-0.38, 0.2]], { lw: 2 });
|
||||
line([[0.38, 0.2], [0.72, 0.2]], { lw: 2 });
|
||||
line([[-0.7, 0.2], [-0.72, 0.34], [-0.38, 0.34], [-0.4, 0.2]], { lw: 1.6, color: INK_SOFT, passes: 1 });
|
||||
line([[0.38, 0.2], [0.4, 0.34], [0.7, 0.34], [0.72, 0.2]], { lw: 1.6, color: INK_SOFT, passes: 1 });
|
||||
line([[-0.3, 0.6], [0.3, 0.6]], { lw: 2.6 });
|
||||
line([[-0.2, 0.68], [0.2, 0.68]], { lw: 2 });
|
||||
},
|
||||
|
||||
sword() {
|
||||
line([[0.1, -0.6], [0.0, -0.05], [-0.02, 0.15]], { lw: 2.6 });
|
||||
line([[0.1, -0.6], [0.05, -0.05], [0.02, 0.15]], { lw: 1.4, color: INK_SOFT, passes: 1 });
|
||||
fill([[0.1, -0.6], [0.0, -0.05], [0.05, -0.05]], 'rgba(150,150,160,0.10)');
|
||||
line([[-0.25, 0.05], [0.22, -0.02]], { lw: 3 });
|
||||
line([[-0.02, 0.15], [0.05, 0.42]], { lw: 2.4 });
|
||||
circle(0.02, 0.45, 0.05, { lw: 1.6, passes: 1 });
|
||||
line([[-0.5, 0.6], [0.5, 0.6]], { lw: 1.4, color: INK_FAINT, passes: 1 });
|
||||
line([[0.15, -0.5], [0.28, -0.42]], { lw: 1.2, color: INK_FAINT, passes: 1 });
|
||||
line([[0.18, -0.3], [0.32, -0.22]], { lw: 1, color: INK_FAINT, passes: 1 });
|
||||
},
|
||||
};
|
||||
|
||||
const draw = types[entry.sketch] || types.fossil;
|
||||
|
||||
+7
-10
@@ -1,4 +1,3 @@
|
||||
import { CATEGORY_INFO } from '../data/entries.js';
|
||||
import { renderSketch } from '../textures/sketch.js';
|
||||
|
||||
// HTML 覆盖层:标题、视图切换、底部导航、登记卡详情
|
||||
@@ -15,9 +14,7 @@ export class Overlay {
|
||||
pageTotal: document.getElementById('page-total'),
|
||||
pageName: document.getElementById('page-name'),
|
||||
detailNo: document.getElementById('detail-no'),
|
||||
detailCat: document.getElementById('detail-cat'),
|
||||
detailName: document.getElementById('detail-name'),
|
||||
detailName2: document.getElementById('detail-name2'),
|
||||
detailTime: document.getElementById('detail-time'),
|
||||
detailRegion: document.getElementById('detail-region'),
|
||||
detailDesc: document.getElementById('detail-desc'),
|
||||
@@ -35,7 +32,7 @@ export class Overlay {
|
||||
});
|
||||
document.getElementById('btn-prev').addEventListener('click', () => this.onPrev());
|
||||
document.getElementById('btn-next').addEventListener('click', () => this.onNext());
|
||||
document.getElementById('btn-flip').addEventListener('click', () => this.onFlip());
|
||||
document.getElementById('btn-flip')?.addEventListener('click', () => this.onFlip());
|
||||
}
|
||||
|
||||
setTotal(n) {
|
||||
@@ -55,24 +52,24 @@ export class Overlay {
|
||||
}
|
||||
|
||||
setEntry(entry, index, total) {
|
||||
const info = CATEGORY_INFO[entry.category] || CATEGORY_INFO['事'];
|
||||
this.els.pageNum.textContent = String(index + 1).padStart(2, '0');
|
||||
this.els.pageName.textContent = entry.name;
|
||||
this.els.detailNo.textContent = entry.id;
|
||||
this.els.detailCat.textContent = entry.category;
|
||||
this.els.detailCat.style.background = info.color;
|
||||
this.els.detailName.textContent = entry.name;
|
||||
this.els.detailName2.textContent = entry.name;
|
||||
this.els.detailTime.textContent = entry.time;
|
||||
this.els.detailRegion.textContent = entry.region;
|
||||
this.els.detailDesc.textContent = entry.description;
|
||||
|
||||
// 素描缩略图
|
||||
// 素描缩略图(等比放大填满,裁掉上下多余部分)
|
||||
const c = this.els.detailSketch;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
const sketch = renderSketch(entry, 560);
|
||||
ctx.drawImage(sketch, (c.width - c.height) / 2, 0, c.height, c.height);
|
||||
const sw = sketch.width, sh = sketch.height;
|
||||
const cw = c.width, ch = c.height;
|
||||
const scale = Math.min(cw / sw, ch / sh);
|
||||
const dw = sw * scale, dh = sh * scale;
|
||||
ctx.drawImage(sketch, (cw - dw) / 2, (ch - dh) / 2, dw, dh);
|
||||
}
|
||||
|
||||
irisTo(visible) {
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ export function makeTextSprite(text, {
|
||||
stroke = 'rgba(20,12,6,0.9)',
|
||||
strokeWidth = 6,
|
||||
padding = 24,
|
||||
font = 'Georgia, "Noto Serif SC", serif',
|
||||
font = '"Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif',
|
||||
weight = '600',
|
||||
scale = 1, // 单行文字的精灵世界高度
|
||||
maxWidth = Infinity, // 精灵世界最大宽度,超宽则居中换行
|
||||
|
||||
Reference in New Issue
Block a user