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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user