rainbow quox

This commit is contained in:
Rhiannon Morris 2024-12-09 22:15:42 +01:00
parent e35f46003b
commit e14bc51fff
16 changed files with 1021 additions and 802 deletions

View file

@ -1,109 +1,142 @@
const rand: () => number = Math.random; // [todo]
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)]!;
}
import * as R from './rand.js';
const max = Math.max;
const min = Math.min;
export type Luma = number;
export type Chroma = number;
export type Hue = number;
export type Alpha = number;
export type HueDistance = number;
const MAXL: Luma = 0.9;
const MINL: Luma = 0.4;
const MINL_LIGHT: Luma = 0.7;
const MAXL_DARK: Luma = 0.65;
const MINC_LIGHT: Chroma = 0.08;
const MAXC_LIGHT: Chroma = 0.1;
const MINC_DARK: Chroma = 0.12;
const MAXC_DARK: Chroma = 0.175;
// max spread for a sequence of analogous colors. unless that would put them
// too close together
const MAXH_WIDTH: HueDistance = 80;
// minimum distance between adjacent analogous colors
const MINH_SEP: HueDistance = 5;
// size of the wedge a "complementary" color can be in
const MAXH_COMPL: HueDistance = 40;
// size of the wedge a "triadic" color can be in
const MAXH_TRIAD: HueDistance = 25;
type LD = 'light' | 'dark';
namespace Oklch {
export namespace Oklch {
export type Channel = 'l' | 'c' | 'h';
export type Channels = Record<Channel, number>;
export type ChannelMap = (x: number) => number;
export type ChannelMaps = Record<Channel, ChannelMap>;
// a function, or constant value, for each channel;
// or nothing, which indicates identity function
export type With = Partial<Record<Channel, number | ChannelMap>>;
export type With1 = ChannelMap | number | undefined;
}
export class Oklch {
static lightFor(baseL: number): number { return randBetween(baseL, MAXL); }
function isLight(l: Luma): boolean { return l >= MINL_LIGHT; }
static darkFor(baseL: number): number { return randBetween(MINL, baseL); }
export namespace Rand { export type State = R.State; }
static isLight(l: number): boolean { return l >= MINL_LIGHT; }
export class Rand extends R.Rand {
constructor(seed?: R.State) { super(seed); }
static brightFor(l: number, baseC: number): number {
if (Oklch.isLight(l)) { return randBetween(baseC, MAXC_LIGHT); }
else { return randBetween(baseC, MAXC_DARK); }
lightFor(baseL: Luma): Luma { return this.float(baseL, MAXL); }
darkFor(baseL: Luma): Luma { return this.float(MINL, baseL); }
brightFor(l: Luma, baseC: Chroma): Chroma {
return this.float(baseC, isLight(l) ? MAXC_LIGHT : MAXC_DARK);
}
static dullFor(l: number, baseC: number): number {
if (Oklch.isLight(l)) { return randBetween(baseC, MINC_LIGHT); }
else { return randBetween(baseC, MINC_DARK); }
dullFor(l: Luma, baseC: Chroma): Chroma {
return this.float(baseC, isLight(l) ? MINC_LIGHT : MINC_DARK);
}
static analogous1(baseH: number): number {
const size = randBetween(MINH_SEP, 2 * MINH_SEP);
return rand() > 0.5 ? baseH + size : baseH - size;
analogous1(baseH: Hue): Hue {
const size = this.float(MINH_SEP, 2 * MINH_SEP);
return this.boolean() ? baseH + size : baseH - size;
}
static analogous(baseH: number, count: number): number[] {
analogous(baseH: Hue, count: number): Hue[] {
const minWidth = min(count * MINH_SEP, MAXH_WIDTH * 0.8);
const width = randBetween(minWidth, MAXH_WIDTH);
const width = this.float(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();
return this.boolean() ? numbers : numbers.reverse();
}
static complementary1(baseH: number): number {
return Oklch.analogous1((baseH + 180) % 360);
complementary1(baseH: Hue): Hue {
return this.analogous1((baseH + 180) % 360);
}
static complementary(baseH: number, count: number): number[] {
const angle = randBetween(180 - MAXH_COMPL/2, 180 + MAXH_COMPL/2);
return Oklch.analogous(baseH + angle, count);
complementary(baseH: Hue, count: number): Hue[] {
const angle = this.float(180 - MAXH_COMPL/2, 180 + MAXH_COMPL/2);
return this.analogous(baseH + angle, count);
}
static triad(baseH: number): [number, number] {
const angle = randBetween(120 - MAXH_TRIAD/2, 120 + MAXH_TRIAD/2);
triad(baseH: Hue): [Hue, Hue] {
const angle = this.float(120 - MAXH_TRIAD/2, 120 + MAXH_TRIAD/2);
return [baseH - angle, baseH + angle];
}
readonly l: number; readonly c: number; readonly h: number;
static baseLuma(ld?: LD): number {
baseLuma(ld?: LD): Luma {
if (ld == 'light') {
return randBetween(MINL_LIGHT, MAXL);
return this.float(MINL_LIGHT, MAXL);
} else if (ld == 'dark') {
return randBetween(MINL, MAXL_DARK);
return this.float(MINL, MAXL_DARK);
} else {
return randBetween(MINL, MAXL);
return this.float(MINL, MAXL);
}
}
static baseChroma(l: number): number {
baseChroma(l: Luma): Chroma {
if (l >= MINL_LIGHT) {
return randBetween(MINC_LIGHT, MAXC_LIGHT);
return this.float(MINC_LIGHT, MAXC_LIGHT);
} else {
return randBetween(MINC_DARK, MAXC_DARK);
return this.float(MINC_DARK, MAXC_DARK);
}
}
static baseHue(): number { return rand() * 360; }
baseHue(): Hue { return this.float(360); }
}
constructor();
constructor(ld: LD);
export class Oklch {
readonly l: Luma; readonly c: Chroma; readonly h: Hue;
static normHue(h: Hue) { return (h = h % 360) < 0 ? h + 360 : h; }
constructor(l: Luma, c: Chroma, h: Hue);
constructor(r: Rand, ld?: LD);
constructor(cs: Oklch.Channels);
constructor(l: number, c: number, h: number);
constructor(lcsld?: number | Oklch.Channels | LD,
cc?: number, hh?: number) {
if (typeof lcsld == 'string' || lcsld == undefined) {
this.l = Oklch.baseLuma(lcsld as LD | undefined);
this.c = Oklch.baseChroma(this.l);
this.h = Oklch.baseHue();
} else if (cc == undefined && hh == undefined) {
const {l, c, h} = lcsld as Oklch.Channels;
constructor(ll: Luma | Oklch.Channels | Rand, cc?: Chroma | LD, hh?: Hue) {
if (hh !== undefined) {
this.l = ll as Luma;
this.c = cc as Chroma;
this.h = hh as Hue;
} else if (typeof ll == 'object' && 'l' in ll) {
const {l, c, h} = ll as Oklch.Channels;
this.l = l; this.c = c; this.h = h;
} else {
this.l = lcsld as number; this.c = cc!; this.h = hh!;
const r = ll as Rand;
this.l = r.baseLuma(cc as LD | undefined);
this.c = r.baseChroma(this.l);
this.h = r.baseHue();
}
}
@ -115,44 +148,25 @@ export class Oklch {
else { return `oklch(${l}% ${c}% ${h})`; }
}
with(lch: Partial<Record<Oklch.Channel, number | Oklch.ChannelMap>>): Oklch {
function call(comp: undefined | number | Oklch.ChannelMap, x: number) {
if (comp == undefined) { return x; }
else if (typeof comp == 'function') { return comp(x); }
else { return comp as number; }
with(maps: Oklch.With): Oklch {
function call(comp: Oklch.With1, x: number) {
switch (typeof comp) {
case 'number': return comp;
case 'function': return comp(x);
default: return x;
}
}
return new Oklch({
l: call(lch.l, this.l),
c: call(lch.c, this.c),
h: call(lch.h, this.h),
l: call(maps.l, this.l),
c: call(maps.c, this.c),
h: call(maps.h, this.h),
});
}
rgb(): Rgb { return toRgbViaCanvas(this); }
}
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;
export type SchemeType = 'triad' | 'fin-belly' | 'fin-body';
export type OuterLayer = 'outer' | 'spines' | 'vitiligo1';
@ -183,84 +197,90 @@ export function makeColorInfo<A>(f: (l: Layer) => A): Record<Layer, A> {
}
export function colors(): Scheme {
const outer = new Oklch('dark');
export function colors(r: Rand = new Rand()): Scheme {
const outer = new Oklch(r, 'dark');
let outerCols: OuterCols =
{ outer, spines: mkSpines(outer), vitiligo1: mkVitiligo(outer) };
{ outer, spines: mkSpines(r, outer), vitiligo1: mkVitiligo(r, outer) };
const stripes = mkStripes();
let sockCols: SockCols = { stripes, cuffs: mkCuffs(stripes) };
const stripes = mkStripes(r);
let sockCols: SockCols = { stripes, cuffs: mkCuffs(r, stripes) };
let finCols: FinCols, bellyCols: BellyCols, type: SchemeType;
const whichBody = rand();
const whichBody = r.float();
if (whichBody > 2/3) {
type = 'triad';
const [f, b] = Oklch.triad(outer.h);
finCols = mkFins(f, outer); bellyCols = mkBelly(b);
const [f, b] = r.triad(outer.h);
finCols = mkFins(r, f, outer); bellyCols = mkBelly(r, b);
} else if (whichBody > 1/3) {
type = 'fin-belly';
const [f, b] = Oklch.complementary(outer.h, 2);
finCols = mkFins(f!, outer); bellyCols = mkBelly(b!);
const [f, b] = r.complementary(outer.h, 2);
finCols = mkFins(r, f!, outer); bellyCols = mkBelly(r, b!);
} else {
type = 'fin-body';
finCols = mkFins(Oklch.analogous1(outer.h), outer);
bellyCols = mkBelly(Oklch.complementary1(outer.h));
finCols = mkFins(r, r.analogous1(outer.h), outer);
bellyCols = mkBelly(r, r.complementary1(outer.h));
}
let miscCols = mkMisc(outerCols, finCols, bellyCols);
let miscCols = mkMisc(r, outerCols, finCols, bellyCols);
return merge(outerCols, sockCols, finCols, bellyCols, miscCols, type);
}
function mkSpines(outer: Oklch): Oklch {
function mkSpines(r: Rand, outer: Oklch): Oklch {
return outer.with({
l: x => x * 0.8,
c: x => x * 1.1,
h: x => randBetween(x + 12, x - 12),
h: x => r.float(x + 12, x - 12),
})
}
function mkVitiligo(outer: Oklch): Oklch {
function mkVitiligo(r: Rand, outer: Oklch): Oklch {
return outer.with({
l: x => randBetween(max(x, 0.94), 0.985), // exception to MAXL
c: x => randBetween(min(x, 0.1), MINC_LIGHT),
l: x => r.float(max(x, 0.94), 0.985), // exception to MAXL
c: x => r.float(min(x, 0.1), MINC_LIGHT),
});
}
function mkStripes(): Oklch {
function mkStripes(r: Rand): Oklch {
return new Oklch({
l: randBetween(0.8, MAXL),
c: randBetween(MINC_LIGHT, MAXC_LIGHT),
h: rand() * 360
l: r.float(0.8, MAXL),
c: r.float(MINC_LIGHT, MAXC_LIGHT),
h: r.baseHue(),
});
}
function mkCuffs(sock: Oklch): Oklch {
function mkCuffs(r: Rand, sock: Oklch): Oklch {
return sock.with({
l: x => randBetween(x * 0.85, x * 0.65),
c: x => randBetween(x, MAXC_LIGHT),
h: x => randBetween(x + 8, x - 8),
l: l => r.float(l * 0.85, l * 0.65),
c: c => r.float(c, MAXC_LIGHT),
h: h => r.float(h + 8, h - 8),
});
}
function mkFins(h: number, outer: Oklch): FinCols {
const [fin1Hue, fin2Hue, fin3Hue] = Oklch.analogous(h, 3);
const [ll, cc] = oneOf(
[Oklch.lightFor, Oklch.dullFor], [Oklch.darkFor, Oklch.brightFor]);
function mkFins(r: Rand, h: Hue, outer: Oklch): FinCols {
const [fin1Hue, fin2Hue, fin3Hue] = r.analogous(h, 3);
const direction: 'lighter' | 'darker' = r.choice(['lighter', 'darker']);
function ll(l: Luma): Luma {
return direction == 'lighter' ? r.lightFor(l) : r.darkFor(l);
}
function cc(l: Luma, c: Chroma): Chroma {
return direction == 'lighter' ? r.dullFor(l, c) : r.brightFor(l, c);
}
const fins1 = new Oklch(ll(outer.l), cc(outer.l, outer.c), fin1Hue!);
const fins2 = new Oklch(ll(fins1.l), cc(fins1.l, fins1.c), fin2Hue!);
const fins3 = new Oklch(ll(fins2.l), cc(fins2.l, fins2.c), fin3Hue!);
const vitiligo4 = mkVitiligo(fins1);
const vitiligo4 = mkVitiligo(r, fins1);
return { fins1, fins2, fins3, vitiligo4 };
}
function mkBelly(h: number): BellyCols {
const [belly1Hue, belly2Hue] = Oklch.analogous(h, 2);
function mkBelly(r: Rand, h: Hue): BellyCols {
const [belly1Hue, belly2Hue] = r.analogous(h, 2);
const belly1 = new Oklch({
l: randBetween(0.7, MAXL),
c: Oklch.baseChroma(1),
l: r.float(0.7, MAXL),
c: r.baseChroma(1),
h: belly1Hue!
});
const belly2 = belly1.with({
@ -268,33 +288,33 @@ function mkBelly(h: number): BellyCols {
c: x => x * 0.9,
h: belly2Hue!,
});
const vitiligo3 = mkVitiligo(belly1);
const vitiligo2 = mkVitiligo(belly2);
const vitiligo3 = mkVitiligo(r, belly1);
const vitiligo2 = mkVitiligo(r, belly2);
return { belly1, belly2, vitiligo2, vitiligo3 };
}
function mkMisc(o: OuterCols, f: FinCols, b: BellyCols): MiscCols {
function mkMisc(r: Rand, o: OuterCols, f: FinCols, b: BellyCols): MiscCols {
const masks = new Oklch({
l: randBetween(0.8, MAXL),
c: randBetween(0.01, 0.06),
h: Oklch.analogous1(oneOf(o.outer, b.belly1, f.fins1).h)
l: r.float(0.8, MAXL),
c: r.float(0.01, 0.06),
h: r.analogous1(r.choice([o.outer, b.belly1, f.fins1]).h)
});
return {
masks,
eyes: new Oklch({
l: Oklch.baseLuma('light'),
c: randBetween(0.28, MAXC_LIGHT),
h: oneOf(Oklch.analogous1, Oklch.complementary1)(o.outer.h)
l: r.baseLuma('light'),
c: r.float(0.28, MAXC_LIGHT),
h: r.boolean() ? r.analogous1(o.outer.h) : r.complementary1(o.outer.h)
}),
claws: masks.with({
l: x => min(MAXL, x + randBetween(0, 0.1)),
c: randBetween(0.01, 0.06),
h: Oklch.analogous1,
l: x => min(MAXL, x + r.float(0, 0.1)),
c: r.float(0.01, 0.06),
h: h => r.analogous1(h),
}),
lines: new Oklch({
l: randBetween(0.01, 0.06),
c: Oklch.baseChroma(0),
h: Oklch.analogous1(o.outer.h)
l: r.float(0.01, 0.06),
c: r.baseChroma(0),
h: r.analogous1(o.outer.h)
}),
};
}
@ -310,3 +330,62 @@ function merge({ outer, spines, vitiligo1 }: OuterCols,
belly1, vitiligo3, belly2, vitiligo2, eyes, masks, claws, lines, type
};
}
export namespace Rgb {
export type Channel = number;
export type Channels = { r: number, g: number, b: number };
}
export class Rgb {
readonly r: Rgb.Channel;
readonly g: Rgb.Channel;
readonly b: Rgb.Channel;
static clamp(x: Rgb.Channel) {
return min(max(0, Math.floor(x)), 255);
}
constructor(r: Rgb.Channel, g: Rgb.Channel, b: Rgb.Channel);
constructor({r, g, b}: Rgb.Channels);
constructor(rr: Rgb.Channel | Rgb.Channels, gg?: Rgb.Channel, bb?: Rgb.Channel) {
const C = Rgb.clamp;
if (typeof rr == 'number') {
this.r = C(rr!); this.g = C(gg!); this.b = C(bb!);
} else {
this.r = C(rr.r); this.g = C(rr.g); this.b = C(rr.b);
}
}
css() {
function h(x: Rgb.Channel) {
let s = x.toString(16);
return s.length == 2 ? s : '0' + s;
}
return `#${h(this.r)}${h(this.g)}${h(this.b)}`
}
}
export type Rgbs = Record<Layer, Rgb>;
let rgbBuf: OffscreenCanvasRenderingContext2D;
export function toRgbViaCanvas(col: Oklch): Rgb {
rgbBuf ??= new OffscreenCanvas(1, 1).getContext('2d')!;
rgbBuf.fillStyle = col.css();
rgbBuf.fillRect(0, 0, 1, 1);
const rgb = rgbBuf.getImageData(0, 0, 1, 1).data;
return new Rgb(rgb[0]!, rgb[1]!, rgb[2]!);
}
export function toRgbs(col: Colors): Rgbs {
return makeColorInfo(l => col[l].rgb());
}
export function toHex({r, g, b}: Rgb): string {
function chan(n: number) {
let a = Math.floor(n).toString(16);
return a.length == 1 ? `0${a}` : a;
}
return `#${chan(r)}${chan(g)}${chan(b)}`;
}