/** * Frog avatars: deterministic SVG portraits seeded from a visitor id. * * One string in, one stable image out. Everything is derived from the id; the * only values that reach the markup are numbers and hex colours, so the result * is safe to inject. * * The frog is one drawn head shape, varied only within narrow bounds, with a * small set of discrete parts (eyes, pupils, mouth, jaw, cheeks) on top. Its * colour is the colour word in the visitor's generated name, so "Coral Hamster" * is a coral frog. * * The markup references nothing by id: no clipPath, mask or gradient. The * sessions ledger renders every avatar twice, once in a layout a container * query hides, and Chrome and Firefox resolve a repeated `url(#id)` to whichever * copy comes first, hidden or not; when that copy was hidden the clip silently * dropped. Every shape here is built to sit inside the head on its own. */ type Rng = () => number; type Point = readonly [number, number]; type Cubic = readonly [Point, Point, Point, Point]; /** * One skin per word in unique-names-generator's `colors` dictionary, in the * dictionary's order. [OKLCH lightness, chroma, hue], tuned to read as the * word while staying clear of both app grounds (dark L .18, light L .97). * Words that name skin tones (peach, beige, tan, moccasin) lean towards khaki, * so those frogs read as frogs rather than faces. */ const SKINS: readonly (readonly [string, number, number, number])[] = [ ["amaranth", 0.63, 0.19, 8], ["amber", 0.8, 0.16, 75], ["amethyst", 0.62, 0.15, 305], ["apricot", 0.78, 0.13, 62], ["aqua", 0.8, 0.12, 200], ["aquamarine", 0.83, 0.12, 172], ["azure", 0.64, 0.17, 252], ["beige", 0.84, 0.06, 100], ["black", 0.42, 0.02, 250], ["blue", 0.58, 0.19, 262], ["blush", 0.7, 0.13, 2], ["bronze", 0.64, 0.12, 72], ["brown", 0.54, 0.1, 52], ["chocolate", 0.5, 0.09, 48], ["coffee", 0.52, 0.06, 70], ["copper", 0.63, 0.13, 50], ["coral", 0.72, 0.15, 36], ["crimson", 0.57, 0.2, 18], ["cyan", 0.74, 0.13, 220], ["emerald", 0.72, 0.16, 155], ["fuchsia", 0.65, 0.24, 330], ["gold", 0.84, 0.15, 92], ["gray", 0.64, 0.01, 250], ["green", 0.72, 0.19, 142], ["harlequin", 0.8, 0.22, 136], ["indigo", 0.5, 0.16, 280], ["ivory", 0.93, 0.045, 108], ["jade", 0.66, 0.13, 162], ["lavender", 0.78, 0.09, 295], ["lime", 0.86, 0.19, 125], ["magenta", 0.62, 0.24, 345], ["maroon", 0.48, 0.13, 18], ["moccasin", 0.87, 0.08, 90], ["olive", 0.6, 0.11, 112], ["orange", 0.76, 0.17, 62], ["peach", 0.79, 0.12, 46], ["pink", 0.8, 0.1, 355], ["plum", 0.52, 0.12, 330], ["purple", 0.56, 0.19, 302], ["red", 0.62, 0.22, 27], ["rose", 0.67, 0.19, 8], ["salmon", 0.74, 0.13, 28], ["sapphire", 0.54, 0.16, 262], ["scarlet", 0.64, 0.23, 33], ["silver", 0.8, 0.01, 250], ["tan", 0.75, 0.07, 92], ["teal", 0.6, 0.1, 192], ["tomato", 0.69, 0.19, 34], ["turquoise", 0.8, 0.13, 185], ["violet", 0.62, 0.19, 300], ["white", 0.95, 0.008, 100], ["yellow", 0.9, 0.17, 102], ]; export const FROG_COLOR_WORDS: readonly string[] = SKINS.map(([word]) => word); /** * The colour word `generateName` puts first, without pulling in the name * generator. With a string seed, unique-names-generator 4.x picks the first * word from the sum of the seed's char codes plus one, run through its own * mulberry32 variant; this mirrors that. A test holds it to the library. */ export function frogColorWord(id: string): string { let sum = 1; for (let i = 0; i < id.length; i++) sum += id.charCodeAt(i); let a = (1831565813 + (sum | 0)) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; const x = ((t ^ (t >>> 14)) >>> 0) / 4294967296; return SKINS[Math.floor(x * SKINS.length)][0]; } function hashCode(str: string): number { let h = 0; for (let i = 0; i < str.length; i++) { h = ((h << 5) - h + str.charCodeAt(i)) | 0; } return Math.abs(h); } /** mulberry32 — avalanches properly on sequential seeds, unlike xorshift. */ function makeRng(seed: number): Rng { let a = seed >>> 0 || 2463534242; return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function pick(r: Rng, options: readonly T[]): T { return options[Math.min(options.length - 1, Math.floor(r() * options.length))]; } function weighted(r: Rng, pairs: readonly (readonly [T, number])[]): T { let total = 0; for (const [, weight] of pairs) total += weight; let x = r() * total; for (const [value, weight] of pairs) { x -= weight; if (x <= 0) return value; } return pairs[pairs.length - 1][0]; } function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : v > hi ? hi : v; } /** Two decimals is plenty at avatar sizes and keeps the markup short. */ function r2(v: number): number { return Math.round(v * 100) / 100; } const pt = (p: Point) => `${r2(p[0])} ${r2(p[1])}`; const mirror = (p: Point): Point => [100 - p[0], p[1]]; function oklchToLinearRgb(l: number, c: number, h: number): [number, number, number] { const a = c * Math.cos((h * Math.PI) / 180); const b = c * Math.sin((h * Math.PI) / 180); const lc = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3; const mc = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3; const sc = (l - 0.0894841775 * a - 1.291485548 * b) ** 3; return [ 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc, -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc, -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc, ]; } const inGamut = (rgb: readonly number[]) => rgb.every(v => v >= -0.0005 && v <= 1.0005); /** OKLCH to sRGB hex, giving up chroma (never lightness or hue) to stay in gamut. */ function oklch(l: number, c: number, h: number): string { l = clamp(l, 0, 1); let rgb = oklchToLinearRgb(l, c, h); if (!inGamut(rgb)) { let lo = 0; let hi = c; for (let i = 0; i < 24; i++) { const mid = (lo + hi) / 2; if (inGamut(oklchToLinearRgb(l, mid, h))) lo = mid; else hi = mid; } rgb = oklchToLinearRgb(l, lo, h); } const encode = (v: number) => { v = clamp(v, 0, 1); return v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055; }; return ( "#" + rgb .map(v => Math.round(encode(v) * 255) .toString(16) .padStart(2, "0") ) .join("") ); } interface Geometry { domeDx: number; domeCy: number; domeR: number; hw: number; hy: number; hh: number; saddleY: number; } /** Three builds of the one head; each frog jitters its build only slightly. */ const BUILDS = { round: { domeDx: 23, domeCy: 33, domeR: 16, hw: 41, hy: 61, hh: 26 }, wide: { domeDx: 25, domeCy: 35, domeR: 15, hw: 44, hy: 62, hh: 23 }, bug: { domeDx: 24, domeCy: 32, domeR: 17.5, hw: 41, hy: 62, hh: 25 }, } as const; type Build = keyof typeof BUILDS; type Eyes = "open" | "bead"; type Pupil = "s" | "m" | "l"; type Mouth = "smile" | "grin" | "small" | "flat"; interface FrogFeatures { word: string; skin: readonly [number, number, number]; g: Geometry; eyes: Eyes; gaze: Point; pupil: Pupil; mouth: Mouth; jaw: boolean; cheeks: boolean; } function frogFeatures(id: string): FrogFeatures { const r = makeRng(hashCode(id)); const build = BUILDS[ weighted(r, [ ["round", 45], ["wide", 30], ["bug", 25], ]) ]; const jitter = () => (r() - 0.5) * 2; const g: Geometry = { domeDx: build.domeDx + jitter() * 0.7, domeCy: build.domeCy + jitter() * 0.7, domeR: build.domeR + jitter() * 0.5, hw: build.hw + jitter() * 0.8, hy: build.hy, hh: build.hh + jitter() * 0.8, saddleY: 0, }; g.saddleY = g.domeCy + g.domeR * 0.16; const word = frogColorWord(id); const [, l, c, h] = SKINS.find(([w]) => w === word) ?? SKINS[0]; return { word, skin: [l, c, h], g, eyes: weighted(r, [ ["open", 76], ["bead", 24], ]), gaze: pick(r, [ [0, 0], [0, 0], [-1, 0], [1, 0], [0, -1], [-1, -1], [1, -1], ]), pupil: weighted(r, [ ["m", 45], ["l", 30], ["s", 25], ]), mouth: weighted(r, [ ["smile", 45], ["grin", 20], ["small", 20], ["flat", 15], ]), jaw: r() < 0.6, cheeks: r() < 0.3, }; } const rad = (deg: number) => (deg * Math.PI) / 180; const onCircle = (cx: number, cy: number, r: number, deg: number): Point => [ cx + r * Math.cos(rad(deg)), cy + r * Math.sin(rad(deg)), ]; const tangentAt = (deg: number): Point => [-Math.sin(rad(deg)), Math.cos(rad(deg))]; /** One cubic for a circular arc of at most 90°; angles run clockwise on screen. */ function arc(cx: number, cy: number, r: number, from: number, to: number): Cubic { const k = (4 / 3) * Math.tan(rad(to - from) / 4) * r; const p0 = onCircle(cx, cy, r, from); const p3 = onCircle(cx, cy, r, to); const t0 = tangentAt(from); const t3 = tangentAt(to); return [p0, [p0[0] + k * t0[0], p0[1] + k * t0[1]], [p3[0] - k * t3[0], p3[1] - k * t3[1]], p3]; } /** * The head as cubic segments, right half first, then that half mirrored. * The forehead leaves the centre level and meets each eye dome on the dome's * own tangent, so the dip between the eyes is a valley, not a notch; the dome * then follows its circle over the top and drops into the cheek and jaw. */ function headOutline(g: Geometry): { right: Cubic[]; left: Cubic[]; domeX: number } { const cx = 50 + g.domeDx; const cy = g.domeCy; const r = g.domeR; const join = 196; const j = onCircle(cx, cy, r, join); const tj = tangentAt(join); const lift = g.saddleY - j[1]; const outer: Point = [cx + r, cy]; const wide: Point = [50 + g.hw, g.hy]; const chin: Point = [50, g.hy + g.hh]; const right: Cubic[] = [ [ [50, g.saddleY], [50 + (j[0] - 50) * 0.55, g.saddleY], [j[0] - tj[0] * lift * 0.62, j[1] - tj[1] * lift * 0.62], j, ], arc(cx, cy, r, join, 270), arc(cx, cy, r, 270, 360), [outer, [outer[0], cy + r * 0.5], [wide[0], g.hy - (g.hy - cy) * 0.42], wide], [wide, [wide[0], g.hy + g.hh * 0.56], [50 + g.hw * 0.58, chin[1]], chin], ]; const left = right.map(([a, b, c, d]): Cubic => [mirror(d), mirror(c), mirror(b), mirror(a)]).reverse(); return { right, left, domeX: cx }; } function pathOf(segs: readonly Cubic[]): string { let d = `M${pt(segs[0][0])}`; for (const [, c1, c2, p] of segs) d += `C${pt(c1)} ${pt(c2)} ${pt(p)}`; return d + "Z"; } /** de Casteljau split of a cubic at t; returns the second half. */ function cubicTail([p0, p1, p2, p3]: Cubic, t: number): Cubic { const lerp = (a: Point, b: Point): Point => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; const a = lerp(p0, p1); const b = lerp(p1, p2); const c = lerp(p2, p3); const d = lerp(a, b); const e = lerp(b, c); return [lerp(d, e), e, c, p3]; } /** * The lighter lower jaw: the outline's own bottom curve from jaw to jaw, * closed by a soft lip line, so it never needs clipping to the head. */ function jawPath(right: readonly Cubic[]): string { const low = cubicTail(right[right.length - 1], 0.36); const lowLeft = [...low].map(mirror).reverse(); return ( `M${pt(low[0])}C${pt(low[1])} ${pt(low[2])} ${pt(low[3])}` + `C${pt(lowLeft[1])} ${pt(lowLeft[2])} ${pt(lowLeft[3])}Q50 ${r2(low[0][1] + 8)} ${pt(low[0])}Z` ); } interface FrogPalette { skin: string; jaw: string; line: string; pupil: string; sclera: string; cheek: string; } /** One palette per colour word, so a list of avatars does the colour maths at most 52 times. */ const palettes = new Map(); function frogPalette(word: string, [l, c, h]: readonly [number, number, number]): FrogPalette { let palette = palettes.get(word); if (!palette) { palette = { skin: oklch(l, c, h), // Paler and warmer than the skin, except on frogs already near white. jaw: l > 0.86 ? oklch(l - 0.07, c * 0.8, h - 10) : oklch(Math.min(l + 0.1, 0.95), c * 0.55, h - 14), line: oklch(l < 0.45 ? 0.15 : 0.27, Math.min(c * 0.55, 0.08), h), pupil: oklch(0.2, 0.02, h), sclera: oklch(0.975, 0.012, 95), cheek: oklch(clamp(l + 0.02, 0.62, 0.82), 0.13, 12), }; palettes.set(word, palette); } return palette; } const PUPIL_SCALE: Record = { s: 0.4, m: 0.5, l: 0.6 }; const MOUTH_WIDTH: Record = { smile: 14, grin: 19, small: 8.5, flat: 11 }; const MOUTH_DEPTH: Record = { smile: 6.5, grin: 8.5, small: 4.6, flat: 1.8 }; function drawFrog(f: FrogFeatures, size: number): string { const c = frogPalette(f.word, f.skin); const { right, left, domeX } = headOutline(f.g); const d = pathOf([...right, ...left]); // Below this the highlights turn to noise and thin strokes to blur. const small = size < 28; // A rim under the fill. Translucent black vanishes on the dark theme and // edges a pale frog on the light one; dark frogs get the inverse. let o = ``; o += ``; if (f.jaw) o += ``; if (f.cheeks) { for (const x of [50 - f.g.hw * 0.64, 50 + f.g.hw * 0.64]) { o += ``; } } // Eyes fill their domes; a small eye in a big dome reads as an ear. const cy = f.g.domeCy; const er = f.g.domeR * 0.74; const pr = er * PUPIL_SCALE[f.pupil]; const gx = f.gaze[0] * er * 0.28; const gy = f.gaze[1] * er * 0.22; for (const x of [100 - domeX, domeX]) { // Bead eyes vanish into dark skin, so those frogs keep the whites. if (f.eyes === "bead" && f.skin[0] >= 0.6) { const br = er * 0.82; const bx = x + gx * 0.4; const by = cy + gy * 0.4; o += ``; o += ``; } else { o += ``; o += ``; if (!small) { o += ``; } } } const my = f.g.hy + (f.jaw ? 2 : 4); const w = MOUTH_WIDTH[f.mouth]; o += ``; return o; } /** * The contents of a `0 0 100 100` viewBox, ready for dangerouslySetInnerHTML. * `size` is the rendered size in px; it only decides how much detail to draw. */ export function frogAvatarMarkup(id: string, size = 20): string { return drawFrog(frogFeatures(id), size); } /** * A standalone `` string, for the map markers and other places that build * DOM by hand instead of rendering React. */ export function frogAvatarSVG(id: string, size: number): string { return ( `${frogAvatarMarkup(id, size)}` ); }