Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/readme/banner.ts

Raw
/**
 * 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<string, string>;
	ui: Record<string, string>;
}
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 = <T>(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<string, readonly string[]> = {
	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<string, readonly string[]> = {
	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<string> = 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) =>
				`<rect x="${q.x * cell + inset}" y="${q.y * cell + inset}" width="${cell - 2 * inset}" height="${cell - 2 * inset}" fill="${q.fill}"/>`,
		)
		.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<BannerOptions> = {},
): 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) =>
		`<g id="${id}"><rect width="${W * cell}" height="${H * cell}" rx="${cell * 1.5}" fill="${l.tile}"/>${rects(l.cells, cell)}${rects(l.eyes, cell, cell / 4)}${rects(l.text, cell)}</g>`;
	return [
		`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W * cell} ${H * cell}" width="${W * cell}" height="${H * cell}" role="img" aria-label="${name}">`,
		`<style>#light{display:none}@media (prefers-color-scheme: light){#dark{display:none}#light{display:inline}}</style>`,
		group("dark", dark.layer),
		group("light", light.layer),
		`</svg>`,
		"",
	].join("\n");
}