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");
}