/** * README banner generator: a mirrored pixel creature derived from the extension * name's initials beside the name set in a 3x5 pixel font, on a nugu-coloured * tile. One SVG renders both GitHub themes through `prefers-color-scheme`. * * Geometry depends on name, seed and mutation only. `version` changes colour * only, so a bumped version keeps the creature recognisable. */ interface Palette { content: Record; ui: Record; } export interface NuguPalette { dark: Palette; light: Palette; } export interface BannerOptions { seed: number; /** Pixel flips applied to the letter body, 0..6. */ mutation: number; /** Colour drift step; geometry never changes with it. */ version: number; /** How strongly a version step moves colour, 0..2. */ drift: number; /** Size of one grid cell in SVG units. */ cell: number; } export const DEFAULT_BANNER: BannerOptions = { seed: 193_581, mutation: 4, version: 0, drift: 0.5, cell: 10, }; // ---- deterministic randomness --------------------------------------------- function hash(text: string): number { let h = 2_166_136_261; for (const ch of text) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16_777_619); } return h >>> 0; } function rng(seed: number): () => number { let s = seed >>> 0 || 1; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4_294_967_296; }; } const pick = (r: () => number, items: readonly T[]): T => items[Math.floor(r() * items.length)] as T; const irange = (r: () => number, a: number, b: number): number => Math.floor(a + r() * (b - a + 1)); /** Smooth 1-D value noise in `v`, keyed by identity and channel, in -1..1. */ function noise(identity: string, channel: string, v: number): number { const i = Math.floor(v); const t = v - i; const s = t * t * (3 - 2 * t); const at = (k: number) => rng(hash(`${identity}#${channel}#${k}`))() * 2 - 1; return at(i) * (1 - s) + at(i + 1) * s; } // ---- colour ---------------------------------------------------------------- function hexToHsl(hex: string): [number, number, number] { const r = Number.parseInt(hex.slice(1, 3), 16) / 255; const g = Number.parseInt(hex.slice(3, 5), 16) / 255; const b = Number.parseInt(hex.slice(5, 7), 16) / 255; const mx = Math.max(r, g, b); const mn = Math.min(r, g, b); const l = (mx + mn) / 2; if (mx === mn) return [0, 0, l * 100]; const d = mx - mn; const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); let h: number; if (mx === r) h = (g - b) / d + (g < b ? 6 : 0); else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; return [h * 60, s * 100, l * 100]; } function hsl(h: number, s: number, l: number): string { const clamp = (v: number) => Math.max(0, Math.min(100, Math.round(v))); return `hsl(${Math.round(((h % 360) + 360) % 360) % 360} ${clamp(s)}% ${clamp(l)}%)`; } interface Inks { fg: string; accent: string; focus: string; local: string; global: string; minor: string; tile: string; } function inks(p: Palette): Inks { const c = p.content; const need = (key: string): string => { const value = c[key]; if (!value) throw new Error(`nugu palette lacks content.${key}`); return value; }; const tile = p.ui["backdrop"]; if (!tile) throw new Error("nugu palette lacks ui.backdrop"); return { fg: need("normal"), accent: need("accent"), focus: need("focus"), local: need("important_local"), global: need("important_global"), minor: need("minor"), tile, }; } // ---- creature --------------------------------------------------------------- // Left half (3 columns incl. centre) of a symmetric 5x5 letter form. const HALF: Record = { a: ["..x", ".x.", "x..", "xxx", "x.."], b: ["xxx", "x.x", "xxx", "x.x", "xxx"], c: ["xxx", "x..", "x..", "x..", "xxx"], d: [".xx", "x.x", "x.x", "x.x", ".xx"], e: ["xxx", "x..", "xx.", "x..", "xxx"], f: ["xxx", "x..", "xx.", "x..", "x.."], g: ["xxx", "x..", "x.x", "x.x", "xxx"], h: ["x.x", "x.x", "xxx", "x.x", "x.x"], i: ["xxx", ".x.", ".x.", ".x.", "xxx"], j: ["xxx", "..x", "..x", "x.x", "xxx"], k: ["x.x", "xx.", "x..", "xx.", "x.x"], l: ["x..", "x..", "x..", "x..", "xxx"], m: ["x..", "xx.", "x.x", "x..", "x.."], n: ["x..", "xx.", "x.x", "x..", "x.."], o: [".xx", "x..", "x..", "x..", ".xx"], p: ["xxx", "x.x", "xxx", "x..", "x.."], q: [".xx", "x..", "x..", "x.x", ".xx"], r: ["xxx", "x.x", "xxx", "xx.", "x.x"], s: ["xxx", "x..", "xxx", "..x", "xxx"], t: ["xxx", ".x.", ".x.", ".x.", ".x."], u: ["x..", "x..", "x..", "x..", "xxx"], v: ["x..", "x..", "x..", ".x.", "..x"], w: ["x..", "x..", "x.x", "xx.", "x.."], x: ["x.x", ".x.", "..x", ".x.", "x.x"], y: ["x.x", ".x.", "..x", "..x", "..x"], z: ["xxx", "..x", ".x.", "x..", "xxx"], }; type Grid = boolean[][]; function bitmap(ch: string): Grid { return (HALF[ch] ?? HALF["o"] ?? []).map((row) => [...row].map((c) => c === "x"), ); } /** First letters of the first and last hyphen part, or one letter. */ export function initials(name: string): string[] { const parts = name.toLowerCase().split("-"); const first = parts[0]?.[0] ?? "o"; const last = parts.at(-1)?.[0]; return parts.length > 1 && last ? [first, last] : [first]; } function body(letters: string[], r: () => number, mutation: number): Grid { const [a, b] = letters; let grid: Grid; if (b === undefined) grid = bitmap(a ?? "o"); else { const top = bitmap(a ?? "o"); const bottom = bitmap(b); grid = [top[0], top[1], top[2], bottom[3], bottom[4]] as Grid; } grid = grid.map((row) => row.slice()); for (let i = 0; i < mutation; i++) { const y = irange(r, 0, 4); const x = irange(r, 0, 2); const row = grid[y]; if (row && !(y === 0 && x === 2)) row[x] = !row[x]; } const flair = pick(r, [null, null, "feet", "antenna"] as const); if (flair === "feet") grid.push([true, false, false]); if (flair === "antenna") grid.unshift([false, true, false]); return grid; } interface Cell { x: number; y: number; fill: string; } interface Creature { w: number; h: number; cells: Cell[]; eyes: Cell[]; ink: string; } function creature(name: string, opts: BannerOptions, c: Inks): Creature { const identity = `${name}|${opts.seed}`; const r = rng(hash(identity)); const grid = body(initials(name), r, opts.mutation); const rows = grid.length; const baseInk = pick(r, [c.fg, c.local, c.focus, c.global]); const [bh, bs, bl] = hexToHsl(baseInk); const ink = hsl( bh + noise(identity, "h", opts.version) * 25 * opts.drift, bs, bl + noise(identity, "l", opts.version) * 12 * opts.drift, ); const eyeInks = [c.accent, c.focus, c.local, c.global]; const eye = opts.version === 0 ? c.accent : (eyeInks[ Math.abs( Math.round(noise(identity, "eye", opts.version) * 2 * opts.drift), ) % eyeInks.length ] ?? c.accent); const [sh, ss, sl] = hexToHsl(c.accent); const stripe = hsl(sh + 40, ss, sl); const stripeRow = noise(identity, "stripe", opts.version) * opts.drift > 0.5 ? irange(rng(hash(`${identity}s`)), 1, rows - 2) : -1; let eyeRow = grid.findIndex( (row, y) => y < rows - 1 && ((row[0] ?? false) || (row[1] ?? false)), ); if (eyeRow < 0) eyeRow = 1; const eyeCol = grid[eyeRow]?.[1] ? 1 : 0; const cells: Cell[] = []; grid.forEach((row, y) => row.forEach((on, x) => { if (!on) return; for (const px of new Set([x, 4 - x])) cells.push({ x: px, y, fill: y === stripeRow ? stripe : ink }); }), ); const eyes = [eyeCol, 4 - eyeCol].map((px) => ({ x: px, y: eyeRow, fill: eye, })); return { w: 5, h: rows, cells, eyes, ink }; } // ---- pixel font ------------------------------------------------------------- const FONT: Record = { a: ["...", ".x.", "x.x", "xxx", "x.x"], b: ["x..", "x..", "xx.", "x.x", "xx."], c: ["...", ".xx", "x..", "x..", ".xx"], d: ["..x", "..x", ".xx", "x.x", ".xx"], e: ["...", ".x.", "x.x", "xx.", ".xx"], f: [".xx", "x..", "xx.", "x..", "x.."], g: [".xx", "x.x", ".xx", "..x", "xx."], h: ["x..", "x..", "xx.", "x.x", "x.x"], i: [".x.", "...", ".x.", ".x.", ".x."], j: ["..x", "...", "..x", "..x", "xx."], k: ["x..", "x.x", "xx.", "x.x", "x.x"], l: [".x.", ".x.", ".x.", ".x.", "..x"], m: ["...", "x.x", "xxx", "x.x", "x.x"], n: ["...", "xx.", "x.x", "x.x", "x.x"], o: ["...", ".x.", "x.x", "x.x", ".x."], p: ["...", "xx.", "x.x", "xx.", "x.."], q: ["...", ".xx", "x.x", ".xx", "..x"], r: ["...", "x.x", "xx.", "x..", "x.."], s: ["...", ".xx", "xx.", "..x", "xx."], t: [".x.", "xxx", ".x.", ".x.", "..x"], u: ["...", "x.x", "x.x", "x.x", ".xx"], v: ["...", "x.x", "x.x", "x.x", ".x."], w: ["...", "x.x", "x.x", "xxx", "x.x"], x: ["...", "x.x", ".x.", ".x.", "x.x"], y: ["x.x", "x.x", ".xx", "..x", "xx."], z: ["...", "xxx", "..x", "x..", "xxx"], "-": ["...", "...", "xxx", "...", "..."], "0": ["xxx", "x.x", "x.x", "x.x", "xxx"], "1": [".x.", "xx.", ".x.", ".x.", "xxx"], "2": ["xx.", "..x", ".x.", "x..", "xxx"], "3": ["xxx", "..x", ".xx", "..x", "xxx"], "4": ["x.x", "x.x", "xxx", "..x", "..x"], "5": ["xxx", "x..", "xxx", "..x", "xxx"], "6": ["xxx", "x..", "xxx", "x.x", "xxx"], "7": ["xxx", "..x", ".x.", ".x.", ".x."], "8": ["xxx", "x.x", "xxx", "x.x", "xxx"], "9": ["xxx", "x.x", "xxx", "..x", "xxx"], }; /** Characters the wordmark can set; anything else is a generation error. */ export const FONT_CHARS: ReadonlySet = new Set(Object.keys(FONT)); interface Word { cells: Array<{ x: number; y: number; ch: string }>; w: number; h: number; } function word(text: string): Word { const cells: Word["cells"] = []; let cx = 0; for (const ch of text) { const glyph = FONT[ch]; if (!glyph) throw new Error(`banner font has no glyph for "${ch}"`); glyph.forEach((row, y) => [...row].forEach((c, x) => { if (c === "x") cells.push({ x: cx + x, y, ch }); }), ); cx += 4; } return { cells, w: cx - 1, h: 5 }; } // ---- banner ----------------------------------------------------------------- const PAD = 2; const GAP = 3; interface Layer { tile: string; cells: Cell[]; eyes: Cell[]; text: Cell[]; } function layer(name: string, opts: BannerOptions, p: Palette) { const c = inks(p); const cr = creature(name, opts, c); const wd = word(name.toLowerCase()); const W = PAD * 2 + cr.w + GAP + wd.w; const H = PAD * 2 + Math.max(cr.h, wd.h); const cx0 = PAD; const cy0 = PAD + Math.floor((H - 2 * PAD - cr.h) / 2); const wx0 = PAD + cr.w + GAP; const wy0 = PAD + Math.floor((H - 2 * PAD - wd.h) / 2); const text = wd.cells.map((q) => ({ x: wx0 + q.x, y: wy0 + q.y, fill: q.ch === "-" ? c.accent : cr.ink, })); const out: Layer = { tile: c.tile, cells: cr.cells.map((q) => ({ ...q, x: cx0 + q.x, y: cy0 + q.y })), eyes: cr.eyes.map((q) => ({ ...q, x: cx0 + q.x, y: cy0 + q.y })), text, }; return { W, H, layer: out }; } function rects(cells: Cell[], cell: number, inset = 0): string { return cells .map( (q) => ``, ) .join(""); } /** * Render one SVG with a dark layer shown by default and a light layer shown * under `prefers-color-scheme: light`. Both layers share geometry. */ export function renderBanner( name: string, palette: NuguPalette, options: Partial = {}, ): string { const opts = { ...DEFAULT_BANNER, ...options }; const dark = layer(name, opts, palette.dark); const light = layer(name, opts, palette.light); const { W, H } = dark; const cell = opts.cell; const group = (id: string, l: Layer) => `${rects(l.cells, cell)}${rects(l.eyes, cell, cell / 4)}${rects(l.text, cell)}`; return [ ``, ``, group("dark", dark.layer), group("light", light.layer), ``, "", ].join("\n"); }