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; } 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 = 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 type BaseCol = 'outer' | 'belly' | 'fins'; export type OptionalBaseCol = 'eyes' | 'stripes'; type KnownPalette = Record & Partial>; 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; 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 = { 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), }, };