yummy.cricket/rainbow-quox/color.ts

268 lines
7.8 KiB
TypeScript

const rand: () => number = Math.random; // [todo]
const max = Math.max;
const min = Math.min;
export type Oklch = { l: number, c: number, h: number };
export function oklch(col: Oklch, alpha: number = 1): string {
return `oklch(${col.l} ${col.c} ${col.h} / ${alpha})`;
}
type LD = 'light' | 'dark';
const MAXL = 0.9;
const MINL = 0.4;
const MINL_LIGHT = 0.7;
const MAXL_DARK = 0.65;
const MINC_LIGHT = 0.08;
const MAXC_LIGHT = 0.1;
const MINC_DARK = 0.12;
const MAXC_DARK = 0.175;
// max spread for a sequence of analogous colors. unless that would put them
// too close together
const MAXH_WIDTH = 80;
// minimum distance between adjacent analogous colors
const MINH_SEP = 5;
// size of the wedge a "complementary" color can be in
const MAXH_COMPL = 40;
// size of the wedge a "triadic" color can be in
const MAXH_TRIAD = 25;
function randBetween(x: number, y: number): number {
const lo = min(x, y), hi = max(x, y);
return lo + rand() * (hi - lo);
}
function oneOf<A>(...xs: A[]): A {
return xs[Math.floor(rand() * xs.length)]!;
}
function baseLuma(ld?: LD): number {
if (ld == 'light') {
return randBetween(MINL_LIGHT, MAXL);
} else if (ld == 'dark') {
return randBetween(MINL, MAXL_DARK);
} else {
return randBetween(MINL, MAXL);
}
}
function baseChroma(l: number): number {
if (l >= MINL_LIGHT) {
return randBetween(MINC_LIGHT, MAXC_LIGHT);
} else {
return randBetween(MINC_DARK, MAXC_DARK);
}
}
function baseHue(): number { return rand() * 360; }
function baseOklch(ld?: LD): Oklch {
const l = baseLuma(ld);
return { l, c: baseChroma(l), h: baseHue() };
}
function lightFor(baseL: number): number {
return randBetween(baseL, MAXL);
}
function darkFor(baseL: number): number {
return randBetween(MINL, baseL);
}
function isLight(l: number): boolean { return l >= MINL_LIGHT; }
function brightFor(l: number, baseC: number): number {
if (isLight(l)) { return randBetween(baseC, MAXC_LIGHT); }
else { return randBetween(baseC, MAXC_DARK); }
}
function dullFor(l: number, baseC: number): number {
if (isLight(l)) { return randBetween(baseC, MINC_LIGHT); }
else { return randBetween(baseC, MINC_DARK); }
}
function analogous1(baseH: number): number {
const size = randBetween(MINH_SEP, 2 * MINH_SEP);
return rand() > 0.5 ? baseH + size : baseH - size;
}
function analogous(baseH: number, count: number): number[] {
const minWidth = min(count * MINH_SEP, MAXH_WIDTH * 0.8);
const width = randBetween(minWidth, MAXH_WIDTH);
const sep = width / (count - 1);
const start = baseH - (width / 2);
const numbers = Array.from({length: count}, (_u, i) => start + i * sep);
return rand() > 0.5 ? numbers : numbers.reverse();
}
function complementary1(baseH: number): number {
return analogous1((baseH + 180) % 360);
}
function complementary(baseH: number, count: number): number[] {
const angle = randBetween(180 - MAXH_COMPL/2, 180 + MAXH_COMPL/2);
return analogous(baseH + angle, count);
}
function triad(baseH: number): [number, number] {
const angle = randBetween(120 - MAXH_TRIAD/2, 120 + MAXH_TRIAD/2);
return [baseH - angle, baseH + angle];
}
export type SchemeType = 'triad' | 'fin-belly' | 'fin-body';
export type OuterLayer = 'outer' | 'spines' | 'vitiligo1';
export type SockLayer = 'stripes' | 'cuffs';
export type FinLayer = 'fins1' | 'fins2' | 'fins3' | 'vitiligo4';
export type BellyLayer = 'belly1' | 'vitiligo3' | 'belly2' | 'vitiligo2';
export type MiscLayer = 'eyes' | 'masks' | 'claws' | 'lines';
export type Layer =
OuterLayer | SockLayer | FinLayer | BellyLayer | MiscLayer;
export type ColsOf<A extends string> = Record<A, Oklch>;
export type OuterCols = ColsOf<OuterLayer>;
export type SockCols = ColsOf<SockLayer>;
export type FinCols = ColsOf<FinLayer>;
export type BellyCols = ColsOf<BellyLayer>;
export type MiscCols = ColsOf<MiscLayer>;
export type Colors = ColsOf<Layer>;
export type Scheme = Colors & {type: SchemeType};
export const allLayers: Layer[] =
['outer', 'spines', 'stripes', 'cuffs', 'fins1', 'fins2', 'fins3',
'belly1', 'belly2', 'masks', 'claws', 'vitiligo1', 'vitiligo2', 'vitiligo3',
'vitiligo4', 'eyes', 'lines'];
export function makeColorInfo<A>(f: (l: Layer) => A): Record<Layer, A> {
return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record<Layer, A>;
}
export function colors(): Scheme {
const outer = baseOklch('dark');
let outerCols: OuterCols =
{ outer, spines: mkSpines(outer), vitiligo1: mkVitiligo(outer) };
const stripes = mkStripes();
let sockCols: SockCols = { stripes, cuffs: mkCuffs(stripes) };
let finCols: FinCols, bellyCols: BellyCols, type: SchemeType;
const whichBody = rand();
if (whichBody > 2/3) {
type = 'triad';
const [f, b] = triad(outer.h);
finCols = mkFins(f, outer); bellyCols = mkBelly(b);
} else if (whichBody > 1/3) {
type = 'fin-belly';
const [f, b] = complementary(outer.h, 2);
finCols = mkFins(f!, outer); bellyCols = mkBelly(b!);
} else {
type = 'fin-body';
finCols = mkFins(analogous1(outer.h), outer);
bellyCols = mkBelly(complementary1(outer.h));
}
let miscCols = mkMisc(outerCols, finCols, bellyCols);
return merge(outerCols, sockCols, finCols, bellyCols, miscCols, type);
}
function mkSpines(outer: Oklch): Oklch {
return {
l: outer.l * 0.8, c: outer.c * 1.1,
h: randBetween(outer.h + 12, outer.h - 12)
};
}
function mkVitiligo(outer: Oklch): Oklch {
return {
l: randBetween(max(outer.l, 0.94), 0.985), // exception to MAXL
c: randBetween(min(outer.c, 0.1), MINC_LIGHT),
h: outer.h
};
}
function mkStripes(): Oklch {
return {
l: randBetween(0.8, MAXL),
c: randBetween(MINC_LIGHT, MAXC_LIGHT),
h: rand() * 360
};
}
function mkCuffs(sock: Oklch): Oklch {
return {
l: randBetween(sock.l * 0.85, sock.l * 0.65),
c: randBetween(sock.c, MAXC_LIGHT),
h: randBetween(sock.h + 8, sock.h - 8)
};
}
function mkFins(h: number, outer: Oklch): FinCols {
const [fin1Hue, fin2Hue, fin3Hue] = analogous(h, 3);
const [ll, cc] = oneOf([lightFor, dullFor], [darkFor, brightFor]);
const fins1 = { l: ll(outer.l), c: cc(outer.l, outer.c), h: fin1Hue! };
const fins2 = { l: ll(fins1.l), c: cc(fins1.l, fins1.c), h: fin2Hue! };
const fins3 = { l: ll(fins2.l), c: cc(fins2.l, fins2.c), h: fin3Hue! };
const vitiligo4 = mkVitiligo(fins1);
return { fins1, fins2, fins3, vitiligo4 };
}
function mkBelly(h: number): BellyCols {
const [belly1Hue, belly2Hue] = analogous(h, 2);
const belly1 =
{ l: randBetween(0.7, MAXL), c: baseChroma(1), h: belly1Hue! };
const belly2 =
{ l: min(MAXL, belly1.l * 1.1), c: belly1.c * 0.9, h: belly2Hue! };
const vitiligo3 = mkVitiligo(belly1);
const vitiligo2 = mkVitiligo(belly2);
return { belly1, belly2, vitiligo2, vitiligo3 };
}
function mkMisc(o: OuterCols, f: FinCols, b: BellyCols): MiscCols {
const masks = {
l: randBetween(0.8, MAXL),
c: randBetween(0.01, 0.06),
h: analogous1(oneOf(o.outer, b.belly1, f.fins1).h)
};
return {
masks,
eyes: {
l: baseLuma('light'),
c: randBetween(0.28, MAXC_LIGHT),
h: oneOf(analogous1, complementary1)(o.outer.h)
},
claws: {
l: min(MAXL, masks.l + randBetween(0, 0.1)),
c: randBetween(0.01, 0.06),
h: analogous1(masks.h)
},
lines: {
l: randBetween(0.01, 0.06),
c: baseChroma(0),
h: analogous1(o.outer.h)
}
};
}
function merge({ outer, spines, vitiligo1 }: OuterCols,
{ stripes, cuffs }: SockCols,
{ fins1, fins2, fins3, vitiligo4 }: FinCols,
{ belly1, vitiligo3, belly2, vitiligo2 }: BellyCols,
{ eyes, masks, claws, lines }: MiscCols,
type: SchemeType): Scheme {
return {
outer, spines, vitiligo1, stripes, cuffs, fins1, fins2, fins3, vitiligo4,
belly1, vitiligo3, belly2, vitiligo2, eyes, masks, claws, lines, type
};
}