refactor rainbow-quox scripting
This commit is contained in:
parent
dff263856c
commit
ddc36a9eea
10 changed files with 556 additions and 461 deletions
4
Makefile
4
Makefile
|
@ -11,7 +11,9 @@ CSS = $(shell find fonts -type f) \
|
|||
$(patsubst %.scss,%.css, \
|
||||
$(wildcard rainbow-quox/style/*) $(wildcard style/*)) \
|
||||
dnd/base.css dnd/bio.css dnd/index.css $(wildcard dnd/*/style.css)
|
||||
SCRIPTS = $(patsubst %.ts,%.js,$(wildcard script/*.ts rainbow-quox/script/*.ts))
|
||||
SCRIPTS = $(patsubst %.ts,%.js, \
|
||||
$(wildcard script/*.ts rainbow-quox/script/color/*.ts \
|
||||
rainbow-quox/script/*.ts))
|
||||
MISC = $(shell find .well-known -type f)
|
||||
ALL = $(CSS) $(PAGES) $(MEDIA) $(SCRIPTS) $(MISC)
|
||||
|
||||
|
|
|
@ -1,194 +1,12 @@
|
|||
import * as R from './rand.js';
|
||||
import { Rand } from './color/rand.js';
|
||||
import { Color, Luma, Chroma, Hue, oklch, oklab, rgb } from './color/def.js';
|
||||
|
||||
export { Rand, Color, Luma, Chroma, Hue, oklch, oklab, rgb };
|
||||
|
||||
|
||||
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';
|
||||
|
||||
|
@ -200,7 +18,7 @@ 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 ColsOf<A extends string> = Record<A, Color>;
|
||||
export type OuterCols = ColsOf<OuterLayer>;
|
||||
export type SockCols = ColsOf<SockLayer>;
|
||||
export type FinCols = ColsOf<FinLayer>;
|
||||
|
@ -223,73 +41,72 @@ export type BaseCol = 'outer' | 'belly' | 'fins';
|
|||
export type OptionalBaseCol = 'eyes' | 'stripes';
|
||||
|
||||
type KnownPalette =
|
||||
Record<BaseCol, Oklch> & Partial<Record<OptionalBaseCol, Oklch>>;
|
||||
Record<BaseCol, Color> & Partial<Record<OptionalBaseCol, Color>>;
|
||||
|
||||
|
||||
export function colors(r: Rand = new Rand(), base?: KnownPalette): Scheme {
|
||||
const outer = base?.outer ?? new Oklch(r, 'dark');
|
||||
let outerCols: OuterCols =
|
||||
const outer = base?.outer ?? r.color('dark');
|
||||
const outerCols: OuterCols =
|
||||
{ outer, spines: mkSpines(r, outer), vitiligo1: mkVitiligo(r, outer) };
|
||||
|
||||
const stripes = mkStripes(r);
|
||||
let sockCols: SockCols = { stripes, cuffs: mkCuffs(r, stripes) };
|
||||
const 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);
|
||||
const [f, b] = r.triad(outer.hue);
|
||||
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);
|
||||
const [f, b] = r.complementary(outer.hue, 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);
|
||||
finCols = mkFins(r, r.analogous1(outer.hue), outer, base);
|
||||
bellyCols = mkBelly(r, r.complementary1(outer.hue), base);
|
||||
}
|
||||
|
||||
let miscCols = mkMisc(r, outerCols, finCols, bellyCols, base);
|
||||
const 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({
|
||||
function mkSpines(r: Rand, outer: Color): Color {
|
||||
return outer.with({ type: 'oklch',
|
||||
l: l => r.darkFor(l),
|
||||
c: c => r.brightFor(outer.l, c),
|
||||
c: c => r.brightFor(outer.luma, c),
|
||||
h: h => r.float(h + 12, h - 12),
|
||||
})
|
||||
}
|
||||
|
||||
function mkVitiligo(r: Rand, outer: Oklch): Oklch {
|
||||
return outer.with({
|
||||
function mkVitiligo(r: Rand, outer: Color): Color {
|
||||
return outer.with({ type: 'oklch',
|
||||
l: x => r.float(max(x, 0.94), 0.985), // exception to MAXL
|
||||
c: x => r.float(min(x, 0.1), MINC_LIGHT),
|
||||
c: x => r.float(min(x, 0.1), r.mincLight),
|
||||
});
|
||||
}
|
||||
|
||||
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 mkStripes(r: Rand): Color {
|
||||
return oklch(r.float(0.8, r.maxl),
|
||||
r.float(r.mincLight, r.maxcLight),
|
||||
r.baseHue());
|
||||
}
|
||||
|
||||
function mkCuffs(r: Rand, sock: Oklch): Oklch {
|
||||
return sock.with({
|
||||
function mkCuffs(r: Rand, sock: Color): Color {
|
||||
return sock.with({ type: 'oklch',
|
||||
l: l => r.float(l * 0.85, l * 0.65),
|
||||
c: c => r.float(c, MAXC_LIGHT),
|
||||
c: c => r.float(c, r.maxcLight),
|
||||
h: h => r.float(h + 8, h - 8),
|
||||
});
|
||||
}
|
||||
|
||||
function mkFins(r: Rand, h: Hue, outer: Oklch, base?: KnownPalette): FinCols {
|
||||
function mkFins(r: Rand, h: Hue, outer: Color,
|
||||
base?: KnownPalette): FinCols {
|
||||
const baseFin1 = base?.fins;
|
||||
const [fin1Hue, fin2Hue, fin3Hue] = r.analogous(baseFin1?.h ?? h, 3);
|
||||
const [fin1Hue, fin2Hue, fin3Hue] = r.analogous(baseFin1?.hue ?? h, 3);
|
||||
|
||||
const direction: 'lighter' | 'darker' = r.choice(['lighter', 'darker']);
|
||||
|
||||
|
@ -300,26 +117,24 @@ function mkFins(r: Rand, h: Hue, outer: Oklch, base?: KnownPalette): FinCols {
|
|||
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 fins1 = baseFin1 ??
|
||||
oklch(ll(outer.luma), cc(outer.luma, outer.chroma), fin1Hue!);
|
||||
const fins2 = oklch(ll(fins1.luma), cc(fins1.luma, fins1.chroma), fin2Hue!);
|
||||
const fins3 = oklch(ll(fins2.luma), cc(fins2.luma, fins2.chroma), fin3Hue!);
|
||||
const lighter = fins1.luma >= fins3.luma ? 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),
|
||||
const baseBelly1 = base?.belly;
|
||||
const [belly1Hue, belly2Hue] = r.analogous(baseBelly1?.hue ?? h, 2);
|
||||
const belly1 = baseBelly1 ??
|
||||
oklch(r.float(0.7, r.maxl), r.baseChroma(1), belly1Hue!);
|
||||
const belly2 = belly1.with({ type: 'oklch',
|
||||
l: x => min(r.maxl, x * 1.1),
|
||||
c: x => x * 0.9,
|
||||
h: belly2Hue!,
|
||||
h: [belly2Hue!],
|
||||
});
|
||||
const vitiligo3 = mkVitiligo(r, belly1);
|
||||
const vitiligo2 = mkVitiligo(r, belly2);
|
||||
|
@ -328,28 +143,22 @@ function mkBelly(r: Rand, h: Hue, base?: KnownPalette): BellyCols {
|
|||
|
||||
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)
|
||||
});
|
||||
const masks = oklch(r.float(0.8, r.maxl), r.float(0.01, 0.06),
|
||||
r.analogous1(r.choice([o.outer, b.belly1, f.fins1]).hue));
|
||||
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),
|
||||
eyes: base?.eyes ?? oklch(
|
||||
r.baseLuma('light'),
|
||||
r.float(0.28, r.maxcLight),
|
||||
r.boolean() ? r.analogous1(o.outer.hue) : r.complementary1(o.outer.hue)
|
||||
),
|
||||
claws: masks.with({ type: 'oklch',
|
||||
l: x => min(r.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)
|
||||
}),
|
||||
lines: oklch(r.float(0.01, 0.06), r.baseChroma(0),
|
||||
r.analogous1(o.outer.hue)),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -366,82 +175,6 @@ function merge({ outer, spines, vitiligo1 }: OuterCols,
|
|||
}
|
||||
|
||||
|
||||
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),
|
||||
|
|
126
rainbow-quox/script/color/conv.ts
Normal file
126
rainbow-quox/script/color/conv.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
export type Oklch = { type: 'oklch', l: number, c: number, h: number };
|
||||
export type Oklab = { type: 'oklab', l: number, a: number, b: number };
|
||||
export type Srgb = { type: 'srgb', r: number, g: number, b: number };
|
||||
export type Lrgb = { type: 'lrgb', r: number, g: number, b: number };
|
||||
|
||||
export type ColorData = Oklch | Oklab | Srgb;
|
||||
|
||||
export type ColorType = 'oklch' | 'oklab' | 'srgb';
|
||||
|
||||
|
||||
type Deg = number;
|
||||
type Rad = number;
|
||||
|
||||
function deg2rad(θ: Deg): Rad { return θ / 180 * Math.PI; }
|
||||
function rad2deg(θ: Rad): Deg { return θ * 180 / Math.PI; }
|
||||
function dcos(θ: Deg): number { return Math.cos(deg2rad(θ)); }
|
||||
function dsin(θ: Deg): number { return Math.sin(deg2rad(θ)); }
|
||||
function datan2(b: number, a: number): Deg { return rad2deg(Math.atan2(b, a)); }
|
||||
|
||||
export function oklch2oklab({ l, c, h }: Oklch): Oklab {
|
||||
return { type: 'oklab', l, a: c * dcos(h), b: c * dsin(h) };
|
||||
}
|
||||
|
||||
export function oklab2oklch({ l, a, b }: Oklab): Oklch {
|
||||
return { type: 'oklch', l, c: Math.sqrt(a*a + b*b), h: datan2(b, a) }
|
||||
}
|
||||
|
||||
// https://bottosson.github.io/posts/oklab/#converting-from-linear-srgb-to-oklab
|
||||
export function lrgb2oklab({ r, g, b }: Lrgb): Oklab {
|
||||
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
||||
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
||||
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
||||
|
||||
const l_ = Math.cbrt(l);
|
||||
const m_ = Math.cbrt(m);
|
||||
const s_ = Math.cbrt(s);
|
||||
|
||||
return {
|
||||
type: 'oklab',
|
||||
l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
|
||||
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
|
||||
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
|
||||
};
|
||||
}
|
||||
|
||||
export function oklab2lrgb({ l, a, b }: Oklab): Lrgb {
|
||||
const L_ = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const M_ = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const S_ = l - 0.0894841775 * a - 1.2914855480 * b;
|
||||
|
||||
const L = L_*L_*L_;
|
||||
const M = M_*M_*M_;
|
||||
const S = S_*S_*S_;
|
||||
|
||||
return {
|
||||
type: 'lrgb',
|
||||
r: clamp(+4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S),
|
||||
g: clamp(-1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S),
|
||||
b: clamp(-0.0041960863 * L - 0.7034186147 * M + 1.7076147010 * S),
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(x: number): number {
|
||||
return Math.max(0, Math.min(1, x));
|
||||
}
|
||||
|
||||
// https://bottosson.github.io/posts/colorwrong/#what-can-we-do%3F
|
||||
function γ(x: number): number {
|
||||
return x >= 0.0031308 ? 1.055 * x ** (1/2.4) - 0.055
|
||||
: 12.92 * x;
|
||||
}
|
||||
|
||||
function γ̂(x: number): number {
|
||||
return x >= 0.04045 ? ((x + 0.055)/1.055) ** 2.4
|
||||
: x / 12.92;
|
||||
}
|
||||
|
||||
export function lrgb2srgb({ r, g, b }: Lrgb): Srgb {
|
||||
return { type: 'srgb', r: γ(r), g: γ(g), b: γ(b) };
|
||||
}
|
||||
|
||||
export function srgb2lrgb({ r, g, b }: Srgb): Lrgb {
|
||||
return { type: 'lrgb', r: γ̂(r), g: γ̂(g), b: γ̂(b) };
|
||||
}
|
||||
|
||||
|
||||
export function oklab2srgb(c: Oklab): Srgb {
|
||||
return lrgb2srgb(oklab2lrgb(c));
|
||||
}
|
||||
|
||||
export function oklch2srgb(c: Oklch): Srgb {
|
||||
return oklab2srgb(oklch2oklab(c));
|
||||
}
|
||||
|
||||
export function srgb2oklab(c: Srgb): Oklab {
|
||||
return lrgb2oklab(srgb2lrgb(c));
|
||||
}
|
||||
|
||||
export function srgb2oklch(c: Srgb): Oklch {
|
||||
return oklab2oklch(srgb2oklab(c));
|
||||
}
|
||||
|
||||
|
||||
export function toOklch(c: ColorData): Oklch {
|
||||
switch (c.type) {
|
||||
case 'oklch': return c;
|
||||
case 'oklab': return oklab2oklch(c);
|
||||
case 'srgb': return srgb2oklch(c);
|
||||
}
|
||||
}
|
||||
|
||||
export function toOklab(c: ColorData): Oklab {
|
||||
switch (c.type) {
|
||||
case 'oklch': return oklch2oklab(c);
|
||||
case 'oklab': return c;
|
||||
case 'srgb': return srgb2oklab(c);
|
||||
}
|
||||
}
|
||||
|
||||
export function toSrgb(c: ColorData): Srgb {
|
||||
switch (c.type) {
|
||||
case 'oklch': return oklch2srgb(c);
|
||||
case 'oklab': return oklab2srgb(c);
|
||||
case 'srgb': return c;
|
||||
}
|
||||
}
|
136
rainbow-quox/script/color/def.ts
Normal file
136
rainbow-quox/script/color/def.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
import { Oklch, Oklab, Srgb, ColorType, ColorData } from './conv.js';
|
||||
import * as Conv from './conv.js';
|
||||
export { Oklch, Oklab, Srgb, ColorType, ColorData };
|
||||
|
||||
|
||||
export type CSSFormat = 'oklch' | 'oklab' | 'rgb' | 'hex';
|
||||
|
||||
export type ChannelMapper<A> = [A] | ((x: A) => A);
|
||||
|
||||
export type ColorMapper<C extends { type: string }> =
|
||||
{ type: C['type'] } &
|
||||
{ [k in Exclude<keyof C, 'type'>]?: ChannelMapper<C[k]> };
|
||||
|
||||
export function apply<A>(f: ChannelMapper<A> | undefined, x: A): A {
|
||||
if (typeof f == 'undefined') return x;
|
||||
else if (typeof f == 'function') return f(x);
|
||||
else return f[0];
|
||||
}
|
||||
|
||||
export class Color {
|
||||
readonly oklch: Oklch;
|
||||
readonly oklab: Oklab;
|
||||
readonly srgb: Srgb;
|
||||
|
||||
constructor(c: ColorData) {
|
||||
this.oklch = Conv.toOklch(c);
|
||||
this.oklab = Conv.toOklab(c);
|
||||
this.srgb = Conv.toSrgb(c);
|
||||
}
|
||||
|
||||
get luma() { return this.oklch.l; }
|
||||
get chroma() { return this.oklch.c; }
|
||||
get hue() { return this.oklch.h; }
|
||||
get labA() { return this.oklab.a; }
|
||||
get labB() { return this.oklab.b; }
|
||||
get red() { return Math.floor(255 * this.srgb.r); }
|
||||
get green() { return Math.floor(255 * this.srgb.g); }
|
||||
get blue() { return Math.floor(255 * this.srgb.b); }
|
||||
|
||||
css(format: CSSFormat = 'hex', α = 1): string {
|
||||
switch (format) {
|
||||
case 'oklch': {
|
||||
const { l, c, h } = this.oklch;
|
||||
return `oklch(${pc(l)} ${pc(c, 0.4)} ${deg(h)} / ${pc(α)})`;
|
||||
}
|
||||
case 'oklab': {
|
||||
const { l, a, b } = this.oklab;
|
||||
return `oklab(${pc(l)} ${pc(a)} ${pc(b)} / ${pc(α)})`;
|
||||
}
|
||||
case 'rgb': {
|
||||
const { r, g, b } = this.srgb;
|
||||
return `rgb(${pc(r)} ${pc(g)} ${pc(b)} / ${pc(α)})`;
|
||||
}
|
||||
case 'hex': {
|
||||
const { r, g, b } = this.srgb;
|
||||
return α == 1 ? `#${hex(r)}${hex(g)}${hex(b)}` :
|
||||
`#${hex(r)}${hex(g)}${hex(b)}${hex(α)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function hex(c: number) {
|
||||
const d = Math.min(255, Math.max(0, Math.floor(255 * c)));
|
||||
const str = d.toString(16);
|
||||
return str.length == 1 ? `0${str}` : str;
|
||||
}
|
||||
|
||||
function pc(c: number, max = 1) {
|
||||
c *= 100 / max;
|
||||
return `${c.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function deg(θ: number) {
|
||||
θ %= 360; if (θ < 0) θ += 360;
|
||||
return `${θ.toFixed(0)}deg`;
|
||||
}
|
||||
}
|
||||
|
||||
with(maps: ColorMapper<Oklch>): Color;
|
||||
with(maps: ColorMapper<Srgb>): Color;
|
||||
with(maps: ColorMapper<Oklch | Srgb>): Color {
|
||||
switch (maps.type) {
|
||||
case 'oklch': {
|
||||
const { l, c, h } = this.oklch;
|
||||
const { l: ll, c: cc, h: hh } = maps as ColorMapper<Oklch>;
|
||||
return oklch(apply(ll, l), apply(cc, c), apply(hh, h));
|
||||
}
|
||||
case 'srgb': {
|
||||
const { r, g, b } = this.srgb;
|
||||
const { r: rr, g: gg, b: bb } = maps as ColorMapper<Srgb>;
|
||||
return rgb(apply(rr, r), apply(gg, g), apply(bb, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static validate(x: unknown): Color | undefined {
|
||||
if (typeof x == 'object' && x != null) {
|
||||
if ('l' in x && 'c' in x && 'h' in x) {
|
||||
const { l, c, h } = x;
|
||||
if (num(l) && num(c) && num(h)) return oklch(l, c, h);
|
||||
} else if ('l' in x && 'a' in x && 'b' in x) {
|
||||
const { l, a, b } = x;
|
||||
if (num(l) && num(a) && num(b)) return oklab(l, a, b);
|
||||
} else if ('r' in x && 'g' in x && 'b' in x) {
|
||||
const { r, g, b } = x;
|
||||
if (num(r) && num(g) && num(b)) return rgb(r, g, b);
|
||||
}
|
||||
}
|
||||
function num(x: unknown): x is number { return typeof x == 'number'; }
|
||||
}
|
||||
|
||||
toJSON(): unknown { const { l, c, h } = this.oklch; return { l, c, h }; }
|
||||
}
|
||||
|
||||
export function oklch(l: number, c: number, h: number): Color {
|
||||
return new Color({ type: 'oklch', l, c, h });
|
||||
}
|
||||
|
||||
export function oklab(l: number, a: number, b: number): Color {
|
||||
return new Color({ type: 'oklab', l, a, b });
|
||||
}
|
||||
|
||||
export function rgb(r: number, g: number, b: number,
|
||||
style: 'int' | 'float' = 'int'): Color {
|
||||
return style == 'int' ?
|
||||
new Color({ type: 'srgb', r: r / 255, g: g / 255, b: b / 255 }) :
|
||||
new Color({ type: 'srgb', r, g, b });
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type Luma = number;
|
||||
export type Chroma = number;
|
||||
export type Hue = number;
|
||||
export type Alpha = number;
|
||||
|
||||
export type HueDistance = number;
|
120
rainbow-quox/script/color/rand.ts
Normal file
120
rainbow-quox/script/color/rand.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
import * as R from '../rand.js';
|
||||
import * as Color from './def.js';
|
||||
|
||||
const max = Math.max;
|
||||
const min = Math.min;
|
||||
|
||||
|
||||
export type State = R.State;
|
||||
|
||||
|
||||
export type CloseFar = 'close' | 'far';
|
||||
export type LightDark = 'light' | 'dark';
|
||||
|
||||
export class Rand extends R.Rand {
|
||||
maxl: Color.Luma = 0.9;
|
||||
minl: Color.Luma = 0.4;
|
||||
minlLight: Color.Luma = 0.7;
|
||||
maxlDark: Color.Luma = 0.65;
|
||||
|
||||
mincLight: Color.Chroma = 0.08;
|
||||
maxcLight: Color.Chroma = 0.1;
|
||||
mincDark: Color.Chroma = 0.12;
|
||||
maxcDark: Color.Chroma = 0.175;
|
||||
|
||||
// max spread for a sequence of analogous colors. unless that would put them
|
||||
// too close together
|
||||
maxhWidth: Color.HueDistance = 80;
|
||||
|
||||
// minimum distance between adjacent analogous colors
|
||||
minhSep: Color.HueDistance = 5;
|
||||
|
||||
// size of the wedge a "complementary" color can be in
|
||||
maxhCompl: Color.HueDistance = 40;
|
||||
|
||||
// size of the wedge a "triadic" color can be in
|
||||
maxhTriad: Color.HueDistance = 25;
|
||||
|
||||
constructor();
|
||||
constructor([a, b, c, d]: State);
|
||||
constructor(str: string);
|
||||
constructor(st?: State | string) {
|
||||
if (st === undefined) super();
|
||||
else if (typeof st === 'string') super(st);
|
||||
else super(st);
|
||||
}
|
||||
|
||||
isLight(l: Color.Luma): boolean { return l >= this.minlLight; }
|
||||
|
||||
lightFor(baseL: Color.Luma, d: CloseFar = 'close'): Color.Luma {
|
||||
const maxl = d == 'close' ? min(this.maxl, baseL * 1.25) : this.maxl;
|
||||
return this.float(baseL, maxl);
|
||||
}
|
||||
darkFor(baseL: Color.Luma, d: CloseFar = 'close'): Color.Luma {
|
||||
const minl = d == 'close' ? max(this.minl, baseL * 0.8) : this.minl
|
||||
return this.float(minl, baseL);
|
||||
}
|
||||
|
||||
brightFor(l: Color.Luma, baseC: Color.Chroma): Color.Chroma {
|
||||
return this.float(baseC, this.isLight(l) ? this.maxcLight : this.maxcDark);
|
||||
}
|
||||
|
||||
dullFor(l: Color.Luma, baseC: Color.Chroma): Color.Chroma {
|
||||
return this.float(baseC, this.isLight(l) ? this.mincLight : this.mincDark);
|
||||
}
|
||||
|
||||
analogous1(baseH: Color.Hue): Color.Hue {
|
||||
const size = this.float(this.minhSep, 2 * this.minhSep);
|
||||
return this.boolean() ? baseH + size : baseH - size;
|
||||
}
|
||||
|
||||
analogous(baseH: Color.Hue, count: number): Color.Hue[] {
|
||||
const minWidth = min(count * this.minhSep, this.maxhWidth * 0.8);
|
||||
const width = this.float(minWidth, this.maxhWidth);
|
||||
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: Color.Hue): Color.Hue {
|
||||
return this.analogous1((baseH + 180) % 360);
|
||||
}
|
||||
|
||||
complementary(baseH: Color.Hue, count: number): Color.Hue[] {
|
||||
const angle = this.float(180 - this.maxhCompl/2, 180 + this.maxhCompl/2);
|
||||
return this.analogous(baseH + angle, count);
|
||||
}
|
||||
|
||||
triad(baseH: Color.Hue): [Color.Hue, Color.Hue] {
|
||||
const angle = this.float(120 - this.maxhTriad/2, 120 + this.maxhTriad/2);
|
||||
return [baseH - angle, baseH + angle];
|
||||
}
|
||||
|
||||
baseLuma(ld?: LightDark): Color.Luma {
|
||||
if (ld == 'light') {
|
||||
return this.float(this.minlLight, this.maxl);
|
||||
} else if (ld == 'dark') {
|
||||
return this.float(this.minl, this.maxlDark);
|
||||
} else {
|
||||
return this.float(this.minl, this.maxl);
|
||||
}
|
||||
}
|
||||
|
||||
baseChroma(l: Color.Luma): Color.Chroma {
|
||||
if (l >= this.minlLight) {
|
||||
return this.float(this.mincLight, this.maxcLight);
|
||||
} else {
|
||||
return this.float(this.mincDark, this.maxcDark);
|
||||
}
|
||||
}
|
||||
|
||||
baseHue(): Color.Hue { return this.float(360); }
|
||||
|
||||
color(ld?: LightDark): Color.Color {
|
||||
const l = this.baseLuma(ld);
|
||||
const c = this.baseChroma(l);
|
||||
const h = this.baseHue();
|
||||
return Color.oklch(l, c, h);
|
||||
}
|
||||
}
|
|
@ -1,19 +1,17 @@
|
|||
import { Colors as Oklchs, Rgbs } from './color.js';
|
||||
import { Colors } from './color.js';
|
||||
import * as Color from './color.js';
|
||||
|
||||
export class HistoryItem {
|
||||
name: string;
|
||||
oklch: Oklchs;
|
||||
rgb: Rgbs;
|
||||
name: string;
|
||||
cols: Colors;
|
||||
|
||||
constructor(name: string, oklch: Oklchs, rgb: Rgbs) {
|
||||
this.oklch = oklch;
|
||||
this.rgb = rgb;
|
||||
constructor(name: string, cols: Colors) {
|
||||
this.cols = cols;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
asHtml(): HTMLButtonElement {
|
||||
const { lines, outer, belly1: belly, fins1: fins } = this.rgb;
|
||||
const { lines, outer, belly1: belly, fins1: fins } = this.cols;
|
||||
|
||||
const content = `
|
||||
<svg class=history-colors width=30 height=25 viewBox="-10 -10 140 120">
|
||||
|
@ -37,7 +35,7 @@ export class HistoryItem {
|
|||
<span class=history-name>${this.name}</span>
|
||||
`;
|
||||
|
||||
let button = document.createElement('button');
|
||||
const button = document.createElement('button');
|
||||
button.className = 'history-item';
|
||||
button.dataset.name = this.name;
|
||||
button.innerHTML = content;
|
||||
|
@ -54,7 +52,7 @@ export class History {
|
|||
add(name: string): void { this.items.push(name); }
|
||||
|
||||
*iterNames(maxLength: number = 100): Iterable<string> {
|
||||
let seen = new Set<string>;
|
||||
const seen = new Set<string>;
|
||||
let done = 0;
|
||||
|
||||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||||
|
@ -71,10 +69,9 @@ export class History {
|
|||
// pass a negative number to iterate over all
|
||||
*iterItems(maxLength?: number): Iterable<HistoryItem> {
|
||||
for (const name of this.iterNames(maxLength)) {
|
||||
const oklch = Color.colors(new Color.Rand(name), Color.KNOWN[name]);
|
||||
const rgbs = Color.toRgbs(oklch);
|
||||
const cols = Color.colors(new Color.Rand(name), Color.KNOWN[name]);
|
||||
|
||||
yield new HistoryItem(name, oklch, rgbs);
|
||||
yield new HistoryItem(name, cols);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -107,8 +104,8 @@ export class History {
|
|||
}
|
||||
|
||||
prune(maxLength?: number): void {
|
||||
let keep = [];
|
||||
for (let name of this.iterNames(maxLength)) keep.push(name);
|
||||
const keep = [];
|
||||
for (const name of this.iterNames(maxLength)) keep.push(name);
|
||||
this.items = keep.reverse();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,14 +4,18 @@ async function loadBitmap(url: string): Promise<ImageBitmap> {
|
|||
const img0 = new Image;
|
||||
const img: Promise<ImageBitmapSource> = new Promise((ok, err) => {
|
||||
img0.addEventListener('load', () => ok(img0));
|
||||
img0.addEventListener('error', () => err(`couldn't load file: ${url}`));
|
||||
img0.addEventListener('error', () => {
|
||||
err(new Error(`couldn't load file: ${url}`));
|
||||
});
|
||||
});
|
||||
img0.src = url;
|
||||
return createImageBitmap(await img);
|
||||
}
|
||||
|
||||
|
||||
export type Buffer = OffscreenCanvasRenderingContext2D;
|
||||
export type Buffer =
|
||||
CanvasCompositing & CanvasDrawImage & CanvasImageData &
|
||||
CanvasRect & CanvasState;
|
||||
|
||||
function dataViaBuffer(bmp: ImageBitmap, buf: Buffer): ImageData {
|
||||
buf.clearRect(0, 0, bmp.width, bmp.height);
|
||||
|
@ -19,34 +23,10 @@ function dataViaBuffer(bmp: ImageBitmap, buf: Buffer): ImageData {
|
|||
return buf.getImageData(0, 0, bmp.width, bmp.height);
|
||||
}
|
||||
|
||||
async function loadDataLocking(url: string, buf: Buffer): Promise<ImageData> {
|
||||
return loadBitmap(url).then(i =>
|
||||
navigator.locks.request('imagebuf', () => dataViaBuffer(i, buf)));
|
||||
}
|
||||
|
||||
async function loadDataFresh(url: string): Promise<ImageData> {
|
||||
const img = await loadBitmap(url);
|
||||
let buf = new OffscreenCanvas(img.width, img.height).getContext('2d')!;
|
||||
return dataViaBuffer(img, buf);
|
||||
}
|
||||
|
||||
export function loadImageData(url: string, buf?: Buffer): Promise<ImageData> {
|
||||
if (buf && navigator.locks) return loadDataLocking(url, buf);
|
||||
else return loadDataFresh(url);
|
||||
}
|
||||
|
||||
|
||||
export const WIDTH = 1040;
|
||||
export const HEIGHT = 713;
|
||||
|
||||
export function makeBuffer(width = WIDTH, height = HEIGHT): Buffer {
|
||||
return new OffscreenCanvas(width, height).getContext('2d')!;
|
||||
}
|
||||
|
||||
function makeBufferIfLocks(width?: number, height?: number): Buffer | undefined {
|
||||
if (navigator.locks) return makeBuffer(width, height);
|
||||
}
|
||||
|
||||
export type Layer = 'stroke' | 'static' | 'eyeshine' | Color.Layer;
|
||||
|
||||
// in compositing order
|
||||
|
@ -61,14 +41,16 @@ export function makeLayerInfo<A>(f: (l: Layer) => A): Record<Layer, A> {
|
|||
|
||||
export async function makeLayerInfoAsync<A>(f: (l: Layer) => Promise<A>):
|
||||
Promise<Record<Layer, A>> {
|
||||
let list = await Promise.all(allLayers.map(l => f(l).then(res => [l, res])));
|
||||
return Object.fromEntries(list);
|
||||
const list = await Promise.all(allLayers.map(l => f(l).then(res => [l, res])));
|
||||
return Object.fromEntries(list) as Promise<Record<Layer, A>>;
|
||||
}
|
||||
|
||||
|
||||
export function loadLayers(dir: string): Promise<Record<Layer, ImageData>> {
|
||||
let buf = makeBufferIfLocks(WIDTH, HEIGHT);
|
||||
return makeLayerInfoAsync(l => loadImageData(`./${dir}/${l}.webp`, buf));
|
||||
export async function
|
||||
loadLayers(dir: string, buf: Buffer): Promise<Record<Layer, ImageData>> {
|
||||
const bitmaps =
|
||||
await makeLayerInfoAsync(l => loadBitmap(`./${dir}/${l}.webp`));
|
||||
return makeLayerInfo(l => dataViaBuffer(bitmaps[l], buf));
|
||||
}
|
||||
|
||||
|
||||
|
@ -76,7 +58,7 @@ export type Position = [x: number, y: number];
|
|||
export type Positions = Record<Layer, Position>;
|
||||
|
||||
export async function loadPos(dir: string): Promise<Positions> {
|
||||
return (await fetch(`./${dir}/pos.json`)).json();
|
||||
return (await fetch(`./${dir}/pos.json`)).json() as Promise<Positions>;
|
||||
}
|
||||
|
||||
|
||||
|
@ -95,10 +77,10 @@ export type Data = {
|
|||
|
||||
export type ComposedData = Required<Data>;
|
||||
|
||||
export async function loadData(): Promise<Data> {
|
||||
let [fl, fp, bl, bp] = await Promise.all([
|
||||
loadLayers('front'), loadPos('front'),
|
||||
loadLayers('back'), loadPos('back')
|
||||
export async function loadData(buf: Buffer): Promise<Data> {
|
||||
const [fl, fp, bl, bp] = await Promise.all([
|
||||
loadLayers('front', buf), loadPos('front'),
|
||||
loadLayers('back', buf), loadPos('back')
|
||||
]);
|
||||
return {
|
||||
front: makeLayerInfo(l => [fl[l], fp[l]]),
|
||||
|
@ -107,13 +89,13 @@ export async function loadData(): Promise<Data> {
|
|||
}
|
||||
|
||||
|
||||
function recolor({ data }: ImageData, { r, g, b }: Color.Rgb) {
|
||||
function recolor({ data }: ImageData, col: Color.Color) {
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = r; data[i+1] = g; data[i+2] = b;
|
||||
data[i] = col.red; data[i+1] = col.green; data[i+2] = col.blue;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recolorAll(layers: Data, cols: Color.Rgbs) {
|
||||
export async function recolorAll(layers: Data, cols: Color.Colors) {
|
||||
await Promise.all(Color.allLayers.map(l => {
|
||||
recolor(layers.front[l][0], cols[l]);
|
||||
recolor(layers.back[l][0], cols[l]);
|
||||
|
@ -139,7 +121,7 @@ async function compose(buf: Buffer, layers: ComposeLayer[],
|
|||
|
||||
export async function
|
||||
ensureComposed(buf: Buffer, data: Data): Promise<ComposedData> {
|
||||
let { front, back } = data;
|
||||
const { front, back } = data;
|
||||
data.frontImage ??= await composeLayers(front);
|
||||
data.backImage ??= await composeLayers(back);
|
||||
return data as ComposedData;
|
||||
|
@ -148,14 +130,12 @@ ensureComposed(buf: Buffer, data: Data): Promise<ComposedData> {
|
|||
return compose(buf, allLayers.map(l => makeLayer(l, sdata)), WIDTH, HEIGHT);
|
||||
}
|
||||
function makeLayer(l: Layer, sdata: SideData): ComposeLayer {
|
||||
let [i, p] = sdata[l];
|
||||
const [i, p] = sdata[l];
|
||||
return [i, p, l == 'eyeshine' ? 'luminosity' : 'source-over'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function redraw(ctx: CanvasRenderingContext2D,
|
||||
buf: Buffer, data: ComposedData, side: Side) {
|
||||
await ensureComposed(buf, data);
|
||||
export function redraw(ctx: CanvasImageData, data: ComposedData, side: Side) {
|
||||
ctx.putImageData(data[`${side}Image`], 0, 0);
|
||||
}
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
import { Rgb, Rgbs, rgb } from './color.js';
|
||||
import { Color, Colors, rgb } from './color.js';
|
||||
import { Layer } from './layer.js';
|
||||
|
||||
export type Color =
|
||||
export type Swatch =
|
||||
Exclude<Layer, 'eyeshine' | 'stroke' | 'static'>
|
||||
| 'collars' | 'bells' | 'tongues' | 'socks' | 'sclera';
|
||||
|
||||
// in palette order
|
||||
export const COLORS: Color[] =
|
||||
['lines', 'outer', 'vitiligo1', 'spines', 'fins1', 'fins2', 'fins3',
|
||||
'vitiligo4', 'belly1', 'vitiligo3', 'belly2', 'vitiligo2', 'sclera',
|
||||
'eyes', 'tongues', 'masks', 'claws', 'socks', 'stripes', 'cuffs',
|
||||
'collars', 'bells'];
|
||||
export const SWATCHES: Swatch[] =
|
||||
['lines', 'outer', 'vitiligo1', 'spines',
|
||||
'socks', 'stripes', 'cuffs',
|
||||
'vitiligo4', 'belly1', 'vitiligo3', 'belly2', 'vitiligo2',
|
||||
'fins1', 'fins2', 'fins3', 'masks', 'claws',
|
||||
'sclera', 'eyes', 'tongues', 'collars', 'bells'];
|
||||
|
||||
export const NAMES: Partial<Record<Color, string>> = {
|
||||
export const NAMES: Partial<Record<Swatch, string>> = {
|
||||
outer: 'outer body',
|
||||
stripes: 'sock stripes',
|
||||
cuffs: 'sock cuffs',
|
||||
|
@ -27,13 +28,13 @@ export const NAMES: Partial<Record<Color, string>> = {
|
|||
vitiligo4: 'fins vitiligo',
|
||||
};
|
||||
|
||||
export function name(l: Color): string {
|
||||
export function name(l: Swatch): string {
|
||||
return NAMES[l] ?? l;
|
||||
}
|
||||
|
||||
export type StaticColor = Exclude<Color, Layer>;
|
||||
export type StaticColor = Exclude<Swatch, Layer>;
|
||||
|
||||
export const STATIC_COLS: Record<StaticColor, Rgb> = {
|
||||
export const STATIC_COLS: Record<StaticColor, Color> = {
|
||||
collars: rgb(206, 75, 101),
|
||||
bells: rgb(235, 178, 79),
|
||||
tongues: rgb(222, 165, 184),
|
||||
|
@ -41,23 +42,23 @@ export const STATIC_COLS: Record<StaticColor, Rgb> = {
|
|||
sclera: rgb(238, 239, 228),
|
||||
};
|
||||
|
||||
export function get(col: Color, palette: Rgbs): Rgb {
|
||||
type PPalette = Partial<Record<Color, Rgb>>;
|
||||
let p = palette as PPalette;
|
||||
let s = STATIC_COLS as PPalette;
|
||||
export function get(col: Swatch, palette: Colors): Color {
|
||||
type PPalette = Partial<Record<Swatch, Color>>;
|
||||
const p = palette as PPalette;
|
||||
const s = STATIC_COLS as PPalette;
|
||||
return (p[col] ?? s[col])!;
|
||||
}
|
||||
|
||||
export function make(seed: string, palette: Rgbs): Blob {
|
||||
let lines = [
|
||||
export function make(seed: string, palette: Colors): Blob {
|
||||
const lines = [
|
||||
"GIMP Palette\n",
|
||||
`Name: quox ${seed}\n`,
|
||||
"Columns: 6\n\n",
|
||||
];
|
||||
|
||||
for (const col of COLORS) {
|
||||
let { r, g, b } = get(col, palette);
|
||||
lines.push(`${r} ${g} ${b} ${name(col)}\n`);
|
||||
for (const sw of SWATCHES) {
|
||||
const col = get(sw, palette);
|
||||
lines.push(`${col.red} ${col.green} ${col.blue} ${name(sw)}\n`);
|
||||
}
|
||||
|
||||
return new Blob(lines, { type: 'application/x-gimp-palette' });
|
||||
|
|
|
@ -16,7 +16,7 @@ function message(msg: string, size = 100) {
|
|||
|
||||
|
||||
function urlState(): string | undefined {
|
||||
let hash = document.location.hash?.substring(1);
|
||||
const hash = document.location.hash?.substring(1);
|
||||
if (hash != '' && hash !== undefined) return decodeURI(hash);
|
||||
}
|
||||
|
||||
|
@ -28,37 +28,35 @@ type ApplyStateOpts = {
|
|||
seed: string,
|
||||
side?: Layer.Side,
|
||||
firstLoad?: boolean,
|
||||
buf?: Layer.Buffer,
|
||||
history?: History,
|
||||
done?: Done,
|
||||
};
|
||||
|
||||
async function
|
||||
applyState(data: Layer.Data, opts: ApplyStateOpts): Promise<string> {
|
||||
let { side, seed, firstLoad, buf, history, done } = opts;
|
||||
const { seed, history } = opts;
|
||||
let { side, firstLoad, done } = opts;
|
||||
side ??= 'front';
|
||||
firstLoad ??= false;
|
||||
buf ??= Layer.makeBuffer();
|
||||
done ??= () => {};
|
||||
|
||||
let rand = new Color.Rand(seed);
|
||||
const rand = new Color.Rand(seed);
|
||||
|
||||
const oklch = Color.colors(rand, Color.KNOWN[seed]);
|
||||
const rgb = Color.toRgbs(oklch);
|
||||
const cols = Color.colors(rand, Color.KNOWN[seed]);
|
||||
const newSeed = rand.alphaNum();
|
||||
|
||||
await Layer.recolorAll(data, rgb);
|
||||
await Layer.recolorAll(data, cols);
|
||||
|
||||
updateBg(oklch);
|
||||
updateSvgs(oklch, rgb);
|
||||
updateBg(cols);
|
||||
updateSvgs(cols);
|
||||
updateLabel(seed);
|
||||
updateUrl(seed);
|
||||
|
||||
if (firstLoad) {
|
||||
await instantUpdateImage(side, await Layer.ensureComposed(buf, data));
|
||||
await instantUpdateImage(side, data);
|
||||
done();
|
||||
} else {
|
||||
await animateUpdateImage(buf, side, data, done);
|
||||
await animateUpdateImage(side, data, done);
|
||||
}
|
||||
|
||||
if (history) history.addSave(seed);
|
||||
|
@ -74,8 +72,9 @@ function getCanvasCtx(id: CanvasId) {
|
|||
}
|
||||
|
||||
async function
|
||||
instantUpdateImage(side: Layer.Side, data: Layer.ComposedData) {
|
||||
getCanvasCtx('main').putImageData(data[`${side}Image`], 0, 0);
|
||||
instantUpdateImage(side: Layer.Side, data: Layer.Data) {
|
||||
const cdata = await Layer.ensureComposed(getCanvasCtx('aux'), data);
|
||||
getCanvasCtx('main').putImageData(cdata[`${side}Image`], 0, 0);
|
||||
}
|
||||
|
||||
type Done = () => void;
|
||||
|
@ -83,10 +82,9 @@ type Done = () => void;
|
|||
const noAnim = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
async function
|
||||
animateUpdateImage(buf: Layer.Buffer, side: Layer.Side,
|
||||
data: Layer.Data, done: Done) {
|
||||
animateUpdateImage(side: Layer.Side, data: Layer.Data, done: Done) {
|
||||
if (noAnim.matches) {
|
||||
instantUpdateImage(side, await Layer.ensureComposed(buf, data));
|
||||
await instantUpdateImage(side, data);
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
@ -97,11 +95,11 @@ animateUpdateImage(buf: Layer.Buffer, side: Layer.Side,
|
|||
const aux = getCanvasCtx('aux');
|
||||
|
||||
document.documentElement.dataset.running = 'reroll';
|
||||
const cdata = await Layer.ensureComposed(buf, data);
|
||||
Layer.redraw(aux, buf, cdata, side);
|
||||
const cdata = await Layer.ensureComposed(aux, data);
|
||||
Layer.redraw(aux, cdata, side);
|
||||
|
||||
aux.canvas.addEventListener('animationend', async () => {
|
||||
await Layer.redraw(main, buf, cdata, side);
|
||||
aux.canvas.addEventListener('animationend', () => {
|
||||
Layer.redraw(main, cdata, side);
|
||||
aux.canvas.style.removeProperty('animation');
|
||||
delete document.documentElement.dataset.running;
|
||||
done();
|
||||
|
@ -113,10 +111,9 @@ animateUpdateImage(buf: Layer.Buffer, side: Layer.Side,
|
|||
}
|
||||
|
||||
async function
|
||||
animateSwapImage(buf: Layer.Buffer, newSide: Layer.Side,
|
||||
data: Layer.ComposedData, done: Done) {
|
||||
animateSwapImage(newSide: Layer.Side, data: Layer.ComposedData, done: Done) {
|
||||
if (noAnim.matches) {
|
||||
instantUpdateImage(newSide, data);
|
||||
await instantUpdateImage(newSide, data);
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
@ -127,9 +124,9 @@ animateSwapImage(buf: Layer.Buffer, newSide: Layer.Side,
|
|||
const aux = getCanvasCtx('aux');
|
||||
|
||||
document.documentElement.dataset.running = 'swap';
|
||||
await Layer.redraw(aux, buf, data, newSide);
|
||||
Layer.redraw(aux, data, newSide);
|
||||
|
||||
aux.canvas.addEventListener('animationend', async () => {
|
||||
aux.canvas.addEventListener('animationend', () => {
|
||||
const image = aux.getImageData(0, 0, Layer.WIDTH, Layer.HEIGHT);
|
||||
main.putImageData(image, 0, 0);
|
||||
|
||||
|
@ -144,7 +141,7 @@ animateSwapImage(buf: Layer.Buffer, newSide: Layer.Side,
|
|||
}
|
||||
|
||||
function updateBg(cols: Color.Colors) {
|
||||
document.documentElement.style.setProperty('--hue', `${cols.outer.h}`);
|
||||
document.documentElement.style.setProperty('--hue', `${cols.outer.hue}`);
|
||||
}
|
||||
|
||||
function updateLabel(seed: string) {
|
||||
|
@ -152,21 +149,21 @@ function updateLabel(seed: string) {
|
|||
if (stateLabel) stateLabel.innerHTML = seed;
|
||||
}
|
||||
|
||||
function updateSvgs(oklch: Color.Colors, rgb: Color.Rgbs) {
|
||||
function updateSvgs(cols: Color.Colors) {
|
||||
const paletteObj = document.getElementById('palette') as HTMLObjectElement;
|
||||
const palette = paletteObj.contentDocument as XMLDocument | null;
|
||||
|
||||
if (palette) {
|
||||
palette.documentElement.style.setProperty('--hue', `${oklch.outer.h}`);
|
||||
palette.documentElement.style.setProperty('--hue', `${cols.outer.hue}`);
|
||||
const get = (id: string) => palette.getElementById(id);
|
||||
|
||||
for (const layer of Color.allLayers) {
|
||||
let col = rgb[layer].css();
|
||||
const col = cols[layer].css();
|
||||
let elem;
|
||||
|
||||
// main group
|
||||
if (elem = get(`i-${layer}`)) {
|
||||
if (oklch[layer].l < 0.6) {
|
||||
if ((elem = get(`i-${layer}`))) {
|
||||
if (cols[layer].luma < 0.6) {
|
||||
elem.classList.add('light'); elem.classList.remove('dark');
|
||||
} else {
|
||||
elem.classList.add('dark'); elem.classList.remove('light');
|
||||
|
@ -175,9 +172,9 @@ function updateSvgs(oklch: Color.Colors, rgb: Color.Rgbs) {
|
|||
}
|
||||
|
||||
// label
|
||||
if (elem = get(`c-${layer}`)) elem.innerHTML = col;
|
||||
if ((elem = get(`c-${layer}`))) elem.innerHTML = col;
|
||||
// minor swatch, if applicable
|
||||
if (elem = get(`s-${layer}`)) elem.style.setProperty('--col', col);
|
||||
if ((elem = get(`s-${layer}`))) elem.style.setProperty('--col', col);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -188,14 +185,12 @@ function showHistory(history: History, data: Layer.Data,
|
|||
const list = document.getElementById('history-items');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
let { side, firstLoad, buf, done } = opts;
|
||||
const { side, firstLoad, done } = opts;
|
||||
|
||||
for (const item of history.iterItems()) {
|
||||
const elem = item.asHtml();
|
||||
let allOpts = { side, firstLoad, buf, done, seed: item.name, history };
|
||||
elem.addEventListener('click', () => {
|
||||
applyState(data, allOpts);
|
||||
});
|
||||
const allOpts = { side, firstLoad, done, seed: item.name, history };
|
||||
elem.addEventListener('click', () => void applyState(data, allOpts));
|
||||
list.appendChild(elem);
|
||||
}
|
||||
|
||||
|
@ -211,13 +206,13 @@ function showHistory(history: History, data: Layer.Data,
|
|||
function closeHistory() {
|
||||
document.getElementById('history-items')?.
|
||||
scroll({ top: 0, left: 0, behavior: 'smooth' });
|
||||
let field = document.getElementById('history-close-target');
|
||||
const field = document.getElementById('history-close-target');
|
||||
if (field) field.parentElement?.removeChild(field);
|
||||
document.documentElement.dataset.state = 'ready';
|
||||
}
|
||||
|
||||
function download(seed: string) {
|
||||
const colors = Color.toRgbs(Color.colors(new Color.Rand(seed)));
|
||||
const colors = Color.colors(new Color.Rand(seed));
|
||||
const blob = Palette.make(seed, colors);
|
||||
|
||||
// there must be a better way to push out a file than
|
||||
|
@ -233,14 +228,15 @@ function download(seed: string) {
|
|||
async function setup() {
|
||||
message('loading layers…');
|
||||
|
||||
let data = await Layer.loadData().catch(e => { message(e, 30); throw e });
|
||||
let history = History.load();
|
||||
const aux = getCanvasCtx('aux');
|
||||
|
||||
let buf = Layer.makeBuffer();
|
||||
const data = await Layer.loadData(aux)
|
||||
.catch(e => { message(`${e}`, 30); throw e });
|
||||
const history = History.load();
|
||||
|
||||
let prevSeed = urlState() ?? new Color.Rand().alphaNum();
|
||||
let seed =
|
||||
await applyState(data, { seed: prevSeed, buf, history, firstLoad: true });
|
||||
await applyState(data, { seed: prevSeed, history, firstLoad: true });
|
||||
let side: Layer.Side = 'front';
|
||||
|
||||
const reroll = document.getElementById('reroll')!;
|
||||
|
@ -248,6 +244,10 @@ async function setup() {
|
|||
|
||||
addListeners();
|
||||
|
||||
function asyncHandler(h: (e: Event) => Promise<void>): (e: Event) => void {
|
||||
return (e: Event) => void h(e);
|
||||
}
|
||||
|
||||
// these ones don't need to be toggled
|
||||
document.getElementById('hideui')?.addEventListener('click', () => {
|
||||
document.documentElement.dataset.state = 'fullquox';
|
||||
|
@ -258,38 +258,38 @@ async function setup() {
|
|||
document.getElementById('history-button')?.addEventListener('click', () => {
|
||||
// does this need the add/remove listeners dance
|
||||
// actually does anything any more?
|
||||
showHistory(history, data, { side, buf });
|
||||
showHistory(history, data, { side });
|
||||
});
|
||||
document.getElementById('close-history')?.addEventListener('click', closeHistory);
|
||||
document.getElementById('current-name')?.addEventListener('focusout', async e => {
|
||||
document.getElementById('current-name')?.addEventListener('focusout', asyncHandler(async e => {
|
||||
const space = String.raw`(\n|\s|<br>| )`;
|
||||
const re = new RegExp(`^${space}+|${space}+$`, 'msgu');
|
||||
|
||||
let elem = e.target as HTMLElement;
|
||||
const elem = e.target as HTMLElement;
|
||||
let str = elem.innerText.replaceAll(re, '');
|
||||
if (!str) str = new Color.Rand().alphaNum();
|
||||
elem.innerText = str;
|
||||
// todo allow images cos it's funny
|
||||
|
||||
prevSeed = seed;
|
||||
seed = await applyState(data, { side, seed: str, buf, history });
|
||||
});
|
||||
seed = await applyState(data, { side, seed: str, history });
|
||||
}));
|
||||
document.getElementById('download-button')?.addEventListener('click', () => {
|
||||
download(prevSeed);
|
||||
});
|
||||
|
||||
document.documentElement.dataset.state = 'ready';
|
||||
|
||||
async function run(task: (k: Done) => Promise<void>): Promise<void> {
|
||||
function run(task: (k: Done) => Promise<void>): void {
|
||||
removeListeners();
|
||||
await task(addListeners);
|
||||
void task(addListeners);
|
||||
}
|
||||
|
||||
function updateFromUrl() {
|
||||
run(async k => {
|
||||
const newSeed = urlState();
|
||||
if (newSeed) {
|
||||
const opts = { history, side, seed: newSeed, buf, done: k };
|
||||
const opts = { history, side, seed: newSeed, done: k };
|
||||
prevSeed = seed;
|
||||
seed = await applyState(data, opts);
|
||||
}
|
||||
|
@ -298,14 +298,14 @@ async function setup() {
|
|||
function runReroll() {
|
||||
run(async k => {
|
||||
prevSeed = seed;
|
||||
seed = await applyState(data, { side, seed, buf, history, done: k });
|
||||
seed = await applyState(data, { side, seed, history, done: k });
|
||||
});
|
||||
}
|
||||
function runSwap() {
|
||||
run(async k => {
|
||||
side = Layer.swapSide(side);
|
||||
const cdata = await Layer.ensureComposed(buf, data);
|
||||
await animateSwapImage(buf, side, cdata, k);
|
||||
const cdata = await Layer.ensureComposed(aux, data);
|
||||
await animateSwapImage(side, cdata, k);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -321,4 +321,4 @@ async function setup() {
|
|||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', setup);
|
||||
document.addEventListener('DOMContentLoaded', () => void setup());
|
||||
|
|
|
@ -73,7 +73,7 @@ export class Rand implements Randy {
|
|||
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
|
||||
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
|
||||
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
|
||||
h1 ^= (h2 ^ h3 ^ h4), h2 ^= h1, h3 ^= h1, h4 ^= h1;
|
||||
h1 ^= (h2 ^ h3 ^ h4); h2 ^= h1; h3 ^= h1; h4 ^= h1;
|
||||
return [h1>>>0, h2>>>0, h3>>>0, h4>>>0];
|
||||
}
|
||||
|
||||
|
@ -112,7 +112,7 @@ export class Rand implements Randy {
|
|||
|
||||
#next(): number {
|
||||
this.#a |= 0; this.#b |= 0; this.#c |= 0; this.#d |= 0;
|
||||
let t = (this.#a + this.#b | 0) + this.#d | 0;
|
||||
const t = (this.#a + this.#b | 0) + this.#d | 0;
|
||||
this.#d = this.#d + 1 | 0;
|
||||
this.#a = this.#b ^ this.#b >>> 9;
|
||||
this.#b = this.#c + (this.#c << 3) | 0;
|
||||
|
|
Loading…
Add table
Reference in a new issue