|
| 1 | +#!/usr/bin/env node |
| 2 | +// SPDX-License-Identifier: MPL-2.0 |
| 3 | +// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 4 | +// |
| 5 | +// build-banner.mjs — Echo Types repository banner |
| 6 | +// 1920×480, dark theorem-proof aesthetic, no dependencies. |
| 7 | +// |
| 8 | +// Run: node tools/banner/build-banner.mjs |
| 9 | +// Writes: docs/assets/banner.svg (PNG must be re-rasterised separately). |
| 10 | + |
| 11 | +import { writeFileSync, mkdirSync } from 'node:fs'; |
| 12 | +import { fileURLToPath } from 'node:url'; |
| 13 | +import { dirname, join } from 'node:path'; |
| 14 | + |
| 15 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 16 | +const repoRoot = join(__dirname, '..', '..'); |
| 17 | +const outDir = join(repoRoot, 'docs', 'assets'); |
| 18 | +const outPath = join(outDir, 'banner.svg'); |
| 19 | + |
| 20 | +const W = 1920, H = 480; |
| 21 | + |
| 22 | +const C = { |
| 23 | + bg: '#0d1620', |
| 24 | + ink: '#ede4d0', |
| 25 | + inkFaint: '#9aa39c', |
| 26 | + base: '#3a4555', |
| 27 | + baseFaint: '#26303a', |
| 28 | + amber: '#c9a96e', |
| 29 | + title: '#f0e8d4', |
| 30 | + subtitle: '#7d8590', |
| 31 | +}; |
| 32 | + |
| 33 | +const baseY = 275; // the horizontal "base space" axis |
| 34 | +const constY = 232; // vertical centre of each constellation |
| 35 | + |
| 36 | +// ---- Constellation: 4-node square graph with two diagonals ------------------ |
| 37 | +const nodes = [ |
| 38 | + { id: 'NW', x: -22, y: -16 }, |
| 39 | + { id: 'NE', x: 22, y: -16 }, |
| 40 | + { id: 'SW', x: -22, y: 16 }, |
| 41 | + { id: 'SE', x: 22, y: 16 }, |
| 42 | +]; |
| 43 | +const edges = { |
| 44 | + N: ['NW', 'NE'], |
| 45 | + E: ['NE', 'SE'], |
| 46 | + S: ['SW', 'SE'], |
| 47 | + W: ['NW', 'SW'], |
| 48 | + D1: ['NW', 'SE'], |
| 49 | + D2: ['NE', 'SW'], |
| 50 | +}; |
| 51 | +const all = ['N', 'E', 'S', 'W', 'D1', 'D2']; |
| 52 | +// Order of edge loss across the procession — diagonals first, then square |
| 53 | +const removalOrder = ['D2', 'D1', 'S', 'E', 'N', 'W']; |
| 54 | + |
| 55 | +const nodeOf = id => nodes.find(n => n.id === id); |
| 56 | + |
| 57 | +function edgesAt(stage) { |
| 58 | + const removed = new Set(removalOrder.slice(0, stage)); |
| 59 | + return all.filter(e => !removed.has(e)); |
| 60 | +} |
| 61 | + |
| 62 | +function constellation(cx, cy, stage, rot) { |
| 63 | + // Identity is preserved: nodes always at full ink. Only edges change. |
| 64 | + let g = `\n <g transform="translate(${cx} ${cy}) rotate(${rot})">`; |
| 65 | + for (const e of edgesAt(stage)) { |
| 66 | + const [a, b] = edges[e]; |
| 67 | + const A = nodeOf(a), B = nodeOf(b); |
| 68 | + const isDiag = e === 'D1' || e === 'D2'; |
| 69 | + const sw = isDiag ? 0.75 : 1.05; |
| 70 | + const opc = isDiag ? 0.78 : 0.96; |
| 71 | + g += `\n <line x1="${A.x}" y1="${A.y}" x2="${B.x}" y2="${B.y}" stroke="${C.ink}" stroke-width="${sw}" stroke-linecap="round" opacity="${opc}"/>`; |
| 72 | + } |
| 73 | + for (const n of nodes) { |
| 74 | + g += `\n <circle cx="${n.x}" cy="${n.y}" r="2.85" fill="${C.ink}"/>`; |
| 75 | + } |
| 76 | + g += `\n </g>`; |
| 77 | + return g; |
| 78 | +} |
| 79 | + |
| 80 | +// ---- Fiber: vertical line descending below the axis, with residue marks ----- |
| 81 | +function fiber(cx, stage) { |
| 82 | + const top = baseY + 8; |
| 83 | + const bot = 412; |
| 84 | + const slots = 7; |
| 85 | + const ys = Array.from({ length: slots }, |
| 86 | + (_, i) => top + (i + 0.5) * (bot - top) / slots); |
| 87 | + |
| 88 | + let g = `\n <g>`; |
| 89 | + g += `\n <line x1="${cx}" y1="${top}" x2="${cx}" y2="${bot}" stroke="${C.base}" stroke-width="0.5"/>`; |
| 90 | + const marks = Math.min(stage, slots); |
| 91 | + for (let i = 0; i < marks; i++) { |
| 92 | + const y = ys[i]; |
| 93 | + // newest mark (topmost) slightly larger to read as "most recent loss" |
| 94 | + const r = i === marks - 1 ? 2.0 : 1.55; |
| 95 | + g += `\n <circle cx="${cx}" cy="${y}" r="${r}" fill="${C.amber}"/>`; |
| 96 | + } |
| 97 | + g += `\n </g>`; |
| 98 | + return g; |
| 99 | +} |
| 100 | + |
| 101 | +// ---- Stage positions: 4 left of the title block, 4 right -------------------- |
| 102 | +const stages = [ |
| 103 | + { stage: 0, x: 160 }, |
| 104 | + { stage: 1, x: 330 }, |
| 105 | + { stage: 2, x: 500 }, |
| 106 | + { stage: 3, x: 670 }, |
| 107 | + // title gap roughly 720..1200 |
| 108 | + { stage: 4, x: 1250 }, |
| 109 | + { stage: 5, x: 1420 }, |
| 110 | + { stage: 6, x: 1590 }, |
| 111 | + { stage: 7, x: 1760 }, |
| 112 | +]; |
| 113 | + |
| 114 | +// ---- Compose ---------------------------------------------------------------- |
| 115 | +let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`; |
| 116 | + |
| 117 | +// Ground |
| 118 | +svg += `\n <rect width="${W}" height="${H}" fill="${C.bg}"/>`; |
| 119 | + |
| 120 | +// Corner registration marks — printer's-plate / specimen-sheet aesthetic |
| 121 | +{ |
| 122 | + const inset = 36, arm = 14, sw = 0.7; |
| 123 | + const corners = [ |
| 124 | + { x: inset, y: inset, dx: 1, dy: 1 }, |
| 125 | + { x: W - inset, y: inset, dx: -1, dy: 1 }, |
| 126 | + { x: inset, y: H - inset, dx: 1, dy: -1 }, |
| 127 | + { x: W - inset, y: H - inset, dx: -1, dy: -1 }, |
| 128 | + ]; |
| 129 | + svg += `\n <g stroke="${C.base}" stroke-width="${sw}" fill="none">`; |
| 130 | + for (const c of corners) { |
| 131 | + svg += `\n <line x1="${c.x}" y1="${c.y}" x2="${c.x + arm * c.dx}" y2="${c.y}"/>`; |
| 132 | + svg += `\n <line x1="${c.x}" y1="${c.y}" x2="${c.x}" y2="${c.y + arm * c.dy}"/>`; |
| 133 | + } |
| 134 | + svg += `\n </g>`; |
| 135 | +} |
| 136 | + |
| 137 | +// Base axis (dashed, very quiet) |
| 138 | +svg += `\n <line x1="80" y1="${baseY}" x2="${W - 80}" y2="${baseY}" stroke="${C.base}" stroke-width="0.6" stroke-dasharray="1 7"/>`; |
| 139 | + |
| 140 | +// Tick marks at each stage position (project the fibers onto the axis) |
| 141 | +for (const s of stages) { |
| 142 | + svg += `\n <line x1="${s.x}" y1="${baseY - 3}" x2="${s.x}" y2="${baseY + 3}" stroke="${C.base}" stroke-width="0.6"/>`; |
| 143 | +} |
| 144 | + |
| 145 | +// Procession |
| 146 | +for (const s of stages) { |
| 147 | + const rot = s.stage * 1.5; // gentle accumulating rotation |
| 148 | + svg += fiber(s.x, s.stage); |
| 149 | + svg += constellation(s.x, constY + s.stage * 0.5, s.stage, rot); |
| 150 | +} |
| 151 | + |
| 152 | +// ---- Title block (centre of the page) --------------------------------------- |
| 153 | +// Small tracked-out italic field-label hovering above the title |
| 154 | +svg += `\n <text x="${W/2}" y="198" font-family="IBM Plex Serif" font-style="italic" font-size="14.5" fill="${C.subtitle}" text-anchor="middle" letter-spacing="7.5">Proof-Relevant Information Residues</text>`; |
| 155 | + |
| 156 | +// Main title — IBM Plex Serif, regular, restrained letter-spacing |
| 157 | +svg += `\n <text x="${W/2}" y="265" font-family="IBM Plex Serif" font-size="72" font-weight="400" fill="${C.title}" text-anchor="middle" letter-spacing="2">Echo Types</text>`; |
| 158 | + |
| 159 | +svg += `\n</svg>\n`; |
| 160 | + |
| 161 | +mkdirSync(outDir, { recursive: true }); |
| 162 | +writeFileSync(outPath, svg); |
| 163 | +console.log(`Wrote ${outPath} (${svg.length} bytes)`); |
0 commit comments