clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / avatar.ts
avatar.ts 9.5K
1// A person's avatar: a small square of bauhaus tiles drawn from their name,2// at build time, as SVG. No uploads and no network; the same name gives the3// same art on every page and every build.4//5// The composition is cat's bauhaus pattern generator6// (https://src.rest/cat/bauhaus-pattern-generator, its index.html), cut down7// to a 3 by 3 grid: its seeded random numbers (hash32 and the sfc32-style8// makeRng, keyed by the seed and a path), its grammars (weave, cadence,9// blocks) that give each tile a place in a repeating sequence of motifs and10// colours, its rare mutations, and its nesting, which splits a tile into11// four (one level here, so a tile is never smaller than a sixth of the12// square). The motifs are the generator's: a quarter circle, a half circle,13// a bar, a triangle, a ring, an inset square, a filled tile, an empty one.14// Its colour themes give way to five tokens (--avatar-ground, -ink, -red,15// -blue, -yellow in style.css), with the order of the three accents seeded,16// so the art reads in light and dark.17//18// Changing anything here changes everyone's avatar: don't, unless that is19// the point.20//21// node lib/avatar.ts <name> [size] the SVG22import fs from 'node:fs';23import { fileURLToPath } from 'node:url';2425const VIEW = 72;26const GRID = 3;27const TILE = VIEW / GRID;2829const MOTIFS = ['quarter', 'half', 'bar', 'diagonal', 'ring', 'inset', 'field', 'void'] as const;30type Motif = (typeof MOTIFS)[number];31const GRAMMARS = ['weave', 'cadence', 'blocks'] as const;3233// The generator's, unchanged.34function hash32(text: string): number {35 let hash = 2166136261;36 for (let i = 0; i < text.length; i += 1) {37 hash ^= text.charCodeAt(i);38 hash = Math.imul(hash, 16777619);39 }40 hash += hash << 13;41 hash ^= hash >>> 7;42 hash += hash << 3;43 hash ^= hash >>> 17;44 hash += hash << 5;45 return hash >>> 0;46}4748interface Rng {49 float(): number;50 int(min: number, max: number): number;51 bool(chance?: number): boolean;52 pick<T>(items: readonly T[]): T;53 weighted<T>(entries: [T, number][]): T;54}5556function makeRng(seed: string, path: string): Rng {57 let a = hash32(`${seed}|${path}|a`);58 let b = hash32(`${seed}|${path}|b`);59 let c = hash32(`${seed}|${path}|c`);60 let d = hash32(`${seed}|${path}|d`);61 const float = (): number => {62 a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;63 const result = (a + b + d) >>> 0;64 d = (d + 1) >>> 0;65 a = b ^ (b >>> 9);66 b = (c + (c << 3)) >>> 0;67 c = ((c << 21) | (c >>> 11)) >>> 0;68 c = (c + result) >>> 0;69 return result / 4294967296;70 };71 return {72 float,73 int: (min, max) => Math.floor(float() * (max - min + 1)) + min,74 bool: (chance = 0.5) => float() < chance,75 pick: (items) => items[Math.floor(float() * items.length)],76 weighted(entries) {77 const total = entries.reduce((sum, e) => sum + e[1], 0);78 let cursor = float() * total;79 for (const [value, weight] of entries) {80 cursor -= weight;81 if (cursor <= 0) return value;82 }83 return entries[entries.length - 1][0];84 },85 };86}8788interface System {89 // Class names of the five colours: ground, ink, then the accents.90 palette: string[];91 grammar: (typeof GRAMMARS)[number];92 period: number;93 blockSize: number;94 mutationRate: number;95 motifSequence: Motif[];96 colorSequence: number[];97 direction: number;98 nesting: { enabled: boolean; rootRate: number };99}100101function buildSystem(seed: string): System {102 const paletteRng = makeRng(seed, 'palette');103 const structureRng = makeRng(seed, 'structure');104 const rhythmRng = makeRng(seed, 'rhythm');105 const nestingRng = makeRng(seed, 'nesting');106 const accents = ['av-r', 'av-b', 'av-y'];107 for (let i = accents.length - 1; i > 0; i--) {108 const j = paletteRng.int(0, i);109 [accents[i], accents[j]] = [accents[j], accents[i]];110 }111 const grammar = structureRng.pick(GRAMMARS);112 // The generator draws how many tiles span the short side here; the grid113 // is fixed, but the draw stays so what follows is the generator's.114 structureRng.int(6, 11);115 const period = structureRng.int(3, 6);116 const blockSize = structureRng.pick([2, 2, 3]);117 const mutationRate = structureRng.pick([0.035, 0.05, 0.07, 0.09]);118 const motifSequence = Array.from({ length: period }, () => rhythmRng.pick(MOTIFS.slice(0, 7)));119 const colorSequence = Array.from({ length: period + 1 }, () => rhythmRng.weighted([[0, 6], [1, 4], [2, 2], [3, 2], [4, 1.5]]));120 const direction = rhythmRng.pick([1, 1, -1]);121 const enabled = nestingRng.bool(0.55);122 nestingRng.weighted([[1, 3], [2, 1]]);123 const rootRate = nestingRng.pick([0.14, 0.18, 0.22, 0.26]);124 return { palette: ['av-g', 'av-i', ...accents], grammar, period, blockSize, mutationRate, motifSequence, colorSequence, direction, nesting: { enabled, rootRate } };125}126127function cadenceIndex(s: System, col: number, row: number): number {128 if (s.grammar === 'weave') return (row + (col % 2) * 2) % s.period;129 if (s.grammar === 'blocks') return (Math.floor(col / s.blockSize) + 2 * Math.floor(row / s.blockSize)) % s.period;130 return (col + s.direction * row * 2 + s.period * 8) % s.period;131}132133function chooseForeground(s: System, bg: number, cadence: number, rng: Rng): number {134 const preferred = s.colorSequence[(cadence + 1) % s.colorSequence.length];135 if (preferred !== bg && rng.bool(0.72)) return preferred;136 if (bg >= 2 && bg <= 4) return rng.weighted([[0, 3], [1, 5]]);137 return rng.pick([0, 1, 2, 3, 4].filter((i) => i !== bg));138}139140const num = (n: number): string => String(Math.round(n * 100) / 100);141142// A motif in a tile of side z at x, y, turned a quarter `turn` times143// clockwise about its centre: SVG, or '' for none.144function motif(m: Motif, x: number, y: number, z: number, turn: number, cls: string, rng: Rng): string {145 const h = z / 2;146 const t = turn ? ` transform="rotate(${turn * 90} ${num(x + h)} ${num(y + h)})"` : '';147 const rect = (rx: number, ry: number, w: number, hh: number): string =>148 `<rect class="${cls}" x="${num(x + rx)}" y="${num(y + ry)}" width="${num(w)}" height="${num(hh)}"${t}/>`;149 const path = (d: string, rule = ''): string => `<path class="${cls}"${rule} d="${d}"${t}/>`;150 switch (m) {151 case 'quarter':152 return path(`M${num(x)} ${num(y)}H${num(x + z)}A${num(z)} ${num(z)} 0 0 1 ${num(x)} ${num(y + z)}Z`);153 case 'half':154 return path(`M${num(x)} ${num(y + z)}A${num(h)} ${num(h)} 0 0 1 ${num(x + z)} ${num(y + z)}Z`);155 case 'bar': {156 const w = z * rng.pick([0.25, 0.333333, 0.5]);157 return rect((z - w) / 2, 0, w, z);158 }159 case 'diagonal':160 return path(`M${num(x)} ${num(y)}H${num(x + z)}V${num(y + z)}Z`);161 case 'ring': {162 const ring = z * rng.pick([0.14, 0.18, 0.22]);163 const r1 = z * 0.38;164 const r2 = Math.max(0.5, r1 - ring);165 const cx = x + h, cy = y + h;166 const circle = (r: number): string => `M${num(cx - r)} ${num(cy)}a${num(r)} ${num(r)} 0 1 0 ${num(2 * r)} 0a${num(r)} ${num(r)} 0 1 0 ${num(-2 * r)} 0Z`;167 return path(circle(r1) + circle(r2), ' fill-rule="evenodd"');168 }169 case 'inset': {170 const i = z * rng.pick([0.18, 0.24, 0.3]);171 return rect(i, i, z - 2 * i, z - 2 * i);172 }173 case 'field':174 return rect(0, 0, z, z);175 default:176 return '';177 }178}179180function leaf(seed: string, s: System, col: number, row: number, x: number, y: number, z: number, path: string, depth: number, cadence: number): string {181 const rng = makeRng(seed, `${path}/leaf`);182 let m = s.motifSequence[cadence % s.motifSequence.length];183 let turn = (((cadence + (s.direction < 0 ? row : col) + depth) % 4) + 4) % 4;184 if (rng.bool(s.mutationRate)) m = rng.pick(MOTIFS);185 if (rng.bool(s.mutationRate * 1.4)) turn = rng.int(0, 3);186 const bg = s.colorSequence[cadence % s.colorSequence.length];187 const fg = chooseForeground(s, bg, cadence, rng);188 // The whole square is the ground already.189 const back = bg ? `<rect class="${s.palette[bg]}" x="${num(x)}" y="${num(y)}" width="${num(z)}" height="${num(z)}"/>` : '';190 return back + motif(m, x, y, z, turn, s.palette[fg], rng);191}192193function node(seed: string, s: System, col: number, row: number, x: number, y: number, z: number, path: string, depth: number, cadence: number): string {194 const rng = makeRng(seed, `nesting/${path}`);195 if (s.nesting.enabled && depth < 1 && rng.bool(s.nesting.rootRate)) {196 const c = z / 2;197 let out = '';198 for (let r = 0; r < 2; r++) {199 for (let k = 0; k < 2; k++) {200 const child = (cadence + k + r * s.direction + depth + 1 + s.period * 4) % s.period;201 out += node(seed, s, col, row, x + k * c, y + r * c, c, `${path}/branch-${depth}/${k + r * 2}`, depth + 1, child);202 }203 }204 return out;205 }206 return leaf(seed, s, col, row, x, y, z, path, depth, cadence);207}208209// The avatar of a name, `size` pixels square. Decoration beside the name,210// which says who it is.211export function avatar(name: string, size = 36): string {212 const seed = String(name);213 const s = buildSystem(seed);214 let body = `<rect class="av-g" width="${VIEW}" height="${VIEW}"/>`;215 for (let row = 0; row < GRID; row++) {216 for (let col = 0; col < GRID; col++) {217 body += node(seed, s, col, row, col * TILE, row * TILE, TILE, `cell/${col}/${row}`, 0, cadenceIndex(s, col, row));218 }219 }220 return `<svg class="avatar" viewBox="0 0 ${VIEW} ${VIEW}" width="${size}" height="${size}" aria-hidden="true">${body}</svg>`;221}222223if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {224 const [name, size] = process.argv.slice(2);225 if (!name) {226 console.error('usage: node lib/avatar.ts <name> [size]');227 process.exit(2);228 }229 console.log(avatar(name, Number(size) || 72));230}