/** * 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 = ` ${Array.from({ length: 24 }, (_, i) => ``).join('\n ')} ${HOSTS.map((h, i) => hostPanel(h, 97 + i * 13)).join('\n ')} ${flows()} ${hub()} ${automation()} `; 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'));