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; export type ChannelMap = (x: number) => number; export type ChannelMaps = Record; // a function, or constant value, for each channel; // or nothing, which indicates identity function export type With = Partial>; export type With1 = ChannelMap | number | undefined; } function isLight(l: Luma): boolean { return l >= MINL_LIGHT; } export namespace Rand { export type State = R.State; } export class Rand extends R.Rand { constructor(seed?: R.State) { super(seed); } 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); } 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(); } } 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})`; } } 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), }); } rgb(): Rgb { return toRgbViaCanvas(this); } } 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 = Record; export type OuterCols = ColsOf; export type SockCols = ColsOf; export type FinCols = ColsOf; export type BellyCols = ColsOf; export type MiscCols = ColsOf; export type Colors = ColsOf; 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(f: (l: Layer) => A): Record { return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record; } export function colors(r: Rand = new Rand()): Scheme { const 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); bellyCols = mkBelly(r, b); } else if (whichBody > 1/3) { type = 'fin-belly'; const [f, b] = r.complementary(outer.h, 2); finCols = mkFins(r, f!, outer); bellyCols = mkBelly(r, b!); } else { type = 'fin-body'; finCols = mkFins(r, r.analogous1(outer.h), outer); bellyCols = mkBelly(r, r.complementary1(outer.h)); } let miscCols = mkMisc(r, outerCols, finCols, bellyCols); return merge(outerCols, sockCols, finCols, bellyCols, miscCols, type); } function mkSpines(r: Rand, outer: Oklch): Oklch { return outer.with({ l: x => x * 0.8, c: x => x * 1.1, h: x => r.float(x + 12, x - 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): 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 lighter = fins1.l >= fins3.l ? fins1 : fins3; const vitiligo4 = mkVitiligo(r, lighter); return { fins1, fins2, fins3, vitiligo4 }; } function mkBelly(r: Rand, h: Hue): BellyCols { const [belly1Hue, belly2Hue] = r.analogous(h, 2); const belly1 = 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): 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: 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)}` } } export type Rgbs = Record; 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)}`; }