/**
* 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(
``,
);
drawn++;
}
}
return `
${chips.join('\n ')}
`;
}
/** 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) => `
`,
);
return `
${tileSvg.join('\n ')}
`;
}
/**
* 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 `
`;
}).join('\n ');
return `
${drops}
`;
}
/** 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) => `
`,
)
.join('\n ');
return `
${chain}
`;
}
const 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'));