/** * Pixel-Identicon avatar generator — pure function, no storage. * * Derives a deterministic 5×5 left-mirrored pixel grid plus an HSL * color from a `display_hash`, returns a self-contained SVG string. * Same hash → same avatar, every time, on every device. * * Hash byte layout (we use the first 18 bytes / 36 hex chars): * bytes[0] → hue (0–360°) * bytes[1] → saturation (50–90%) * bytes[2] → lightness (35–60%) * bytes[3..17] → 15 cell on/off bits (3 cols × 5 rows, mirrored) * * The 5×5 grid is generated by computing the left 3 columns from the * hash bits, then mirroring columns 0+1 onto 4+3 so the silhouette is * always symmetric — the trick that makes Identicons feel like real * faces/creatures even though they're random pixels. */ interface AvatarRendering { cells: boolean[][]; // 5 rows × 5 cols fg: string; bg: string; } const GRID = 5; function hexToBytes(hex: string): number[] { const cleaned = hex.replace(/[^0-9a-f]/gi, ''); const bytes: number[] = []; for (let i = 0; i < cleaned.length; i += 2) { bytes.push(parseInt(cleaned.slice(i, i + 2), 16) || 0); } return bytes; } function rendering(displayHash: string): AvatarRendering { const bytes = hexToBytes(displayHash); if (bytes.length < 18) { // Pad short hashes — shouldn't happen with SHA256 (32 bytes), but // defensive so the function never throws on a malformed input. while (bytes.length < 18) bytes.push(0); } const hue = (bytes[0] / 255) * 360; const sat = 50 + (bytes[1] / 255) * 40; const light = 35 + (bytes[2] / 255) * 25; const fg = `hsl(${hue.toFixed(0)}, ${sat.toFixed(0)}%, ${light.toFixed(0)}%)`; const bg = `hsl(${hue.toFixed(0)}, ${sat.toFixed(0)}%, 92%)`; const cells: boolean[][] = Array.from({ length: GRID }, () => Array.from({ length: GRID }, () => false) ); let bitIdx = 0; for (let row = 0; row < GRID; row++) { for (let col = 0; col < 3; col++) { const byteIdx = 3 + Math.floor(bitIdx / 8); const bit = (bytes[byteIdx] >> (bitIdx % 8)) & 1; const on = bit === 1; cells[row][col] = on; // Mirror cols 0+1 onto 4+3. Col 2 is the spine. if (col < 2) cells[row][GRID - 1 - col] = on; bitIdx++; } } return { cells, fg, bg }; } /** * Render the avatar as an inline SVG string. Default 64×64 viewport. * Width/height attributes are intentionally omitted so callers can * size via CSS (`.avatar { width: 32px; height: 32px }` etc.). */ export function generateAvatarSvg(displayHash: string): string { const { cells, fg, bg } = rendering(displayHash); const rectSize = 100 / GRID; let pixels = ''; for (let row = 0; row < GRID; row++) { for (let col = 0; col < GRID; col++) { if (!cells[row][col]) continue; const x = (col * rectSize).toFixed(2); const y = (row * rectSize).toFixed(2); pixels += ``; } } return ( `` + `` + `${pixels}` + `` ); } /** Exported for tests. */ export const __TEST__ = { rendering, hexToBytes, GRID };