512 lines
15 KiB
TypeScript
512 lines
15 KiB
TypeScript
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';
|
|
|
|
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;
|
|
}
|
|
|
|
function isLight(l: Luma): boolean { return l >= MINL_LIGHT; }
|
|
|
|
export namespace Rand { export type State = R.State; }
|
|
|
|
type CloseFar = 'close' | 'far';
|
|
|
|
export class Rand extends R.Rand {
|
|
constructor();
|
|
constructor([a, b, c, d]: Rand.State);
|
|
constructor(str: string);
|
|
constructor(st?: Rand.State | string) {
|
|
if (st === undefined) super();
|
|
else if (typeof st === 'string') super(st);
|
|
else super(st);
|
|
}
|
|
|
|
lightFor(baseL: Luma, d: CloseFar = 'close'): Luma {
|
|
let maxl = d == 'close' ? min(MAXL, baseL * 1.25) : MAXL;
|
|
return this.float(baseL, maxl);
|
|
}
|
|
darkFor(baseL: Luma, d: CloseFar = 'close'): Luma {
|
|
let minl = d == 'close' ? max(MINL, baseL * 0.8) : MINL
|
|
return this.float(minl, baseL);
|
|
}
|
|
|
|
brightFor(l: Luma, baseC: Chroma): Chroma {
|
|
return this.float(baseC, isLight(l) ? MAXC_LIGHT : MAXC_DARK);
|
|
}
|
|
|
|
dullFor(l: Luma, baseC: Chroma): Chroma {
|
|
return this.float(baseC, isLight(l) ? MINC_LIGHT : MINC_DARK);
|
|
}
|
|
|
|
analogous1(baseH: Hue): Hue {
|
|
const size = this.float(MINH_SEP, 2 * MINH_SEP);
|
|
return this.boolean() ? baseH + size : baseH - size;
|
|
}
|
|
|
|
analogous(baseH: Hue, count: number): Hue[] {
|
|
const minWidth = min(count * MINH_SEP, MAXH_WIDTH * 0.8);
|
|
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 this.boolean() ? numbers : numbers.reverse();
|
|
}
|
|
|
|
complementary1(baseH: Hue): Hue {
|
|
return this.analogous1((baseH + 180) % 360);
|
|
}
|
|
|
|
complementary(baseH: Hue, count: number): Hue[] {
|
|
const angle = this.float(180 - MAXH_COMPL/2, 180 + MAXH_COMPL/2);
|
|
return this.analogous(baseH + angle, count);
|
|
}
|
|
|
|
triad(baseH: Hue): [Hue, Hue] {
|
|
const angle = this.float(120 - MAXH_TRIAD/2, 120 + MAXH_TRIAD/2);
|
|
return [baseH - angle, baseH + angle];
|
|
}
|
|
|
|
baseLuma(ld?: LD): Luma {
|
|
if (ld == 'light') {
|
|
return this.float(MINL_LIGHT, MAXL);
|
|
} else if (ld == 'dark') {
|
|
return this.float(MINL, MAXL_DARK);
|
|
} else {
|
|
return this.float(MINL, MAXL);
|
|
}
|
|
}
|
|
|
|
baseChroma(l: Luma): Chroma {
|
|
if (l >= MINL_LIGHT) {
|
|
return this.float(MINC_LIGHT, MAXC_LIGHT);
|
|
} else {
|
|
return this.float(MINC_DARK, MAXC_DARK);
|
|
}
|
|
}
|
|
|
|
baseHue(): Hue { return this.float(360); }
|
|
}
|
|
|
|
|
|
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(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 {
|
|
const r = ll as Rand;
|
|
this.l = r.baseLuma(cc as LD | undefined);
|
|
this.c = r.baseChroma(this.l);
|
|
this.h = r.baseHue();
|
|
}
|
|
}
|
|
|
|
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(maps.l, this.l),
|
|
c: call(maps.c, this.c),
|
|
h: call(maps.h, this.h),
|
|
});
|
|
}
|
|
|
|
css(alpha: number = 1): string {
|
|
const l = (this.l * 100).toFixed(0);
|
|
const c = (this.c * 250).toFixed(0);
|
|
const h = this.h.toFixed(0);
|
|
if (alpha != 1) { return `oklch(${l}% ${c}% ${h} / ${alpha})`; }
|
|
else { return `oklch(${l}% ${c}% ${h})`; }
|
|
}
|
|
|
|
rgb(): Rgb { return toRgbViaCanvas(this); }
|
|
|
|
static validate(x: unknown): Oklch | undefined {
|
|
if (typeof x == 'object' && x != null && 'l' in x && 'c' in x && 'h' in x) {
|
|
const { l, c, h } = x;
|
|
if (typeof l == 'number' && typeof c == 'number' && typeof h == 'number')
|
|
return oklch(l, c, h);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
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 type BaseCol = 'outer' | 'belly' | 'fins';
|
|
export type OptionalBaseCol = 'eyes' | 'stripes';
|
|
|
|
type KnownPalette =
|
|
Record<BaseCol, Oklch> & Partial<Record<OptionalBaseCol, Oklch>>;
|
|
|
|
|
|
export function colors(r: Rand = new Rand(), base?: KnownPalette): Scheme {
|
|
const outer = base?.outer ?? new Oklch(r, 'dark');
|
|
let outerCols: OuterCols =
|
|
{ outer, spines: mkSpines(r, outer), vitiligo1: mkVitiligo(r, outer) };
|
|
|
|
const stripes = mkStripes(r);
|
|
let sockCols: SockCols = { stripes, cuffs: mkCuffs(r, stripes) };
|
|
|
|
let finCols: FinCols, bellyCols: BellyCols, type: SchemeType;
|
|
const whichBody = r.float();
|
|
if (whichBody > 2/3) {
|
|
type = 'triad';
|
|
const [f, b] = r.triad(outer.h);
|
|
finCols = mkFins(r, f, outer, base); bellyCols = mkBelly(r, b, base);
|
|
} else if (whichBody > 1/3) {
|
|
type = 'fin-belly';
|
|
const [f, b] = r.complementary(outer.h, 2);
|
|
finCols = mkFins(r, f!, outer, base); bellyCols = mkBelly(r, b!, base);
|
|
} else {
|
|
type = 'fin-body';
|
|
finCols = mkFins(r, r.analogous1(outer.h), outer, base);
|
|
bellyCols = mkBelly(r, r.complementary1(outer.h), base);
|
|
}
|
|
|
|
let miscCols = mkMisc(r, outerCols, finCols, bellyCols, base);
|
|
|
|
return merge(outerCols, sockCols, finCols, bellyCols, miscCols, type);
|
|
}
|
|
|
|
|
|
function mkSpines(r: Rand, outer: Oklch): Oklch {
|
|
return outer.with({
|
|
l: l => r.darkFor(l),
|
|
c: c => r.brightFor(outer.l, c),
|
|
h: h => r.float(h + 12, h - 12),
|
|
})
|
|
}
|
|
|
|
function mkVitiligo(r: Rand, outer: Oklch): Oklch {
|
|
return outer.with({
|
|
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(r: Rand): Oklch {
|
|
return new Oklch({
|
|
l: r.float(0.8, MAXL),
|
|
c: r.float(MINC_LIGHT, MAXC_LIGHT),
|
|
h: r.baseHue(),
|
|
});
|
|
}
|
|
|
|
function mkCuffs(r: Rand, sock: Oklch): Oklch {
|
|
return sock.with({
|
|
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(r: Rand, h: Hue, outer: Oklch, base?: KnownPalette): FinCols {
|
|
const baseFin1 = base?.fins;
|
|
const [fin1Hue, fin2Hue, fin3Hue] = r.analogous(baseFin1?.h ?? 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 = baseFin1 ?? oklch(ll(outer.l), cc(outer.l, outer.c), fin1Hue!);
|
|
const fins2 = oklch(ll(fins1.l), cc(fins1.l, fins1.c), fin2Hue!);
|
|
const fins3 = oklch(ll(fins2.l), cc(fins2.l, fins2.c), fin3Hue!);
|
|
const lighter = fins1.l >= fins3.l ? fins1 : fins3;
|
|
const vitiligo4 = mkVitiligo(r, lighter);
|
|
return { fins1, fins2, fins3, vitiligo4 };
|
|
}
|
|
|
|
function mkBelly(r: Rand, h: Hue, base?: KnownPalette): BellyCols {
|
|
let baseBelly1 = base?.belly;
|
|
const [belly1Hue, belly2Hue] = r.analogous(baseBelly1?.h ?? h, 2);
|
|
const belly1 = baseBelly1 ?? new Oklch({
|
|
l: r.float(0.7, MAXL),
|
|
c: r.baseChroma(1),
|
|
h: belly1Hue!
|
|
});
|
|
const belly2 = belly1.with({
|
|
l: x => min(MAXL, x * 1.1),
|
|
c: x => x * 0.9,
|
|
h: belly2Hue!,
|
|
});
|
|
const vitiligo3 = mkVitiligo(r, belly1);
|
|
const vitiligo2 = mkVitiligo(r, belly2);
|
|
return { belly1, belly2, vitiligo2, vitiligo3 };
|
|
}
|
|
|
|
function mkMisc(r: Rand, o: OuterCols, f: FinCols, b: BellyCols,
|
|
base?: KnownPalette): MiscCols {
|
|
const masks = new Oklch({
|
|
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: base?.eyes ?? new Oklch({
|
|
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 + r.float(0, 0.1)),
|
|
c: r.float(0.01, 0.06),
|
|
h: h => r.analogous1(h),
|
|
}),
|
|
lines: new Oklch({
|
|
l: r.float(0.01, 0.06),
|
|
c: r.baseChroma(0),
|
|
h: r.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
|
|
};
|
|
}
|
|
|
|
|
|
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)}`
|
|
}
|
|
|
|
static validate(x: unknown): Rgb | undefined {
|
|
if (typeof x == 'object' && x != null && 'r' in x && 'g' in x && 'b' in x) {
|
|
const { r, g, b } = x;
|
|
if (typeof r == 'number' && typeof g == 'number' && typeof b == 'number')
|
|
return rgb(r, g, 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 pix = rgbBuf.getImageData(0, 0, 1, 1).data;
|
|
return rgb(pix[0]!, pix[1]!, pix[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)}`;
|
|
}
|
|
|
|
|
|
export function oklch(l: number, c: number, h: number) {
|
|
return new Oklch(l, c, h);
|
|
}
|
|
|
|
export function rgb(r: number, g: number, b: number) {
|
|
return new Rgb(r, g, b);
|
|
}
|
|
|
|
|
|
export const KNOWN: Record<string, KnownPalette> = {
|
|
niss: {
|
|
outer: oklch(0.83, 0.201, 151),
|
|
belly: oklch(0.87, 0.082, 99),
|
|
fins: oklch(0.68, 0.178, 16),
|
|
eyes: oklch(0.73, 0.135, 242),
|
|
},
|
|
kesi: {
|
|
outer: oklch(0.86, 0.147, 147),
|
|
belly: oklch(0.96, 0.04, 108),
|
|
fins: oklch(0.94, 0.142, 102),
|
|
eyes: oklch(0.76, 0.115, 300),
|
|
},
|
|
60309: {
|
|
outer: oklch(0.84, 0.068, 212),
|
|
belly: oklch(0.56, 0.035, 233),
|
|
fins: oklch(0.55, 0.101, 268),
|
|
eyes: oklch(0.86, 0.146, 154),
|
|
},
|
|
'prickly pear': {
|
|
outer: oklch(0.64, 0.087, 316),
|
|
belly: oklch(0.88, 0.03, 88),
|
|
fins: oklch(0.6, 0.071, 142),
|
|
eyes: oklch(0.66, 0.091, 134),
|
|
},
|
|
'the goo': {
|
|
outer: oklch(0.92, 0.046, 354),
|
|
belly: oklch(0.83, 0.099, 354),
|
|
fins: oklch(0.74, 0.115, 354),
|
|
eyes: oklch(0.73, 0.149, 0),
|
|
},
|
|
lambda: {
|
|
outer: oklch(0.71, 0.154, 58),
|
|
belly: oklch(0.9, 0.05, 80),
|
|
fins: oklch(0.76, 0.16, 140),
|
|
eyes: oklch(0.82, 0.178, 141),
|
|
},
|
|
flussence: {
|
|
outer: oklch(0.77, 0.118, 133),
|
|
belly: oklch(0.71, 0.086, 253),
|
|
fins: oklch(0.58, 0.102, 254),
|
|
eyes: oklch(0.37, 0.107, 278),
|
|
},
|
|
serena: {
|
|
outer: oklch(0.69, 0.176, 349),
|
|
belly: oklch(0.92, 0.04, 350),
|
|
fins: oklch(0.74, 0.138, 319),
|
|
eyes: oklch(0.65, 0.206, 4),
|
|
},
|
|
pippin: {
|
|
outer: oklch(0.74, 0.08, 61),
|
|
belly: oklch(0.82, 0.062, 70),
|
|
fins: oklch(0.52, 0.09, 45),
|
|
eyes: oklch(0.74, 0.167, 136),
|
|
},
|
|
su: {
|
|
outer: oklch(0.29, 0.012, 219),
|
|
belly: oklch(0.89, 0.01, 256),
|
|
fins: oklch(0.53, 0.093, 20),
|
|
// eyes: oklch(0.53, 0.109, 254),
|
|
},
|
|
trans: {
|
|
outer: oklch(0.83, 0.065, 228),
|
|
belly: oklch(0.95, 0.021, 137),
|
|
fins: oklch(0.86, 0.069, 352),
|
|
// eyes: oklch(0.57, 0.158, 273),
|
|
},
|
|
};
|