/** * Deterministic lava-lamp avatars, a nod to Cloudflare's wall of entropy lamps. Everything is derived * from the handle, so the same handle renders the same lamp on every client and deployment with * nothing stored. Output must stay stable once shipped: changing it changes everyone's avatar. * * The static lamp is the identity. The animated variant starts on the exact static frame and loops * back to it, so swapping between them never jumps. Animation draws from its own random stream, so * tuning motion can never change what a lamp looks like at rest. */ const VIEW = 64; const COMPLEMENTARY_CHANCE = 0.6; const TOP_CAP_CHANCE = 0.45; const TWO_TONE_CHANCE = 0.35; const FINE_BUBBLES_CHANCE = 0.3; const MAX_CACHED = 500; const CLEARANCE = 8; const MIN_SPEED = 3.5; const MAX_SPEED = 5.5; const MIN_LOOP_SECONDS = 14; const MAX_LOOP_SECONDS = 30; function cyrb53(value: string, seed = 0): [number, number] { let h1 = 0xdeadbeef ^ seed; let h2 = 0x41c6ce57 ^ seed; for (let i = 0; i < value.length; i++) { const code = value.charCodeAt(i); h1 = Math.imul(h1 ^ code, 2654435761); h2 = Math.imul(h2 ^ code, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); return [h1 >>> 0, h2 >>> 0]; } function mulberry32(seed: number): () => number { let state = seed >>> 0; return () => { state = (state + 0x6d2b79f5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function ranged(random: () => number) { return (min: number, max: number) => min + random() * (max - min); } export type LavaBlob = { cx: number; cy: number; rx: number; ry: number; tone: 0 | 1 }; export type LavaMotion = { rise: number; sway: number; seconds: number }; export type LavaLamp = { noiseSeed: number; noiseFrequency: number; wobble: number; wax: { core: string; edge: string }[]; liquid: { top: string; bottom: string }; glow: string; highlight: { x: number; angle: number }; blobs: LavaBlob[]; }; const round = (value: number) => Math.round(value * 100) / 100; const hsl = (hue: number, saturation: number, lightness: number) => `hsl(${Math.round(((hue % 360) + 360) % 360)} ${Math.round(saturation)}% ${Math.round(lightness)}%)`; function waxTone(hue: number, between: (min: number, max: number) => number) { return { core: hsl(hue + 8, between(85, 100), between(68, 76)), edge: hsl(hue, between(80, 95), between(50, 58)) }; } export function lavaLamp(handle: string): LavaLamp { const [high, low] = cyrb53(handle.toLowerCase()); const between = ranged(mulberry32(high)); const chance = (odds: number) => between(0, 1) < odds; const waxHue = between(0, 360); const liquidHue = waxHue + (chance(COMPLEMENTARY_CHANCE) ? between(150, 210) : between(-50, 50)); const wax = [waxTone(waxHue, between)]; const twoTone = chance(TWO_TONE_CHANCE); if (twoTone) wax.push(waxTone(waxHue + (chance(0.5) ? 1 : -1) * between(30, 60), between)); const liquid = { top: hsl(liquidHue, between(45, 75), between(20, 28)), bottom: hsl(liquidHue + between(-15, 15), between(50, 80), between(8, 13)), }; const blobs: LavaBlob[] = [{ cx: 32, cy: VIEW + 10, rx: between(36, 44), ry: between(16, 22), tone: 0 }]; if (chance(TOP_CAP_CHANCE)) { blobs.push({ cx: between(20, 44), cy: -6, rx: between(14, 24), ry: between(9, 13), tone: 0 }); } const fine = chance(FINE_BUBBLES_CHANCE); const floating = fine ? 4 + Math.floor(between(0, 5)) : 2 + Math.floor(between(0, 4)); for (let i = 0; i < floating; i++) { const radius = fine ? between(2.5, 5.5) : between(4, 11); blobs.push({ cx: between(10, 54), cy: between(8, 54), rx: radius, ry: radius * between(0.85, 1.6), tone: twoTone && i % 2 === 1 ? 1 : 0, }); } return { noiseSeed: low % 10000, noiseFrequency: round(between(0.03, 0.06)), wobble: round(between(4, 9)), wax, liquid, glow: hsl(waxHue, 100, 60), highlight: { x: round(between(6, 16)), angle: round(between(-12, 12)) }, blobs: blobs.map((blob) => ({ ...blob, cx: round(blob.cx), cy: round(blob.cy), rx: round(blob.rx), ry: round(blob.ry), })), }; } /** * Motion for each floating blob, from a separate stream so it never perturbs the static lamp. Every * blob rises until it clears the top of the glass, then sinks back; loop length follows the distance * so big climbs don't race and short ones don't crawl. */ export function lavaMotion(handle: string, blobs: LavaBlob[]): LavaMotion[] { const [, low] = cyrb53(handle.toLowerCase(), 0x1a7a); const between = ranged(mulberry32(low)); return blobs.map((blob) => { const rise = blob.cy + blob.ry + CLEARANCE; const unitsPerSecond = between(MIN_SPEED, MAX_SPEED); return { rise: round(rise), sway: round(between(-3, 3)), seconds: round(Math.min(MAX_LOOP_SECONDS, Math.max(MIN_LOOP_SECONDS, (2 * rise) / unitsPerSecond))), }; }); } const EASE = 'calcMode="spline" keyTimes="0;.5;1" keySplines=".45 0 .55 1;.45 0 .55 1" repeatCount="indefinite"'; function blobMarkup(blob: LavaBlob, motion: LavaMotion | null): string { const attrs = `cx="${blob.cx}" cy="${blob.cy}" rx="${blob.rx}" ry="${blob.ry}"`; if (!motion) return ``; const dur = `dur="${motion.seconds}s"`; const top = round(blob.cy - motion.rise); const side = round(blob.cx + motion.sway); return ( `` + `` + `` + `` ); } function render(handle: string, animated: boolean): string { const lamp = lavaLamp(handle); const moving = lamp.blobs.slice(1); const motion = animated ? lavaMotion(handle, moving) : []; const markup = lamp.blobs.map((blob, index) => ({ tone: blob.tone, svg: blobMarkup(blob, index === 0 || !animated ? null : motion[index - 1]), })); const groups = lamp.wax .map((_, tone) => { const inner = markup .filter((blob) => blob.tone === tone) .map((blob) => blob.svg) .join(""); return inner ? `${inner}` : ""; }) .join(""); const waxGradients = lamp.wax .map( (tone, index) => ``, ) .join(""); const frequency = lamp.noiseFrequency; const noiseMotion = animated ? `` : ""; return ( `` + `` + `` + `` + waxGradients + `` + `${noiseMotion}` + `` + `` + `` + `` + `` + `` + `` + groups + `` + `` + `` ); } export function lavaLampSvg(handle: string): string { return render(handle, false); } /** Same lamp with drifting wax; frame zero is identical to {@link lavaLampSvg}. */ export function animatedLavaLampSvg(handle: string): string { return render(handle, true); } const dataUrls = new Map(); function memoized(key: string, build: () => string): string { const cached = dataUrls.get(key); if (cached) return cached; const url = `data:image/svg+xml,${encodeURIComponent(build())}`; if (dataUrls.size >= MAX_CACHED) dataUrls.delete(dataUrls.keys().next().value as string); dataUrls.set(key, url); return url; } /** Memoized data URL for an ``, so each lamp is generated once per page lifetime. */ export function lavaLampDataUrl(handle: string, animated = false): string { const name = handle.toLowerCase(); return memoized(`${animated ? "a" : "s"}:${name}`, () => (animated ? animatedLavaLampSvg(name) : lavaLampSvg(name))); }