/**
* 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 (
``
);
}
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)));
}