diff --git a/.gitignore b/.gitignore index 56934af..46123e4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ _build _tmp .directory *~ +dist-newstyle diff --git a/Makefile b/Makefile index f311932..dcd23f5 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,3 @@ -CSS = $(wildcard style/*.css) $(shell find fonts -type f) \ - rainbow-quox/style.css PAGES = index.html pubkey.txt rainbow-quox/index.html MEDIA = \ $(wildcard media/*.png) $(wildcard media/*.gif) $(wildcard media/*.webp) \ @@ -8,6 +6,9 @@ MEDIA = \ $(wildcard rainbow-quox/front/*) $(wildcard rainbow-quox/back/*) \ rainbow-quox/palette.svg rainbow-quox/bright-squares.png \ rainbow-quox/back.svg +CSS = $(shell find fonts -type f) \ + $(patsubst %.scss,%.css, \ + $(wildcard rainbow-quox/*.scss) $(wildcard style/*.css)) SCRIPTS = $(patsubst %.ts,%.js,$(wildcard script/*.ts rainbow-quox/*.ts)) MISC = $(shell find .well-known -type f) ALL = $(CSS) $(PAGES) $(MEDIA) $(SCRIPTS) $(MISC) @@ -31,41 +32,31 @@ upload: build $(BUILDDIR)/ $(HOST):$(REMOTE_DIR)/ $(BUILDDIR)/%: % - @echo $* - @mkdir -p $(dir $@) - @cp $< $@ - -$(BUILDDIR)/%.gif: %_bg.gif - @echo $(notdir $@) - @mkdir -p $(dir $(BUILDDIR)/$* $(TMPDIR)/$*) - @gifsicle -U $*_bg.gif -o $(TMPDIR)/$*_bg_u.gif - @convert -transparent '#ff9bc7' $(TMPDIR)/$*_bg_u.gif $(TMPDIR)/$*_t.gif - @gifsicle --disposal=previous $(TMPDIR)/$*_t.gif -o $(BUILDDIR)/$*.gif - -$(BUILDDIR)/%_small.png: %.svg - @echo $(notdir $@) - @mkdir -p $(dir $@) - @inkscape -e $@ -h 16 $< >/dev/null - -$(BUILDDIR)/%_large.png: %.svg - @echo $(notdir $@) - @mkdir -p $(dir $@) - @inkscape -e $@ -h 30 $< >/dev/null - -$(BUILDDIR)/%_dim.png: %.png - @echo $(notdir $@) - @convert -channel A -evaluate Multiply 0.75 $< $@ + @echo '[copy] ' $< + mkdir -p $(dir $@) + cp $< $@ $(BUILDDIR)/%.js: %.ts + @echo '[tsc] ' $< tsc --strict --noUncheckedIndexedAccess --noEmitOnError \ --lib dom,es2023 --target es2015 \ --outDir $(dir $@) $^ -$(BUILDDIR)/rainbow-quox/palette.svg: rainbow-quox/palette.svg.raku - raku $^ $@ +$(BUILDDIR)/rainbow-quox/palette.svg: rainbow-quox/make-palette/* + @echo '[make-palette] rainbow-quox/palette.svg' + cd rainbow-quox/make-palette; \ + cabal run -v0 -- make-palette $(abspath $@) +$(BUILDDIR)/%.css: %.scss $(wildcard $(dir %)/lib/*.scss) + @echo '[sass] ' $< + mkdir -p $(dir $@) + sass --no-source-map -I style $< $@ \ + --silence-deprecation mixed-decls clean: + @echo '[clean]' $(RM) -r $(TMPDIR) $(BUILDDIR) .PHONY: clean all build upload + +.SILENT: diff --git a/rainbow-quox/back/eyeshine.webp b/rainbow-quox/back/eyeshine.webp new file mode 100644 index 0000000..fa1916d --- /dev/null +++ b/rainbow-quox/back/eyeshine.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eebdbf997f1fe6bbcb95b07904631197a4a8f9da4901dc52f7b3a4281dd966f2 +size 1834 diff --git a/rainbow-quox/canvas.ts b/rainbow-quox/canvas.ts deleted file mode 100644 index d54799d..0000000 --- a/rainbow-quox/canvas.ts +++ /dev/null @@ -1,350 +0,0 @@ -import * as Color from './color.js'; - -export const WIDTH = 1000; -export const HEIGHT = 673; - - -export type Layer = 'static' | 'eyeshine' | Color.Layer; - -// in compositing order -export const allLayers: Layer[] = - ['static', 'outer', 'spines', 'stripes', 'cuffs', 'fins1', 'fins2', 'fins3', - 'belly1', 'belly2', 'masks', 'claws', 'vitiligo1', 'vitiligo2', 'vitiligo3', - 'vitiligo4', 'eyes', 'eyeshine', 'lines']; - -export function makeLayerInfo(f: (l: Layer) => A): Record { - return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record; -} - -export type Position = [x: number, y: number]; -export type Positions = Record; - -export async function loadPos(side: SideName): Promise { - let req = new Request(`./${side}/pos.json`); - return fetch(req).then(resp => resp.json()); -} - -export type LayerInfo1 = {data: ImageData, pos: Position}; - -export type LayerInfo = { - layers: Record, - comp?: ImageData, -}; - - -type Rgb = [number, number, number]; -type Rgbs = Record; - -let rgbBuf = new OffscreenCanvas(1, 1).getContext('2d')!; - -function toRgb(col: Color.Oklch): Rgb { - rgbBuf.fillStyle = col.css(); - rgbBuf.fillRect(0, 0, 1, 1); - const rgb = rgbBuf.getImageData(0, 0, 1, 1).data; - return [rgb[0]!, rgb[1]!, rgb[2]!]; -} - -function toRgbs(col: Color.Colors): Rgbs { - return Color.makeColorInfo(l => toRgb(col[l])); -} - -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)}`; -} - - -function setImageDataRgb({ data }: ImageData, [r, g, b]: Rgb, a: number = -1) { - for (let i = 0; i < data.length; i += 4) { - data[i] = r; data[i+1] = g; data[i+2] = b; - if (a >= 0) data[i+3]! *= a; - } -} - - -async function loadImage(url: string): Promise { - const img0 = new Image; img0.src = url; - const img: ImageBitmapSource = img0.complete ? img0 : - await new Promise(r => img0.addEventListener('load', () => r(img0))); - return createImageBitmap(img); -} - -async function load(buf: OffscreenCanvasRenderingContext2D, - side: SideName, layer: Layer): Promise { - if (layer == 'eyeshine') layer = 'eyes'; - let bmp = await loadImage(`./${side}/${layer}.webp`); - return navigator.locks.request('buf', async () => { - buf.clearRect(0, 0, WIDTH, HEIGHT); // ? - buf.drawImage(bmp, 0, 0); - return buf.getImageData(0, 0, WIDTH, HEIGHT) - }); -} - -async function loadSide(buf: OffscreenCanvasRenderingContext2D, - side: SideName): Promise { - const layers: Partial> = { }; - const pos = await loadPos(side); - const images = Object.fromEntries( - await Promise.all( - allLayers.map(l => load(buf, side, l).then(res => [l, res]))) - ) as Record; - for (const l of allLayers) { - layers[l] = { data: images[l], pos: pos[l] }; - } - return { layers: layers as Record }; -} - -function setColors(cols: Rgbs, info: LayerInfo): void { - for (const l of allLayers) { - if (l == 'static' || l == 'eyeshine') continue; - setImageDataRgb(info.layers[l].data, cols[l]); - } - delete info.comp; -} - -async function compose(buf: OffscreenCanvasRenderingContext2D, - { layers }: LayerInfo): Promise { - return navigator.locks.request('buf', async () => { - buf.save(); - buf.clearRect(0, 0, WIDTH, HEIGHT); - - for (const l of allLayers) { - const [x, y] = layers[l].pos; - buf.globalCompositeOperation = - l == 'eyeshine' ? 'luminosity' : 'source-over'; - buf.drawImage(await createImageBitmap(layers[l].data), x, y); - } - - buf.restore(); - return buf.getImageData(0, 0, WIDTH, HEIGHT); - }); -} - - -async function redraw(ctx: CanvasRenderingContext2D, - buf: OffscreenCanvasRenderingContext2D, - info: LayerInfo) { - let data = info.comp ??= await compose(buf, info); - ctx.putImageData(data, 0, 0); -} - - -function message(msg: string, ctx: CanvasRenderingContext2D) { - ctx.save(); - ctx.clearRect(0, 0, WIDTH, HEIGHT); - ctx.font = 'bold 100px Muller, sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(msg, WIDTH/2, HEIGHT/2, WIDTH-10); - ctx.restore(); -} - - -function startAnim(name: string) { - document.documentElement.dataset.running = name; - document.getElementById('buttons')?.style.removeProperty('z-index'); -} - -function isRunning(): boolean { - return !!document.documentElement.dataset.running; -} - -function finishAnim() { - delete document.documentElement.dataset.running; - document.getElementById('buttons')?.style.setProperty('z-index', '1'); -} - -type SideName = 'front' | 'back'; - -class Side { - cur: SideName = 'front'; - fronts: LayerInfo; - backs: LayerInfo; - - private constructor(fronts: LayerInfo, backs: LayerInfo) { - this.fronts = fronts; this.backs = backs; - } - - static async init(buf: OffscreenCanvasRenderingContext2D) { - return new Side(await loadSide(buf, 'front'), await loadSide(buf, 'back')); - } - - flip() { this.cur = (this.cur == 'front' ? 'back' : 'front'); } - layers() { return this.cur == 'front' ? this.fronts : this.backs; } - - async recolorOn(ctx: CanvasRenderingContext2D, - buf: OffscreenCanvasRenderingContext2D): - Promise<[Color.Colors, Rgbs]> { - const cols = Color.colors(); - const rgbs = toRgbs(cols); - setColors(rgbs, this.fronts); - setColors(rgbs, this.backs); - await redraw(ctx, buf, this.layers()); - return [cols, rgbs]; - } - - async ensureComposed(buf: OffscreenCanvasRenderingContext2D) { - this.fronts.comp ??= await compose(buf, this.fronts); - this.backs.comp ??= await compose(buf, this.backs); - } -} - -function setBg(hue: number) { - document.documentElement.style.setProperty('--hue', `${hue}`); -} - - -function getPalette(): XMLDocument | null { - const palette = document.getElementById('palette') as HTMLObjectElement; - return palette.contentDocument; -} - -function setPalette(oklch: Color.Colors, rgb: Rgbs) { - const palette = getPalette(); - - if (!palette) { - setTimeout(() => setPalette(oklch, rgb), 500); - return; - } - - palette.documentElement.style.setProperty('--hue', `${oklch.outer.h}`); - console.log(palette); - - for (const l of Color.allLayers) { - let col = toHex(rgb[l]); - - const swatch = palette.getElementById(`s-${l}`); - if (swatch) swatch.style.fill = col; - - const text = palette.getElementById(`c-${l}`); - if (text) text.innerHTML = col; - - const bg = palette.getElementById(`p-${l}`); - if (bg) bg.style.fill = col; - - const item = palette.getElementById(`i-${l}`); - if (item) { - const textCol = oklch[l].l < 0.6 ? 'white' : 'black'; - item.style.setProperty('--text', textCol); - } - - } -} - - -async function animateReroll(ctx1: CanvasRenderingContext2D, - ctx2: CanvasRenderingContext2D, - buf: OffscreenCanvasRenderingContext2D, - side: Side, done: () => void) { - const duration = 200; - const [oklch, rgb] = await side.recolorOn(ctx2, buf); - ctx2.canvas.style.animation = `${duration}ms ease forwards fade-in`; - setBg(oklch.outer.h); - setPalette(oklch, rgb); - setTimeout(finish, duration); - - async function finish() { - await redraw(ctx1, buf, side.layers()).then(() => { - ctx2.canvas.style.removeProperty('animation'); - ctx2.canvas.style.removeProperty('opacity'); - done(); - }); - } -} - - -async function animateSwap(picCtx: CanvasRenderingContext2D, - pic2Ctx: CanvasRenderingContext2D, - side: Side, - buf: OffscreenCanvasRenderingContext2D, - done: () => void) { - const duration = 400; - - side.flip(); - await Promise.all([ - side.ensureComposed(buf), - redraw(pic2Ctx, buf, side.layers()), - ]); - - picCtx.canvas.style.animation = `${duration}ms ease swap1`; - pic2Ctx.canvas.style.animation = `${duration}ms ease swap2`; - setTimeout(swap, duration/2); - setTimeout(finish, duration); - - function swap() { - let data = pic2Ctx.getImageData(0, 0, WIDTH, HEIGHT); - picCtx.putImageData(data, 0, 0); - } - - function finish() { - picCtx.canvas.style.removeProperty('animation'); - pic2Ctx.canvas.style.removeProperty('animation'); - done(); - } -} - - -document.addEventListener('DOMContentLoaded', async function() { - let pic = document.getElementById('pic') as HTMLCanvasElement; - let picCtx = pic.getContext('2d')!; - - let pic2 = document.getElementById('pic2') as HTMLCanvasElement; - let pic2Ctx = pic2.getContext('2d')!; - - let buf = new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!; - - message('loading layers…', picCtx); - - let side: Side = await Side.init(buf).catch(err => { - message(err, picCtx); - throw err; - }); - - let [oklch, rgb] = await side.recolorOn(picCtx, buf); - setBg(oklch.outer.h); - setPalette(oklch, rgb); - await redraw(picCtx, buf, side.layers()); - - const reroll = document.getElementById('reroll')!; - const swap = document.getElementById('swap')!; - - addListeners(); - - document.documentElement.dataset.ready = '1'; - - type Done = (finish: () => void) => void; - - function handleReroll(e: Event) { - wrap(reroll, - done => animateReroll(picCtx, pic2Ctx, buf, side, done), - 'reroll', e); - } - - function handleSwap(e: Event) { - wrap(swap, done => animateSwap(picCtx, pic2Ctx, side, buf, done), - 'swap', e); - } - - function addListeners() { - reroll.addEventListener('click', handleReroll); - swap.addEventListener('click', handleSwap); - } - - function removeListeners() { - reroll.removeEventListener('click', handleReroll); - swap.removeEventListener('click', handleSwap); - } - - function wrap(elem: Element, f: Done, name: string, e: Event) { - if (elem != e.target) return; - e.stopPropagation(); - if (isRunning()) return; - removeListeners(); - startAnim(name); - f(() => { finishAnim(); addListeners(); }); - } -}); - -export { load, loadSide }; diff --git a/rainbow-quox/color.ts b/rainbow-quox/color.ts index b712e0d..dd1a9b9 100644 --- a/rainbow-quox/color.ts +++ b/rainbow-quox/color.ts @@ -1,109 +1,142 @@ -const rand: () => number = Math.random; // [todo] - -function randBetween(x: number, y: number): number { - const lo = min(x, y), hi = max(x, y); - return lo + rand() * (hi - lo); -} - -function oneOf(...xs: A[]): A { - return xs[Math.floor(rand() * xs.length)]!; -} +import * as R from './rand.js'; const max = Math.max; const min = Math.min; +export type Luma = number; +export type Chroma = number; +export type Hue = number; +export type Alpha = number; + +export type HueDistance = number; + +const MAXL: Luma = 0.9; +const MINL: Luma = 0.4; +const MINL_LIGHT: Luma = 0.7; +const MAXL_DARK: Luma = 0.65; + +const MINC_LIGHT: Chroma = 0.08; +const MAXC_LIGHT: Chroma = 0.1; +const MINC_DARK: Chroma = 0.12; +const MAXC_DARK: Chroma = 0.175; + +// max spread for a sequence of analogous colors. unless that would put them +// too close together +const MAXH_WIDTH: HueDistance = 80; + +// minimum distance between adjacent analogous colors +const MINH_SEP: HueDistance = 5; + +// size of the wedge a "complementary" color can be in +const MAXH_COMPL: HueDistance = 40; + +// size of the wedge a "triadic" color can be in +const MAXH_TRIAD: HueDistance = 25; + type LD = 'light' | 'dark'; -namespace Oklch { +export namespace Oklch { export type Channel = 'l' | 'c' | 'h'; export type Channels = Record; 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; } -export class Oklch { - static lightFor(baseL: number): number { return randBetween(baseL, MAXL); } +function isLight(l: Luma): boolean { return l >= MINL_LIGHT; } - static darkFor(baseL: number): number { return randBetween(MINL, baseL); } +export namespace Rand { export type State = R.State; } - static isLight(l: number): boolean { return l >= MINL_LIGHT; } +export class Rand extends R.Rand { + constructor(seed?: R.State) { super(seed); } - static brightFor(l: number, baseC: number): number { - if (Oklch.isLight(l)) { return randBetween(baseC, MAXC_LIGHT); } - else { return randBetween(baseC, MAXC_DARK); } + lightFor(baseL: Luma): Luma { return this.float(baseL, MAXL); } + darkFor(baseL: Luma): Luma { return this.float(MINL, baseL); } + + brightFor(l: Luma, baseC: Chroma): Chroma { + return this.float(baseC, isLight(l) ? MAXC_LIGHT : MAXC_DARK); } - static dullFor(l: number, baseC: number): number { - if (Oklch.isLight(l)) { return randBetween(baseC, MINC_LIGHT); } - else { return randBetween(baseC, MINC_DARK); } + dullFor(l: Luma, baseC: Chroma): Chroma { + return this.float(baseC, isLight(l) ? MINC_LIGHT : MINC_DARK); } - static analogous1(baseH: number): number { - const size = randBetween(MINH_SEP, 2 * MINH_SEP); - return rand() > 0.5 ? baseH + size : baseH - size; + analogous1(baseH: Hue): Hue { + const size = this.float(MINH_SEP, 2 * MINH_SEP); + return this.boolean() ? baseH + size : baseH - size; } - static analogous(baseH: number, count: number): number[] { + analogous(baseH: Hue, count: number): Hue[] { const minWidth = min(count * MINH_SEP, MAXH_WIDTH * 0.8); - const width = randBetween(minWidth, MAXH_WIDTH); + const width = this.float(minWidth, MAXH_WIDTH); const sep = width / (count - 1); const start = baseH - (width / 2); const numbers = Array.from({length: count}, (_u, i) => start + i * sep); - return rand() > 0.5 ? numbers : numbers.reverse(); + return this.boolean() ? numbers : numbers.reverse(); } - static complementary1(baseH: number): number { - return Oklch.analogous1((baseH + 180) % 360); + complementary1(baseH: Hue): Hue { + return this.analogous1((baseH + 180) % 360); } - static complementary(baseH: number, count: number): number[] { - const angle = randBetween(180 - MAXH_COMPL/2, 180 + MAXH_COMPL/2); - return Oklch.analogous(baseH + angle, count); + complementary(baseH: Hue, count: number): Hue[] { + const angle = this.float(180 - MAXH_COMPL/2, 180 + MAXH_COMPL/2); + return this.analogous(baseH + angle, count); } - static triad(baseH: number): [number, number] { - const angle = randBetween(120 - MAXH_TRIAD/2, 120 + MAXH_TRIAD/2); + triad(baseH: Hue): [Hue, Hue] { + const angle = this.float(120 - MAXH_TRIAD/2, 120 + MAXH_TRIAD/2); return [baseH - angle, baseH + angle]; } - readonly l: number; readonly c: number; readonly h: number; - - static baseLuma(ld?: LD): number { + baseLuma(ld?: LD): Luma { if (ld == 'light') { - return randBetween(MINL_LIGHT, MAXL); + return this.float(MINL_LIGHT, MAXL); } else if (ld == 'dark') { - return randBetween(MINL, MAXL_DARK); + return this.float(MINL, MAXL_DARK); } else { - return randBetween(MINL, MAXL); + return this.float(MINL, MAXL); } } - static baseChroma(l: number): number { + baseChroma(l: Luma): Chroma { if (l >= MINL_LIGHT) { - return randBetween(MINC_LIGHT, MAXC_LIGHT); + return this.float(MINC_LIGHT, MAXC_LIGHT); } else { - return randBetween(MINC_DARK, MAXC_DARK); + return this.float(MINC_DARK, MAXC_DARK); } } - static baseHue(): number { return rand() * 360; } + baseHue(): Hue { return this.float(360); } +} - constructor(); - constructor(ld: LD); + +export class Oklch { + readonly l: Luma; readonly c: Chroma; readonly h: Hue; + + static normHue(h: Hue) { return (h = h % 360) < 0 ? h + 360 : h; } + + constructor(l: Luma, c: Chroma, h: Hue); + constructor(r: Rand, ld?: LD); constructor(cs: Oklch.Channels); - constructor(l: number, c: number, h: number); - constructor(lcsld?: number | Oklch.Channels | LD, - cc?: number, hh?: number) { - if (typeof lcsld == 'string' || lcsld == undefined) { - this.l = Oklch.baseLuma(lcsld as LD | undefined); - this.c = Oklch.baseChroma(this.l); - this.h = Oklch.baseHue(); - } else if (cc == undefined && hh == undefined) { - const {l, c, h} = lcsld as Oklch.Channels; + constructor(ll: Luma | Oklch.Channels | Rand, cc?: Chroma | LD, hh?: Hue) { + if (hh !== undefined) { + this.l = ll as Luma; + this.c = cc as Chroma; + this.h = hh as Hue; + } else if (typeof ll == 'object' && 'l' in ll) { + const {l, c, h} = ll as Oklch.Channels; this.l = l; this.c = c; this.h = h; } else { - this.l = lcsld as number; this.c = cc!; this.h = hh!; + const r = ll as Rand; + this.l = r.baseLuma(cc as LD | undefined); + this.c = r.baseChroma(this.l); + this.h = r.baseHue(); } } @@ -115,44 +148,25 @@ export class Oklch { else { return `oklch(${l}% ${c}% ${h})`; } } - with(lch: Partial>): Oklch { - function call(comp: undefined | number | Oklch.ChannelMap, x: number) { - if (comp == undefined) { return x; } - else if (typeof comp == 'function') { return comp(x); } - else { return comp as number; } + with(maps: Oklch.With): Oklch { + function call(comp: Oklch.With1, x: number) { + switch (typeof comp) { + case 'number': return comp; + case 'function': return comp(x); + default: return x; + } } return new Oklch({ - l: call(lch.l, this.l), - c: call(lch.c, this.c), - h: call(lch.h, this.h), + l: call(maps.l, this.l), + c: call(maps.c, this.c), + h: call(maps.h, this.h), }); } + + rgb(): Rgb { return toRgbViaCanvas(this); } } -const MAXL = 0.9; -const MINL = 0.4; -const MINL_LIGHT = 0.7; -const MAXL_DARK = 0.65; - -const MINC_LIGHT = 0.08; -const MAXC_LIGHT = 0.1; -const MINC_DARK = 0.12; -const MAXC_DARK = 0.175; - -// max spread for a sequence of analogous colors. unless that would put them -// too close together -const MAXH_WIDTH = 80; - -// minimum distance between adjacent analogous colors -const MINH_SEP = 5; - -// size of the wedge a "complementary" color can be in -const MAXH_COMPL = 40; - -// size of the wedge a "triadic" color can be in -const MAXH_TRIAD = 25; - export type SchemeType = 'triad' | 'fin-belly' | 'fin-body'; export type OuterLayer = 'outer' | 'spines' | 'vitiligo1'; @@ -183,84 +197,90 @@ export function makeColorInfo(f: (l: Layer) => A): Record { } -export function colors(): Scheme { - const outer = new Oklch('dark'); +export function colors(r: Rand = new Rand()): Scheme { + const outer = new Oklch(r, 'dark'); let outerCols: OuterCols = - { outer, spines: mkSpines(outer), vitiligo1: mkVitiligo(outer) }; + { outer, spines: mkSpines(r, outer), vitiligo1: mkVitiligo(r, outer) }; - const stripes = mkStripes(); - let sockCols: SockCols = { stripes, cuffs: mkCuffs(stripes) }; + const stripes = mkStripes(r); + let sockCols: SockCols = { stripes, cuffs: mkCuffs(r, stripes) }; let finCols: FinCols, bellyCols: BellyCols, type: SchemeType; - const whichBody = rand(); + const whichBody = r.float(); if (whichBody > 2/3) { type = 'triad'; - const [f, b] = Oklch.triad(outer.h); - finCols = mkFins(f, outer); bellyCols = mkBelly(b); + const [f, b] = r.triad(outer.h); + finCols = mkFins(r, f, outer); bellyCols = mkBelly(r, b); } else if (whichBody > 1/3) { type = 'fin-belly'; - const [f, b] = Oklch.complementary(outer.h, 2); - finCols = mkFins(f!, outer); bellyCols = mkBelly(b!); + const [f, b] = r.complementary(outer.h, 2); + finCols = mkFins(r, f!, outer); bellyCols = mkBelly(r, b!); } else { type = 'fin-body'; - finCols = mkFins(Oklch.analogous1(outer.h), outer); - bellyCols = mkBelly(Oklch.complementary1(outer.h)); + finCols = mkFins(r, r.analogous1(outer.h), outer); + bellyCols = mkBelly(r, r.complementary1(outer.h)); } - let miscCols = mkMisc(outerCols, finCols, bellyCols); + let miscCols = mkMisc(r, outerCols, finCols, bellyCols); return merge(outerCols, sockCols, finCols, bellyCols, miscCols, type); } -function mkSpines(outer: Oklch): Oklch { +function mkSpines(r: Rand, outer: Oklch): Oklch { return outer.with({ l: x => x * 0.8, c: x => x * 1.1, - h: x => randBetween(x + 12, x - 12), + h: x => r.float(x + 12, x - 12), }) } -function mkVitiligo(outer: Oklch): Oklch { +function mkVitiligo(r: Rand, outer: Oklch): Oklch { return outer.with({ - l: x => randBetween(max(x, 0.94), 0.985), // exception to MAXL - c: x => randBetween(min(x, 0.1), MINC_LIGHT), + l: x => r.float(max(x, 0.94), 0.985), // exception to MAXL + c: x => r.float(min(x, 0.1), MINC_LIGHT), }); } -function mkStripes(): Oklch { +function mkStripes(r: Rand): Oklch { return new Oklch({ - l: randBetween(0.8, MAXL), - c: randBetween(MINC_LIGHT, MAXC_LIGHT), - h: rand() * 360 + l: r.float(0.8, MAXL), + c: r.float(MINC_LIGHT, MAXC_LIGHT), + h: r.baseHue(), }); } -function mkCuffs(sock: Oklch): Oklch { +function mkCuffs(r: Rand, sock: Oklch): Oklch { return sock.with({ - l: x => randBetween(x * 0.85, x * 0.65), - c: x => randBetween(x, MAXC_LIGHT), - h: x => randBetween(x + 8, x - 8), + l: l => r.float(l * 0.85, l * 0.65), + c: c => r.float(c, MAXC_LIGHT), + h: h => r.float(h + 8, h - 8), }); } -function mkFins(h: number, outer: Oklch): FinCols { - const [fin1Hue, fin2Hue, fin3Hue] = Oklch.analogous(h, 3); - const [ll, cc] = oneOf( - [Oklch.lightFor, Oklch.dullFor], [Oklch.darkFor, Oklch.brightFor]); +function mkFins(r: Rand, h: Hue, outer: Oklch): FinCols { + const [fin1Hue, fin2Hue, fin3Hue] = r.analogous(h, 3); + const direction: 'lighter' | 'darker' = r.choice(['lighter', 'darker']); + + function ll(l: Luma): Luma { + return direction == 'lighter' ? r.lightFor(l) : r.darkFor(l); + } + function cc(l: Luma, c: Chroma): Chroma { + return direction == 'lighter' ? r.dullFor(l, c) : r.brightFor(l, c); + } const fins1 = new Oklch(ll(outer.l), cc(outer.l, outer.c), fin1Hue!); const fins2 = new Oklch(ll(fins1.l), cc(fins1.l, fins1.c), fin2Hue!); const fins3 = new Oklch(ll(fins2.l), cc(fins2.l, fins2.c), fin3Hue!); - const vitiligo4 = mkVitiligo(fins1); + const vitiligo4 = mkVitiligo(r, fins1); return { fins1, fins2, fins3, vitiligo4 }; } -function mkBelly(h: number): BellyCols { - const [belly1Hue, belly2Hue] = Oklch.analogous(h, 2); +function mkBelly(r: Rand, h: Hue): BellyCols { + const [belly1Hue, belly2Hue] = r.analogous(h, 2); const belly1 = new Oklch({ - l: randBetween(0.7, MAXL), - c: Oklch.baseChroma(1), + l: r.float(0.7, MAXL), + c: r.baseChroma(1), h: belly1Hue! }); const belly2 = belly1.with({ @@ -268,33 +288,33 @@ function mkBelly(h: number): BellyCols { c: x => x * 0.9, h: belly2Hue!, }); - const vitiligo3 = mkVitiligo(belly1); - const vitiligo2 = mkVitiligo(belly2); + const vitiligo3 = mkVitiligo(r, belly1); + const vitiligo2 = mkVitiligo(r, belly2); return { belly1, belly2, vitiligo2, vitiligo3 }; } -function mkMisc(o: OuterCols, f: FinCols, b: BellyCols): MiscCols { +function mkMisc(r: Rand, o: OuterCols, f: FinCols, b: BellyCols): MiscCols { const masks = new Oklch({ - l: randBetween(0.8, MAXL), - c: randBetween(0.01, 0.06), - h: Oklch.analogous1(oneOf(o.outer, b.belly1, f.fins1).h) + l: r.float(0.8, MAXL), + c: r.float(0.01, 0.06), + h: r.analogous1(r.choice([o.outer, b.belly1, f.fins1]).h) }); return { masks, eyes: new Oklch({ - l: Oklch.baseLuma('light'), - c: randBetween(0.28, MAXC_LIGHT), - h: oneOf(Oklch.analogous1, Oklch.complementary1)(o.outer.h) + l: r.baseLuma('light'), + c: r.float(0.28, MAXC_LIGHT), + h: r.boolean() ? r.analogous1(o.outer.h) : r.complementary1(o.outer.h) }), claws: masks.with({ - l: x => min(MAXL, x + randBetween(0, 0.1)), - c: randBetween(0.01, 0.06), - h: Oklch.analogous1, + l: x => min(MAXL, x + r.float(0, 0.1)), + c: r.float(0.01, 0.06), + h: h => r.analogous1(h), }), lines: new Oklch({ - l: randBetween(0.01, 0.06), - c: Oklch.baseChroma(0), - h: Oklch.analogous1(o.outer.h) + l: r.float(0.01, 0.06), + c: r.baseChroma(0), + h: r.analogous1(o.outer.h) }), }; } @@ -310,3 +330,62 @@ function merge({ outer, spines, vitiligo1 }: OuterCols, belly1, vitiligo3, belly2, vitiligo2, eyes, masks, claws, lines, type }; } + + +export namespace Rgb { + export type Channel = number; + export type Channels = { r: number, g: number, b: number }; +} + +export class Rgb { + readonly r: Rgb.Channel; + readonly g: Rgb.Channel; + readonly b: Rgb.Channel; + + static clamp(x: Rgb.Channel) { + return min(max(0, Math.floor(x)), 255); + } + + constructor(r: Rgb.Channel, g: Rgb.Channel, b: Rgb.Channel); + constructor({r, g, b}: Rgb.Channels); + constructor(rr: Rgb.Channel | Rgb.Channels, gg?: Rgb.Channel, bb?: Rgb.Channel) { + const C = Rgb.clamp; + if (typeof rr == 'number') { + this.r = C(rr!); this.g = C(gg!); this.b = C(bb!); + } else { + this.r = C(rr.r); this.g = C(rr.g); this.b = C(rr.b); + } + } + + css() { + function h(x: Rgb.Channel) { + let s = x.toString(16); + return s.length == 2 ? s : '0' + s; + } + return `#${h(this.r)}${h(this.g)}${h(this.b)}` + } +} + +export type Rgbs = Record; + +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)}`; +} diff --git a/rainbow-quox/front/eyeshine.webp b/rainbow-quox/front/eyeshine.webp new file mode 100644 index 0000000..db3c69c --- /dev/null +++ b/rainbow-quox/front/eyeshine.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c407db7a06688de206e8edfff49a6164c4a0ac62dab42c780befa80fb100e3f6 +size 1890 diff --git a/rainbow-quox/index.html b/rainbow-quox/index.html index 2884542..31db9fd 100644 --- a/rainbow-quox/index.html +++ b/rainbow-quox/index.html @@ -6,20 +6,20 @@ rainbow quox - +
+
quox #0
+
- - if the canvas isn't working, - the old version is here - - + + the canvas failed to load. sorry. +
diff --git a/rainbow-quox/make-palette/cabal.project b/rainbow-quox/make-palette/cabal.project new file mode 100644 index 0000000..9a93c76 --- /dev/null +++ b/rainbow-quox/make-palette/cabal.project @@ -0,0 +1,9 @@ +packages: ./make-palette.cabal + +source-repository-package + type: git + location: https://git.rhiannon.website/rhi/svg-builder + tag: 9c09fcea4ac316dd5e0709b40f85952047070bf1 + +shared: False +executable-dynamic: False diff --git a/rainbow-quox/make-palette/make-palette.cabal b/rainbow-quox/make-palette/make-palette.cabal new file mode 100644 index 0000000..0ab338e --- /dev/null +++ b/rainbow-quox/make-palette/make-palette.cabal @@ -0,0 +1,12 @@ +cabal-version: 3.0 + +name: make-palette +version: 0 + +executable make-palette + hs-source-dirs: . + build-depends: base, text, svg-builder + default-language: GHC2024 + default-extensions: OverloadedStrings, PatternSynonyms + main-is: make-palette.hs + ghc-options: -Wall diff --git a/rainbow-quox/make-palette/make-palette.hs b/rainbow-quox/make-palette/make-palette.hs new file mode 100644 index 0000000..55a7d08 --- /dev/null +++ b/rainbow-quox/make-palette/make-palette.hs @@ -0,0 +1,147 @@ +import Graphics.Svg +import Data.Text (Text) +import Data.Text qualified as Text +import Data.Maybe +import System.Environment +import Data.String (IsString (..)) + +main :: IO () +main = do + [out] <- getArgs + renderToFile out document + + +document :: Element +document = + svg11_ (stylesheet "palette.css" <> foldMap makeItem (zip [0..] items)) + `with` [Width_ <<- width, Height_ <<- "179", + ViewBox_ <<- viewBox] + where + width = tshow $ 53 + 100 * length items + viewBox = "-53 -1 " <> width <> " 179" + + +items :: [Swatch] +items = + ["outer" `WithMinor` "vitiligo1", + "spines" `WithUse` "vitiligo1", + "stripes" `As` "socks" `WithMinor` "cuffs", + "belly1" `WithMinor` "vitiligo3", + "belly2" `WithMinor` "vitiligo2", + "fins1" `WithMinor` "vitiligo4", + "fins2" `WithUse` "vitiligo4", + "fins3" `WithUse` "vitiligo4", + Only "masks", Only "claws", Only "eyes" + ] + +data Layer = Layer Text (Maybe Text) +instance IsString Layer where fromString l = Layer (fromString l) Nothing +pattern As :: Text -> Text -> Layer +pattern txt `As` disp = Layer txt (Just disp) + +data Swatch = + Only Layer + | WithMinor Layer Layer + | WithUse Layer Text + +makeItem :: (Int, Swatch) -> Element +makeItem (i, swatch) = case swatch of + Only (Layer l d) -> makeOnly l d i + WithMinor (Layer l1 d1) (Layer l2 d2) -> makeMinor l1 d1 l2 d2 i + WithUse (Layer l1 d1) l2 -> makeUse l1 d1 l2 i + +data SwatchCount = One | Two + +data SwatchPos = SOnly | SFirst | SSecond + +data RectType = RMaj | RMin | ROnly + + +stylesheet :: Text -> Element +stylesheet path = + makeElementNoEnd "link" `with` + [makeAttribute "xmlns" "http://www.w3.org/1999/xhtml", + makeAttribute "rel" "stylesheet", + makeAttribute "href" path, + Type_ <<- "text/css"] + +makeOnly :: Text -> Maybe Text -> Int -> Element +makeOnly layer display index = + itemGroup layer index + [makeRect layer ROnly, + makeTextBg layer, + makeName (fromMaybe layer display) One, + makeHex layer SOnly] + +makeMinor :: Text -> Maybe Text -> Text -> Maybe Text -> Int -> Element +makeMinor layer1 display1 layer2 _display2 index = + itemGroup layer1 index + [makeRect layer1 RMaj, + makeRect layer2 RMin, + makeTextBg layer1, + makeName (fromMaybe layer1 display1) Two, + makeHex layer1 SFirst, + makeHex layer2 SSecond] + +makeUse :: Text -> Maybe Text -> Text -> Int -> Element +makeUse layer1 display1 layer2 index = + itemGroup layer1 index + [makeRect layer1 RMaj, + use_ [makeAttribute "href" $ "#s-" <> layer2], + makeTextBg layer1, + makeName (fromMaybe layer1 display1) Two, + makeHex layer1 SFirst, + use_ [makeAttribute "href" $ "#c-" <> layer2]] + +itemGroup :: Text -> Int -> [Element] -> Element +itemGroup layer index elt = + g_ [Class_ <<- "item", Id_ <<- "i-" <> layer, + Transform_ <<- itemTranslate index] + (mconcat elt) + +makeRect :: Text -> RectType -> Element +makeRect layer typ = + rect_ [Class_ <<- classes, Id_ <<- "s-" <> layer, + Fill_ <<- "pink", + X_ <<- "0", Y_ <<- y, + Width_ <<- "80", Height_ <<- height] + where + classes = "swatch " <> + case typ of RMaj -> "maj"; RMin -> "min"; ROnly -> "only" + height = case typ of RMaj -> "50"; RMin -> "25"; ROnly -> "80" + y = case typ of RMaj -> "30"; RMin -> "0"; ROnly -> "0" + +makeTextBg :: Text -> Element +makeTextBg layer = + polygon_ [Class_ <<- "text-bg", Id_ <<- "p-" <> layer, + Fill_ <<- "pink", + points [(0,85), (80,85), (28,175), (-52,175)]] + +makeName :: Text -> SwatchCount -> Element +makeName display count = + text_ [Class_ <<- "name", Text_anchor_ <<- "end", + X_ <<- tshow x, Y_ <<- "100", textTransform x] $ + toElement display + where x = case count of One -> 22; Two -> 18 + +makeHex :: Text -> SwatchPos -> Element +makeHex layer count = + text_ [Class_ <<- "hex", Id_ <<- "c-" <> layer, Text_anchor_ <<- "end", + X_ <<- tshow x, Y_ <<- "100", textTransform x] $ + "#000000" + where x = case count of SOnly -> 51; SFirst -> 42; SSecond -> 64 + +textTransform :: Int -> Attribute +textTransform x = + Transform_ <<- rotateAround @Double (-60) (fromIntegral x) 100 + + +itemTranslate :: Int -> Text +itemTranslate i = translate @Double (fromIntegral $ i * 100) 0 + +points :: [(Int, Int)] -> Attribute +points = (Points_ <<-) . Text.unwords . + map (\(x, y) -> Text.intercalate "," [tshow x, tshow y]) + +tshow :: Show a => a -> Text +tshow = Text.pack . show diff --git a/rainbow-quox/palette.scss b/rainbow-quox/palette.scss new file mode 100644 index 0000000..a1b62c5 --- /dev/null +++ b/rainbow-quox/palette.scss @@ -0,0 +1,40 @@ +@import url(../fonts/muller/muller.css); +@import url(../fonts/pragmatapro/pragmatapro.css); + +:root { + --hue: 300; + --c-hue: calc(var(--hue) + 180); + --col: oklch(0.8 0.04 var(--hue)); + color-scheme: light dark; + + font-family: 'Muller', sans-serif; + text-anchor: end; +} + +.swatch, .text-bg { + transition: fill 0.2s ease; + fill: var(--col); + stroke: oklch(from var(--col) 0.15 0.25 calc(h + 180)); + stroke-width: 2; + + filter: drop-shadow(6px 6px 0 oklch(0.4 0.2 var(--hue) / 0.45)); + + @media (prefers-color-scheme: dark) { + filter: drop-shadow(6px 6px 0 oklch(0.1 0.15 var(--hue) / 0.45)); + } +} + +.light :is(text, use) { fill: oklch(from var(--col) 0.95 0.075 calc(h + 180)); } +.dark :is(text, use) { fill: oklch(from var(--col) 0.15 0.25 calc(h + 180)); } + +.name { + font-weight: 700; + font-size: 25px; +} + +.hex { + font-family: monospace; + font-weight: 500; + font-size: 15px; +} + diff --git a/rainbow-quox/palette.svg.raku b/rainbow-quox/palette.svg.raku deleted file mode 100644 index ac8e7c9..0000000 --- a/rainbow-quox/palette.svg.raku +++ /dev/null @@ -1,133 +0,0 @@ -unit sub MAIN($out); - - -my $doc = q:to/EOP/; - - - - - EOP - -sub make-item($layer, :$display = $layer, :$use-vit?, :$vit?) { ... } - -make-item 'outer', upper => 'vitiligo1'; -make-item 'spines', use => 'vitiligo1'; -make-item 'stripes', upper => 'cuffs', display => 'socks'; -make-item 'belly1', upper => 'vitiligo3'; -make-item 'belly2', upper => 'vitiligo2'; -make-item 'fins1', upper => 'vitiligo4'; -make-item 'fins2', use => 'vitiligo4'; -make-item 'fins3', use => 'vitiligo4'; -make-item 'masks'; -make-item 'claws'; -make-item 'eyes'; - -$doc ~= ''; - -$out.IO.spurt: $doc; - - - - -sub make-item($layer, :$display = $layer, :$use?, :$upper?) { - state $index = 0; - my $str = - qq[\n]; - with $upper { - $str ~= qq{ - - - }; - } orwith $use { - $str ~= qq{ - - - }; - } else { - $str ~= qq{ }; - } - - $str ~= qq{ - - }; - - with $upper { - $str ~= qq{ - - $display - - - #?????? - - - #?????? - - }; - } orwith $use { - $str ~= qq{ - - $layer - - - #?????? - - - }; - } else { - $str ~= qq{ - - $layer - - - #?????? - - }; - } - $str ~= qq{ }; - - $index++; - - $doc ~= $str -} diff --git a/rainbow-quox/quox.ts b/rainbow-quox/quox.ts new file mode 100644 index 0000000..b2c51de --- /dev/null +++ b/rainbow-quox/quox.ts @@ -0,0 +1,378 @@ +import * as Color from './color.js'; + +type State = Color.Rand.State; + + +async function loadBitmap(url: string): Promise { + const img0 = new Image; + const img: Promise = new Promise((ok, err) => { + img0.addEventListener('load', () => ok(img0)); + img0.addEventListener('error', () => err(`couldn't load file: ${url}`)); + }); + img0.src = url; + return createImageBitmap(await img); +} + + +type Buffer = OffscreenCanvasRenderingContext2D; + +function dataViaBuffer(bmp: ImageBitmap, buf: Buffer): ImageData { + buf.clearRect(0, 0, bmp.width, bmp.height); + buf.drawImage(bmp, 0, 0); + return buf.getImageData(0, 0, bmp.width, bmp.height); +} + +async function loadDataLocking(url: string, buf: Buffer): Promise { + return loadBitmap(url).then(i => + navigator.locks.request('imagebuf', () => dataViaBuffer(i, buf))); +} + +async function loadDataFresh(url: string): Promise { + const img = await loadBitmap(url); + let buf = new OffscreenCanvas(img.width, img.height).getContext('2d')!; + return dataViaBuffer(img, buf); +} + +function loadImageData(url: string, buf?: Buffer): Promise { + if (buf && navigator.locks) return loadDataLocking(url, buf); + else return loadDataFresh(url); +} + +const WIDTH = 1000; +const HEIGHT = 673; + +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) makeBuffer(width, height); + else return undefined; +} + +export type Layer = 'static' | 'eyeshine' | Color.Layer; + +// in compositing order +export const allLayers: Layer[] = + ['static', 'outer', 'spines', 'stripes', 'cuffs', 'fins1', 'fins2', 'fins3', + 'belly1', 'belly2', 'masks', 'claws', 'vitiligo1', 'vitiligo2', 'vitiligo3', + 'vitiligo4', 'eyes', 'eyeshine', 'lines']; + +function makeLayerInfo(f: (l: Layer) => A): Record { + return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record; +} + +async function makeLayerInfoAsync(f: (l: Layer) => Promise): +Promise> { + let list = await Promise.all(allLayers.map(l => f(l).then(res => [l, res]))); + return Object.fromEntries(list); +} + + +function loadLayers(dir: string): Promise> { + let buf = makeBufferIfLocks(WIDTH, HEIGHT); + return makeLayerInfoAsync(l => loadImageData(`./${dir}/${l}.webp`, buf)); +} + + + +type Position = [x: number, y: number]; +type Positions = Record; + +async function loadPos(dir: string): Promise { + return (await fetch(`./${dir}/pos.json`)).json(); +} + + +type Side = 'front' | 'back'; + +function swapSide(s: Side): Side { + return s == 'front' ? 'back' : 'front'; +} + +type SideData = Record; + +type LayerData = { + front: SideData, back: SideData, + frontImage?: ImageData, backImage?: ImageData, +}; + +type ComposedData = Required; + +async function loadData(): Promise { + let [fl, fp, bl, bp] = await Promise.all([ + loadLayers('front'), loadPos('front'), + loadLayers('back'), loadPos('back') + ]); + return { + front: makeLayerInfo(l => [fl[l], fp[l]]), + back: makeLayerInfo(l => [bl[l], bp[l]]), + } +} + + +function singleColor({ data }: ImageData, { r, g, b }: Color.Rgb) { + for (let i = 0; i < data.length; i += 4) { + data[i] = r; data[i+1] = g; data[i+2] = b; + } +} + +async function recolorLayers(layers: LayerData, cols: Color.Rgbs) { + await Promise.all(Color.allLayers.map(l => { + singleColor(layers.front[l][0], cols[l]); + singleColor(layers.back[l][0], cols[l]); + })); + delete layers.frontImage; delete layers.backImage; +} + +type ComposeLayer = [ImageData, Position, GlobalCompositeOperation]; + +async function compose(buf: Buffer, layers: ComposeLayer[], + width: number, height: number): Promise { + buf.save(); + buf.clearRect(0, 0, width, height); + const bmps = await Promise.all(layers.map(async ([l, [x, y], m]) => + [await createImageBitmap(l), x, y, m] as const)); + for (const [bmp, x, y, m] of bmps) { + buf.globalCompositeOperation = m; + buf.drawImage(bmp, x, y); + } + buf.restore(); + return buf.getImageData(0, 0, width, height); +} + +async function +ensureComposed(buf: Buffer, data: LayerData): Promise { + let { front, back } = data; + data.frontImage ??= await composeLayers(front); + data.backImage ??= await composeLayers(back); + return data as ComposedData; + + function composeLayers(sdata: SideData): Promise { + return compose(buf, allLayers.map(l => makeLayer(l, sdata)), WIDTH, HEIGHT); + } + function makeLayer(l: Layer, sdata: SideData): ComposeLayer { + let [i, p] = sdata[l]; + return [i, p, l == 'eyeshine' ? 'luminosity' : 'source-over']; + } +} + + +async function redraw(ctx: CanvasRenderingContext2D, + buf: Buffer, data: ComposedData, side: Side) { + await ensureComposed(buf, data); + ctx.putImageData(data[`${side}Image`], 0, 0); +} + + +function message(msg: string, error = false) { + const ctx = getCanvasCtx('main'); + const size = error ? 30 : 100; + ctx.save(); + ctx.clearRect(0, 0, WIDTH, HEIGHT); + ctx.font = `bold ${size}px Muller, sans-serif`; + ctx.textAlign = 'center'; + ctx.fillText(msg, WIDTH/2, HEIGHT/2, WIDTH-10); + ctx.restore(); +} + + +function urlState(): State | undefined { + const str = document.location.hash; + if (str?.match(/^#\d+$/)) return parseInt(str.substring(1)); +} + +function updateUrl(state: State): void { + history.replaceState({}, '', `#${state}`); +} + +type ApplyStateOpts = + { side: Side, state: State, firstLoad: boolean, buf: Buffer, done: Done }; + +async function +applyState(data: LayerData, opts: Partial): Promise { + let { side, state, firstLoad, buf, done } = opts; + side ??= 'front'; + firstLoad ??= false; + buf ??= new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!; + + let r = new Color.Rand(state); + const initState = r.state; + + const oklch = Color.colors(r); + const rgb = Color.toRgbs(oklch); + const newState = r.state; + + await recolorLayers(data, rgb); + + updateBg(oklch); + updatePalette(oklch, rgb); + updateLabel(initState); + + if (firstLoad) { + await instantUpdateImage(side, await ensureComposed(buf, data)); + } else { + updateUrl(initState); + await animateUpdateImage(buf, side, data, done ?? (() => {})); + } + + return newState; +} + +type CanvasId = 'main' | 'aux'; + +function getCanvasCtx(id: CanvasId) { + const canvas = document.getElementById(id) as HTMLCanvasElement; + return canvas.getContext('2d')!; +} + +async function +instantUpdateImage(side: Side, data: ComposedData) { + getCanvasCtx('main').putImageData(data[`${side}Image`], 0, 0); +} + +type Done = () => void; + +async function +animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) { + const duration = 200; + + const main = getCanvasCtx('main'); + const aux = getCanvasCtx('aux'); + + document.documentElement.dataset.running = 'reroll'; + const cdata = await ensureComposed(buf, data); + redraw(aux, buf, cdata, side); + + aux.canvas.addEventListener('animationend', async () => { + await redraw(main, buf, cdata, side); + aux.canvas.style.removeProperty('animation'); + delete document.documentElement.dataset.running; + done(); + }); + + setTimeout(() => + aux.canvas.style.animation = `${duration}ms ease forwards fade-in` + ); +} + +async function +animateSwapImage(buf: Buffer, newSide: Side, data: ComposedData, done: Done) { + const duration = 400; + + const main = getCanvasCtx('main'); + const aux = getCanvasCtx('aux'); + + document.documentElement.dataset.running = 'swap'; + await redraw(aux, buf, data, newSide); + + aux.canvas.addEventListener('animationend', async () => { + const image = aux.getImageData(0, 0, WIDTH, HEIGHT); + main.putImageData(image, 0, 0); + + main.canvas.style.removeProperty('animation'); + aux.canvas.style.removeProperty('animation'); + delete document.documentElement.dataset.running; + done(); + }); + + main.canvas.style.animation = `${duration}ms ease swap1`; + aux.canvas.style.animation = `${duration}ms ease swap2`; +} + +function updateBg(cols: Color.Colors) { + document.documentElement.style.setProperty('--hue', `${cols.outer.h}`); +} + +function updateLabel(st: State) { + const stateLabel = document.getElementById('state'); + if (stateLabel) stateLabel.innerHTML = `${st}`; +} + +function updatePalette(oklch: Color.Colors, rgb: Color.Rgbs) { + const paletteObj = document.getElementById('palette') as HTMLObjectElement; + const palette = paletteObj.contentDocument as XMLDocument | null; + + if (!palette) return; + + palette.documentElement.style.setProperty('--hue', `${oklch.outer.h}`); + const get = (id: string) => palette.getElementById(id); + + for (const layer of Color.allLayers) { + let col = rgb[layer].css(); + let elem; + + // main group + if (elem = get(`i-${layer}`)) { + if (oklch[layer].l < 0.6) { + elem.classList.add('light'); elem.classList.remove('dark'); + } else { + elem.classList.add('dark'); elem.classList.remove('light'); + } + elem.style.setProperty('--col', col); + } + + // label + if (elem = get(`c-${layer}`)) elem.innerHTML = col; + // minor swatch, if applicable + if (elem = get(`s-${layer}`)) elem.style.setProperty('--col', col); + } +} + + +async function setup() { + message('loading layers…'); + + let data = await loadData().catch(e => { message(e, true); throw e }); + + let buf = new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!; + + let state = urlState(); + let side: Side = 'front'; + state = await applyState(data, { state, buf, firstLoad: true }); + + const reroll = document.getElementById('reroll')!; + const swap = document.getElementById('swap')!; + + addListeners(); + document.documentElement.dataset.ready = '1'; + + async function run(task: (k: Done) => Promise): Promise { + removeListeners(); + await task(addListeners); + } + + function updateFromUrl() { + run(async k => { + const newState = urlState(); + if (newState) { + state = await applyState(data, { side, state: newState, buf, done: k }); + } + }); + } + function runReroll() { + run(async k => { + state = await applyState(data, { side, state, buf, done: k }); + }); + } + function runSwap() { + run(async k => { + side = swapSide(side); + const cdata = await ensureComposed(buf, data); + await animateSwapImage(buf, side, cdata, k); + }); + } + + function addListeners() { + window.addEventListener('hashchange', updateFromUrl); + reroll.addEventListener('click', runReroll); + swap.addEventListener('click', runSwap); + } + function removeListeners() { + window.removeEventListener('hashchange', updateFromUrl); + reroll.removeEventListener('click', runReroll); + swap.removeEventListener('click', runSwap); + } +} + +document.addEventListener('DOMContentLoaded', setup); diff --git a/rainbow-quox/rand.ts b/rainbow-quox/rand.ts new file mode 100644 index 0000000..aae8bdb --- /dev/null +++ b/rainbow-quox/rand.ts @@ -0,0 +1,45 @@ +// https://stackoverflow.com/a/424445 thanks my dude + +export type State = number; + +const M = 0x80000000; +const A = 1103515245; +const C = 12345; + +export class Rand { + state: number; + + constructor(state?: State) { + this.state = typeof state == 'number' && !isNaN(state) ? + state : Math.floor(Math.random() * (M - 1)); + } + + #next(): number { return this.state = (A * this.state + C) % M; } + + #float(x?: number, y?: number): number { + const [lo, hi] = + x === undefined ? [0, 1] : + y === undefined ? [0, x] : + [Math.min(x, y), Math.max(x, y)]; + + return lo + this.#next() / (M - 1) * (hi - lo); + } + + int(): number; // whole int32 range + int(x: number): number; // [0, x) + int(x: number, y: number): number; // [x, y) + int(x?: number, y?: number): number { + return x === undefined ? this.#next() : Math.floor(this.#float(x, y)); + } + + float(): number; // [0, 1) + float(x: number): number; // [0, x) + float(x: number, y: number): number; // [x, y) + float(x?: number, y?: number): number { return this.#float(x, y); } + + choice(array: A[]): A { + return array[this.int(0, array.length)]!; + } + + boolean(): boolean { return this.float() > 0.5; } +} diff --git a/rainbow-quox/style.css b/rainbow-quox/style.css deleted file mode 100644 index 7818943..0000000 --- a/rainbow-quox/style.css +++ /dev/null @@ -1,148 +0,0 @@ -@import url(/fonts/muller/muller.css); - -@property --transition { - syntax: "*"; - inherits: true; - initial-value: none; -} - -* { box-sizing: border-box; } - -@media (prefers-reduced-motion: reduce) { - * { transition: none; } -} - -@keyframes swap1 { - 0% { transform: none; } - 49% { transform: translateX(-150vw); } - 50% { content-visibility: hidden; } - 100% { content-visibility: hidden; transform: none; } -} - -@keyframes swap2 { - 0% { content-visibility: hidden; transform: translateX(150vw); } - 49% { content-visibility: visible; opacity: 1; } - 100% { transform: none; opacity: 1; } -} - -@keyframes fade-in { - from { opacity: 0 } - to { opacity: 1 } -} - -:root, body { - min-height: 100vh; - margin: 0; -} - -:root { - --hue: 300; - --c-hue: calc(180 + var(--hue)); - align-items: center; justify-content: center; - - --font: Muller, sans-serif; - font-family: var(--font); - - --shadow-color: 6px 6px 2px oklch(0.4 0.2 var(--hue) / 0.45); - @media (prefers-color-scheme: dark) { - --shadow-color: 6px 6px 2px oklch(0.1 0.15 var(--hue) / 0.45); - } - - color-scheme: light dark; - background: - url(bright-squares.png), - linear-gradient(to bottom in oklch, - oklch(0.9 0.08 var(--hue)), - oklch(0.7 0.10 var(--hue))); - background-blend-mode: screen; - - @media (prefers-color-scheme: dark) { - background: - url(bright-squares.png), - linear-gradient(to bottom in oklch, - oklch(0.3 0.08 var(--hue)), - oklch(0.2 0.09 var(--hue))); - } - - width: 100vw; - overflow-x: hidden; -} - -body { - display: flex; - flex-flow: column; - justify-content: space-between; -} - -#pic-holder, #pic, #pic2 { - width: min(1000px, 75vw); - margin: auto; - aspect-ratio: 2000/1346; - flex: 1; -} - -#pic-holder { position: relative; } -#pic, #pic2 { position: absolute; inset: 0; } - -#pic2 { - opacity: 0; - pointer-events: none; - z-index: 2; -} - -#buttons { - flex: none; - display: flex; - margin: 0 2rem 2.5rem; - align-items: start; - z-index: 1; - translate: -2em -6em; -} - -button { - --fg: var(--c-hue); - --bg: var(--hue); - font: 700 20pt var(--font); - background: oklch(0.5 0.2 var(--bg)); - color: oklch(0.95 0.075 var(--fg)); - border: 3px solid oklch(0.2 0.05 var(--bg)); - padding: 0.2em 4em 0.2em 0.5em; - transform-origin: center right; - box-shadow: var(--shadow-color); - transition: all 0.4s ease; - - [data-ready] & { transform: rotate(-60deg); } - - :root:not([data-running]) &:active, - [data-running=swap] &#swap, - [data-running=reroll] &#reroll { - transform: rotate(-60deg) translate(-1em); - } - - + button { margin-left: -6.5em; } -} - -#back { - position: absolute; - top: 22px; left: 22px; - - a { - color: oklch(0.4 0.15 var(--c-hue)); - text-decoration: 3px solid underline; - text-decoration-color: oklch(0.6 0.1 var(--c-hue)); - } - - @media (prefers-color-scheme: dark) { - color: oklch(0.9 0.19 var(--c-hue)); - } -} - -/* #palette-holder { */ -/* overflow: auto; */ -/* } */ - -#palette-holder { - width: min(90vw, 1126px); - margin: 2.5rem auto 0; -} -#palette { width: 100%; } diff --git a/rainbow-quox/style.scss b/rainbow-quox/style.scss new file mode 100644 index 0000000..cb056ba --- /dev/null +++ b/rainbow-quox/style.scss @@ -0,0 +1,142 @@ +@import url(/fonts/muller/muller.css); + +@keyframes swap1 { + 0% { transform: none; } + 49% { transform: translateX(-150vw); } + 50% { content-visibility: hidden; } + 100% { content-visibility: hidden; transform: none; } +} + +@keyframes swap2 { + 0% { content-visibility: hidden; transform: translateX(150vw); } + 49% { content-visibility: visible; opacity: 1; } + 100% { transform: none; opacity: 1; } +} + +@keyframes fade-in { + from { opacity: 0 } + to { opacity: 1 } +} + + +@mixin transy { + transition: transform 0.25s cubic-bezier(.47,.74,.61,1.2); +} + +@mixin box { + font: 700 20pt var(--font); // respecify font family for