~james/core

core/web/src/lib/avatarGradient.ts -rw-r--r-- 2.7 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// the avatar worker imports this file too and draws the same gradient

// 32-bit FNV-1a
export const hashString = (str: string): number => {
	let hash = 2166136261;
	for (let i = 0; i < str.length; i++) {
		hash ^= str.charCodeAt(i);
		hash = Math.imul(hash, 16777619);
	}
	return hash >>> 0;
};

// mulberry32
const mulberry32 = (seed: number) => {
	let a = seed >>> 0;
	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;
	};
};

const round = (n: number, places = 1) => Number(n.toFixed(places));

// the chromatic entries of base16 material darker, in wheel order. a run of them stays harmonious
const PALETTE = ["#f07178", "#f78c6c", "#ffcb6b", "#c3e88d", "#89ddff", "#82aaff", "#c792ea"];

export interface Gradient {
	angle: number;
	// offsets are percentages
	stops: { offset: number; color: string }[];
	glow: { x: number; y: number; r: number; opacity: number; color: string };
}

export const gradientFor = (seed: string): Gradient => {
	const rand = mulberry32(hashString(seed));

	const angle = Math.round(rand() * 360);
	const stopCount = 3 + Math.floor(rand() * 3);
	const direction = rand() < 0.5 ? -1 : 1;
	const start = Math.floor(rand() * PALETTE.length);

	const stops = Array.from({ length: stopCount }, (_, i) => ({
		offset: round((i / (stopCount - 1)) * 100),
		color: PALETTE[
			(((start + direction * i) % PALETTE.length) + PALETTE.length) % PALETTE.length
		]
	}));

	const glow = {
		x: round(0.2 + rand() * 0.6, 2),
		y: round(0.15 + rand() * 0.5, 2),
		r: round(0.55 + rand() * 0.35, 2),
		opacity: round(0.25 + rand() * 0.2, 2),
		color: PALETTE[Math.floor(rand() * PALETTE.length)]
	};

	return { angle, stops, glow };
};

// ogre's satori reads rgb(r g b / 0) as opaque black but handles hex alpha
const alpha = (opacity: number) =>
	Math.round(opacity * 255)
		.toString(16)
		.padStart(2, "0");

// the same picture as the worker's svg, as a css background-image
export const gradientCss = (seed: string): string => {
	const { angle, stops, glow } = gradientFor(seed);
	// the svg gradient line runs corner to corner whatever the angle. css spans the box along its angle
	const radians = ((angle + 135) * Math.PI) / 180;
	const scale = Math.SQRT2 / (Math.abs(Math.sin(radians)) + Math.abs(Math.cos(radians)));
	const linear = stops
		.map((s) => `${s.color} ${round(50 + (s.offset - 50) * scale)}%`)
		.join(", ");
	const radius = round(glow.r * 100);
	return (
		`radial-gradient(${radius}% ${radius}% at ${round(glow.x * 100)}% ${round(glow.y * 100)}%, ` +
		`${glow.color}${alpha(glow.opacity)}, ${glow.color}00), ` +
		`linear-gradient(${(angle + 135) % 360}deg, ${linear})`
	);
};