Files
hoelee-blog/scripts/gen-case-studies-banner.mjs
T
hoelee 041471d25e feat(listing): portfolio-first archive, case-study hero, row action links
/posts/ and /zh/posts/ become hybrid: three fixed-slug flagship cards above the
complete year/month archive, with the flagship block collapsing (inert, h=0) as
soon as the reader filters. /categories/case-studies/ becomes a portfolio: compact
two-column hero with a 21:9 deterministic topology banner, flagship ordering from
lib/portfolio.ts instead of pubDate, and one measured pill per card.

Also fixes what the screenshot review turned up: ZH archive rows and category chips
were printing raw English slugs, the two chip strips were unlabelled and read as one
list with a duplicated 'devops', card meta rows broke rhythm past four tags, the nav
had no current-section indicator, and mobile title links were under the 44px tap
minimum. scripts/probe-layout.mjs is the repeatable gate: 72/72 page x viewport
checks and the collapse test.
2026-10-27 04:06:54 +08:00

240 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Generates the case-studies portfolio banner — 21:9, no baked-in text.
*
* The consultant's spec for /categories/case-studies/ asks for a wide architecture
* visual: three production hosts, Docker, Prometheus and the automation flow, in a
* dark terminal grey-green with restrained warm-copper accents, rendered into a
* 240–300 px tall band beside the hero copy. Everything here is geometry, no words:
* at that display height any label would be unreadable, and the headline copy has to
* stay semantic HTML.
*
* Deterministic (seeded), so regenerating never churns the committed asset.
*
* node scripts/gen-case-studies-banner.mjs
*
* Writes:
* public/images/categories/case-studies-banner.webp 2400x1029 (21:9)
* public/images/categories/case-studies-banner-1200.webp 1200x514
* public/images/categories/case-studies-banner.svg the source geometry
*/
import { mkdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
const W = 2400;
const H = 1029; // 21:9
// Host panels: the real fleet is 3 hosts / 162 containers, and the visual carries the
// same proportions (Unraid 62, Synology 78, VPS 22) so the art is not decorative lies.
const HOSTS = [
{ x: 130, y: 300, w: 300, rows: 10, cols: 10, count: 62, hot: [3, 17, 41] },
{ x: 520, y: 210, w: 320, rows: 12, cols: 10, count: 78, hot: [2, 9, 33, 60] },
{ x: 930, y: 396, w: 280, rows: 7, cols: 10, count: 22, hot: [6, 15] },
];
const HUB = { cx: 1560, cy: 514 };
// Deterministic pseudo-random: same seed -> same picture, forever.
function rnd(seed) {
let s = seed;
return () => {
s = (s * 1103515245 + 12345) % 2147483648;
return s / 2147483648;
};
}
function hostHeight(host) {
return 26 + 14 * 2 + host.rows * 15;
}
function hostPanel(host, seed) {
const r = rnd(seed);
const head = 26;
const pad = 14;
const cols = host.cols;
const cw = 20;
const ch = 11;
const gx = (host.w - pad * 2 - cols * cw) / (cols - 1);
const gy = 15;
const h = hostHeight(host);
const chips = [];
let drawn = 0;
const hot = new Set(host.hot);
for (let row = 0; row < host.rows && drawn < host.count; row++) {
for (let col = 0; col < cols && drawn < host.count; col++) {
const i = row * cols + col;
const accent = hot.has(i)
? '#d08b4a'
: r() > 0.86
? '#4fae7a'
: r() > 0.5
? '#3a7157'
: '#2e5c48';
chips.push(
`<rect x="${(host.x + pad + col * (cw + gx)).toFixed(1)}" y="${(host.y + head + pad + row * gy).toFixed(1)}" width="${cw}" height="${ch}" rx="2.5" fill="${accent}" />`,
);
drawn++;
}
}
return `<g>
<rect x="${host.x}" y="${host.y}" width="${host.w}" height="${h}" rx="12" fill="#0f1d18" stroke="#2f5342" stroke-width="1.8" />
<path d="M ${host.x} ${host.y + head} h ${host.w}" stroke="#355944" stroke-width="1.4" />
<circle cx="${host.x + 18}" cy="${host.y + 13}" r="4" fill="#4fae7a" />
<circle cx="${host.x + 32}" cy="${host.y + 13}" r="4" fill="#3a7157" />
<circle cx="${host.x + 46}" cy="${host.y + 13}" r="4" fill="#d08b4a" />
<rect x="${host.x + host.w - 44}" y="${host.y + 8}" width="32" height="10" rx="5" fill="#1d3229" />
${chips.join('\n ')}
<rect x="${host.x + pad}" y="${host.y + h - 12}" width="${host.w - pad * 2}" height="2" rx="1" fill="#3a7157" opacity="0.8" />
<rect x="${host.x + host.w / 2 - 26}" y="${host.y + h + 8}" width="52" height="4" rx="2" fill="#2f5342" />
</g>`;
}
/** A Prometheus-ish readout hub: concentric rings, a flame glyph, four metric tiles. */
function hub() {
const { cx, cy } = HUB;
const tiles = [
{ top: cy - 196, pts: '0,34 14,26 28,30 42,16 56,20 70,8 84,12' },
{ top: cy - 66, pts: '0,10 14,22 28,14 42,28 56,18 70,30 84,22' },
{ top: cy + 66, pts: '0,28 14,12 28,20 42,6 56,14 70,10 84,18' },
{ top: cy + 196, pts: '0,20 14,18 28,24 42,12 56,16 70,6 84,14' },
].map((t) => ({ ...t, x: cx + 206, y: t.top - 43 }));
const tileSvg = tiles.map(
(t) => `<g>
<rect x="${t.x}" y="${t.y}" width="160" height="86" rx="10" fill="#0f1d18" stroke="#2f5342" stroke-width="1.4" />
<polyline points="${t.pts
.split(' ')
.map((p) => {
const [a, b] = p.split(',').map(Number);
return `${t.x + 16 + a},${t.y + 48 + (b - 20)}`;
})
.join(' ')}" fill="none" stroke="#d08b4a" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round" opacity="0.95" />
<rect x="${t.x + 16}" y="${t.y + 16}" width="46" height="6" rx="3" fill="#3a7157" />
</g>`,
);
return `<g>
<circle cx="${cx}" cy="${cy}" r="196" fill="none" stroke="#3a7157" stroke-width="1.4" stroke-dasharray="4 9" opacity="0.9" />
<circle cx="${cx}" cy="${cy}" r="152" fill="none" stroke="#4a8f6f" stroke-width="1.6" opacity="0.85" />
<circle cx="${cx}" cy="${cy}" r="110" fill="#0d1f1a" stroke="#528b74" stroke-width="2.2" />
<circle cx="${cx}" cy="${cy}" r="110" fill="url(#hubGlow)" opacity="0.55" />
<path d="M ${cx} ${cy - 52} c 30 25 39 39 39 57 a 39 39 0 0 1 -78 0 c 0 -18 9 -32 39 -57 z" fill="#d08b4a" />
<path d="M ${cx} ${cy - 18} c 14 14 18 21 18 30 a 18 18 0 0 1 -36 0 c 0 -9 4 -16 18 -30 z" fill="#0d1f1a" opacity="0.72" />
${tileSvg.join('\n ')}
</g>`;
}
/**
* Hosts → hub wiring, drawn as a network bus rather than three curves across the
* panels: each host drops a stub into a shared copper bus below the fleet, and the
* bus rises into the hub's outer ring. Crossing the panels looked like a mistake at
* banner size, which is exactly the kind of detail nobody should have to decode.
*/
function flows() {
const busY = 760;
const busX = HUB.cx;
const drops = HOSTS.map((host) => {
const x = host.x + host.w / 2;
const y0 = host.y + hostHeight(host) + 22;
return `<g>
<path d="M ${x} ${y0} V ${busY - 6}" fill="none" stroke="#d08b4a" stroke-width="2.4" opacity="0.7" />
<circle cx="${x}" cy="${y0}" r="4.5" fill="#d08b4a" />
<circle cx="${x}" cy="${busY}" r="4" fill="#e8ab76" />
</g>`;
}).join('\n ');
return `<g>
<path d="M 300 ${busY} H ${busX}" fill="none" stroke="#d08b4a" stroke-width="3" opacity="0.7" stroke-linecap="round" />
<path d="M ${busX} ${busY} V ${HUB.cy + 200}" fill="none" stroke="#d08b4a" stroke-width="3" opacity="0.7" />
<circle cx="${busX}" cy="${HUB.cy + 200}" r="5" fill="#e8ab76" />
${drops}
</g>`;
}
/** The automation tail: hub → three workflow nodes, drawn as a green chain. */
function automation() {
const nodes = [
{ x: 1960, y: 320, w: 170, h: 112 },
{ x: 2160, y: 470, w: 170, h: 112 },
{ x: 1960, y: 620, w: 170, h: 112 },
];
const chain = nodes
.map(
(n) => `<g>
<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="10" fill="#0f1d18" stroke="#3a7157" stroke-width="1.6" />
<rect x="${n.x + 18}" y="${n.y + 22}" width="62" height="7" rx="3.5" fill="#4fae7a" />
<rect x="${n.x + 18}" y="${n.y + 44}" width="112" height="6" rx="3" fill="#2e5c48" />
<rect x="${n.x + 18}" y="${n.y + 62}" width="84" height="6" rx="3" fill="#2e5c48" />
<circle cx="${n.x + n.w - 24}" cy="${n.y + 26}" r="5.5" fill="#4fae7a" />
<circle cx="${n.x + n.w - 24}" cy="${n.y + 26}" r="12" fill="none" stroke="#4fae7a" stroke-width="1.2" opacity="0.5" />
</g>`,
)
.join('\n ');
return `<g>
<path d="M ${HUB.cx + 200} ${HUB.cy - 20} C 1860 ${HUB.cy - 20}, 1860 376, 1960 376" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
<path d="M 2130 376 C 2240 376, 2220 526, 2160 526" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
<path d="M 2245 582 C 2245 640, 2160 676, 2130 676" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
${chain}
</g>`;
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#08120f" />
<stop offset="0.55" stop-color="#0c1a15" />
<stop offset="1" stop-color="#0a1512" />
</linearGradient>
<radialGradient id="copperGlow" cx="0.18" cy="0.9" r="0.7">
<stop offset="0" stop-color="#c07a3e" stop-opacity="0.16" />
<stop offset="1" stop-color="#c07a3e" stop-opacity="0" />
</radialGradient>
<radialGradient id="hubGlow" cx="0.5" cy="0.45" r="0.6">
<stop offset="0" stop-color="#d08b4a" stop-opacity="0.55" />
<stop offset="1" stop-color="#d08b4a" stop-opacity="0" />
</radialGradient>
<pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse">
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="#8fb3a2" stroke-width="0.5" opacity="0.06" />
</pattern>
</defs>
<rect width="${W}" height="${H}" fill="url(#bg)" />
<rect width="${W}" height="${H}" fill="url(#grid)" />
<rect width="${W}" height="${H}" fill="url(#copperGlow)" />
<!-- baseline: the network everything sits on -->
<path d="M 60 ${H - 120} H ${W - 60}" stroke="#2f5342" stroke-width="2" />
<path d="M 60 ${H - 120} H ${W - 60}" stroke="#d08b4a" stroke-width="2" stroke-dasharray="120 26 8 26" opacity="0.45" />
${Array.from({ length: 24 }, (_, i) => `<rect x="${90 + i * 96}" y="${H - 136}" width="2" height="30" fill="#2f5342" />`).join('\n ')}
<g transform="translate(0, 24)">
${HOSTS.map((h, i) => hostPanel(h, 97 + i * 13)).join('\n ')}
${flows()}
${hub()}
${automation()}
</g>
</svg>`;
const outDir = path.join(process.cwd(), 'public', 'images', 'categories');
await mkdir(outDir, { recursive: true });
await sharp(Buffer.from(svg)).webp({ quality: 82 }).toFile(path.join(outDir, 'case-studies-banner.webp'));
await sharp(Buffer.from(svg))
.resize(1200, 514, { fit: 'fill' })
.webp({ quality: 80 })
.toFile(path.join(outDir, 'case-studies-banner-1200.webp'));
// Keep the source next to the render so a future edit does not have to reverse-engineer
// the geometry out of a WebP.
await writeFile(path.join(outDir, 'case-studies-banner.svg'), svg, 'utf8');
const report = [];
for (const f of ['case-studies-banner.webp', 'case-studies-banner-1200.webp']) {
const { size } = await stat(path.join(outDir, f));
report.push(`${f}: ${(size / 1024).toFixed(1)} KB`);
}
console.log(report.join('\n'));