rainbow quox
This commit is contained in:
parent
e35f46003b
commit
e14bc51fff
16 changed files with 1021 additions and 802 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -12,3 +12,4 @@ _build
|
||||||
_tmp
|
_tmp
|
||||||
.directory
|
.directory
|
||||||
*~
|
*~
|
||||||
|
dist-newstyle
|
||||||
|
|
47
Makefile
47
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
|
PAGES = index.html pubkey.txt rainbow-quox/index.html
|
||||||
MEDIA = \
|
MEDIA = \
|
||||||
$(wildcard media/*.png) $(wildcard media/*.gif) $(wildcard media/*.webp) \
|
$(wildcard media/*.png) $(wildcard media/*.gif) $(wildcard media/*.webp) \
|
||||||
|
@ -8,6 +6,9 @@ MEDIA = \
|
||||||
$(wildcard rainbow-quox/front/*) $(wildcard rainbow-quox/back/*) \
|
$(wildcard rainbow-quox/front/*) $(wildcard rainbow-quox/back/*) \
|
||||||
rainbow-quox/palette.svg rainbow-quox/bright-squares.png \
|
rainbow-quox/palette.svg rainbow-quox/bright-squares.png \
|
||||||
rainbow-quox/back.svg
|
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))
|
SCRIPTS = $(patsubst %.ts,%.js,$(wildcard script/*.ts rainbow-quox/*.ts))
|
||||||
MISC = $(shell find .well-known -type f)
|
MISC = $(shell find .well-known -type f)
|
||||||
ALL = $(CSS) $(PAGES) $(MEDIA) $(SCRIPTS) $(MISC)
|
ALL = $(CSS) $(PAGES) $(MEDIA) $(SCRIPTS) $(MISC)
|
||||||
|
@ -31,41 +32,31 @@ upload: build
|
||||||
$(BUILDDIR)/ $(HOST):$(REMOTE_DIR)/
|
$(BUILDDIR)/ $(HOST):$(REMOTE_DIR)/
|
||||||
|
|
||||||
$(BUILDDIR)/%: %
|
$(BUILDDIR)/%: %
|
||||||
@echo $*
|
@echo '[copy] ' $<
|
||||||
@mkdir -p $(dir $@)
|
mkdir -p $(dir $@)
|
||||||
@cp $< $@
|
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 $< $@
|
|
||||||
|
|
||||||
$(BUILDDIR)/%.js: %.ts
|
$(BUILDDIR)/%.js: %.ts
|
||||||
|
@echo '[tsc] ' $<
|
||||||
tsc --strict --noUncheckedIndexedAccess --noEmitOnError \
|
tsc --strict --noUncheckedIndexedAccess --noEmitOnError \
|
||||||
--lib dom,es2023 --target es2015 \
|
--lib dom,es2023 --target es2015 \
|
||||||
--outDir $(dir $@) $^
|
--outDir $(dir $@) $^
|
||||||
|
|
||||||
$(BUILDDIR)/rainbow-quox/palette.svg: rainbow-quox/palette.svg.raku
|
$(BUILDDIR)/rainbow-quox/palette.svg: rainbow-quox/make-palette/*
|
||||||
raku $^ $@
|
@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:
|
clean:
|
||||||
|
@echo '[clean]'
|
||||||
$(RM) -r $(TMPDIR) $(BUILDDIR)
|
$(RM) -r $(TMPDIR) $(BUILDDIR)
|
||||||
|
|
||||||
.PHONY: clean all build upload
|
.PHONY: clean all build upload
|
||||||
|
|
||||||
|
.SILENT:
|
||||||
|
|
BIN
rainbow-quox/back/eyeshine.webp
(Stored with Git LFS)
Normal file
BIN
rainbow-quox/back/eyeshine.webp
(Stored with Git LFS)
Normal file
Binary file not shown.
|
@ -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<A>(f: (l: Layer) => A): Record<Layer, A> {
|
|
||||||
return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record<Layer, A>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Position = [x: number, y: number];
|
|
||||||
export type Positions = Record<Layer, Position>;
|
|
||||||
|
|
||||||
export async function loadPos(side: SideName): Promise<Positions> {
|
|
||||||
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<Layer, LayerInfo1>,
|
|
||||||
comp?: ImageData,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
type Rgb = [number, number, number];
|
|
||||||
type Rgbs = Record<Color.Layer, Rgb>;
|
|
||||||
|
|
||||||
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<ImageBitmap> {
|
|
||||||
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<ImageData> {
|
|
||||||
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<LayerInfo> {
|
|
||||||
const layers: Partial<Record<Layer, LayerInfo1>> = { };
|
|
||||||
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<Layer, ImageData>;
|
|
||||||
for (const l of allLayers) {
|
|
||||||
layers[l] = { data: images[l], pos: pos[l] };
|
|
||||||
}
|
|
||||||
return { layers: layers as Record<Layer, LayerInfo1> };
|
|
||||||
}
|
|
||||||
|
|
||||||
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<ImageData> {
|
|
||||||
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 };
|
|
|
@ -1,109 +1,142 @@
|
||||||
const rand: () => number = Math.random; // [todo]
|
import * as R from './rand.js';
|
||||||
|
|
||||||
function randBetween(x: number, y: number): number {
|
|
||||||
const lo = min(x, y), hi = max(x, y);
|
|
||||||
return lo + rand() * (hi - lo);
|
|
||||||
}
|
|
||||||
|
|
||||||
function oneOf<A>(...xs: A[]): A {
|
|
||||||
return xs[Math.floor(rand() * xs.length)]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = Math.max;
|
const max = Math.max;
|
||||||
const min = Math.min;
|
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';
|
type LD = 'light' | 'dark';
|
||||||
|
|
||||||
namespace Oklch {
|
export namespace Oklch {
|
||||||
export type Channel = 'l' | 'c' | 'h';
|
export type Channel = 'l' | 'c' | 'h';
|
||||||
export type Channels = Record<Channel, number>;
|
export type Channels = Record<Channel, number>;
|
||||||
export type ChannelMap = (x: number) => number;
|
export type ChannelMap = (x: number) => number;
|
||||||
export type ChannelMaps = Record<Channel, ChannelMap>;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Oklch {
|
function isLight(l: Luma): boolean { return l >= MINL_LIGHT; }
|
||||||
static lightFor(baseL: number): number { return randBetween(baseL, MAXL); }
|
|
||||||
|
|
||||||
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 {
|
lightFor(baseL: Luma): Luma { return this.float(baseL, MAXL); }
|
||||||
if (Oklch.isLight(l)) { return randBetween(baseC, MAXC_LIGHT); }
|
darkFor(baseL: Luma): Luma { return this.float(MINL, baseL); }
|
||||||
else { return randBetween(baseC, MAXC_DARK); }
|
|
||||||
|
brightFor(l: Luma, baseC: Chroma): Chroma {
|
||||||
|
return this.float(baseC, isLight(l) ? MAXC_LIGHT : MAXC_DARK);
|
||||||
}
|
}
|
||||||
|
|
||||||
static dullFor(l: number, baseC: number): number {
|
dullFor(l: Luma, baseC: Chroma): Chroma {
|
||||||
if (Oklch.isLight(l)) { return randBetween(baseC, MINC_LIGHT); }
|
return this.float(baseC, isLight(l) ? MINC_LIGHT : MINC_DARK);
|
||||||
else { return randBetween(baseC, MINC_DARK); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static analogous1(baseH: number): number {
|
analogous1(baseH: Hue): Hue {
|
||||||
const size = randBetween(MINH_SEP, 2 * MINH_SEP);
|
const size = this.float(MINH_SEP, 2 * MINH_SEP);
|
||||||
return rand() > 0.5 ? baseH + size : baseH - size;
|
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 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 sep = width / (count - 1);
|
||||||
const start = baseH - (width / 2);
|
const start = baseH - (width / 2);
|
||||||
const numbers = Array.from({length: count}, (_u, i) => start + i * sep);
|
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 {
|
complementary1(baseH: Hue): Hue {
|
||||||
return Oklch.analogous1((baseH + 180) % 360);
|
return this.analogous1((baseH + 180) % 360);
|
||||||
}
|
}
|
||||||
|
|
||||||
static complementary(baseH: number, count: number): number[] {
|
complementary(baseH: Hue, count: number): Hue[] {
|
||||||
const angle = randBetween(180 - MAXH_COMPL/2, 180 + MAXH_COMPL/2);
|
const angle = this.float(180 - MAXH_COMPL/2, 180 + MAXH_COMPL/2);
|
||||||
return Oklch.analogous(baseH + angle, count);
|
return this.analogous(baseH + angle, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
static triad(baseH: number): [number, number] {
|
triad(baseH: Hue): [Hue, Hue] {
|
||||||
const angle = randBetween(120 - MAXH_TRIAD/2, 120 + MAXH_TRIAD/2);
|
const angle = this.float(120 - MAXH_TRIAD/2, 120 + MAXH_TRIAD/2);
|
||||||
return [baseH - angle, baseH + angle];
|
return [baseH - angle, baseH + angle];
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly l: number; readonly c: number; readonly h: number;
|
baseLuma(ld?: LD): Luma {
|
||||||
|
|
||||||
static baseLuma(ld?: LD): number {
|
|
||||||
if (ld == 'light') {
|
if (ld == 'light') {
|
||||||
return randBetween(MINL_LIGHT, MAXL);
|
return this.float(MINL_LIGHT, MAXL);
|
||||||
} else if (ld == 'dark') {
|
} else if (ld == 'dark') {
|
||||||
return randBetween(MINL, MAXL_DARK);
|
return this.float(MINL, MAXL_DARK);
|
||||||
} else {
|
} else {
|
||||||
return randBetween(MINL, MAXL);
|
return this.float(MINL, MAXL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static baseChroma(l: number): number {
|
baseChroma(l: Luma): Chroma {
|
||||||
if (l >= MINL_LIGHT) {
|
if (l >= MINL_LIGHT) {
|
||||||
return randBetween(MINC_LIGHT, MAXC_LIGHT);
|
return this.float(MINC_LIGHT, MAXC_LIGHT);
|
||||||
} else {
|
} 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(cs: Oklch.Channels);
|
||||||
constructor(l: number, c: number, h: number);
|
constructor(ll: Luma | Oklch.Channels | Rand, cc?: Chroma | LD, hh?: Hue) {
|
||||||
constructor(lcsld?: number | Oklch.Channels | LD,
|
if (hh !== undefined) {
|
||||||
cc?: number, hh?: number) {
|
this.l = ll as Luma;
|
||||||
if (typeof lcsld == 'string' || lcsld == undefined) {
|
this.c = cc as Chroma;
|
||||||
this.l = Oklch.baseLuma(lcsld as LD | undefined);
|
this.h = hh as Hue;
|
||||||
this.c = Oklch.baseChroma(this.l);
|
} else if (typeof ll == 'object' && 'l' in ll) {
|
||||||
this.h = Oklch.baseHue();
|
const {l, c, h} = ll as Oklch.Channels;
|
||||||
} else if (cc == undefined && hh == undefined) {
|
|
||||||
const {l, c, h} = lcsld as Oklch.Channels;
|
|
||||||
this.l = l; this.c = c; this.h = h;
|
this.l = l; this.c = c; this.h = h;
|
||||||
} else {
|
} 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})`; }
|
else { return `oklch(${l}% ${c}% ${h})`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
with(lch: Partial<Record<Oklch.Channel, number | Oklch.ChannelMap>>): Oklch {
|
with(maps: Oklch.With): Oklch {
|
||||||
function call(comp: undefined | number | Oklch.ChannelMap, x: number) {
|
function call(comp: Oklch.With1, x: number) {
|
||||||
if (comp == undefined) { return x; }
|
switch (typeof comp) {
|
||||||
else if (typeof comp == 'function') { return comp(x); }
|
case 'number': return comp;
|
||||||
else { return comp as number; }
|
case 'function': return comp(x);
|
||||||
|
default: return x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return new Oklch({
|
return new Oklch({
|
||||||
l: call(lch.l, this.l),
|
l: call(maps.l, this.l),
|
||||||
c: call(lch.c, this.c),
|
c: call(maps.c, this.c),
|
||||||
h: call(lch.h, this.h),
|
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 SchemeType = 'triad' | 'fin-belly' | 'fin-body';
|
||||||
|
|
||||||
export type OuterLayer = 'outer' | 'spines' | 'vitiligo1';
|
export type OuterLayer = 'outer' | 'spines' | 'vitiligo1';
|
||||||
|
@ -183,84 +197,90 @@ export function makeColorInfo<A>(f: (l: Layer) => A): Record<Layer, A> {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function colors(): Scheme {
|
export function colors(r: Rand = new Rand()): Scheme {
|
||||||
const outer = new Oklch('dark');
|
const outer = new Oklch(r, 'dark');
|
||||||
let outerCols: OuterCols =
|
let outerCols: OuterCols =
|
||||||
{ outer, spines: mkSpines(outer), vitiligo1: mkVitiligo(outer) };
|
{ outer, spines: mkSpines(r, outer), vitiligo1: mkVitiligo(r, outer) };
|
||||||
|
|
||||||
const stripes = mkStripes();
|
const stripes = mkStripes(r);
|
||||||
let sockCols: SockCols = { stripes, cuffs: mkCuffs(stripes) };
|
let sockCols: SockCols = { stripes, cuffs: mkCuffs(r, stripes) };
|
||||||
|
|
||||||
let finCols: FinCols, bellyCols: BellyCols, type: SchemeType;
|
let finCols: FinCols, bellyCols: BellyCols, type: SchemeType;
|
||||||
const whichBody = rand();
|
const whichBody = r.float();
|
||||||
if (whichBody > 2/3) {
|
if (whichBody > 2/3) {
|
||||||
type = 'triad';
|
type = 'triad';
|
||||||
const [f, b] = Oklch.triad(outer.h);
|
const [f, b] = r.triad(outer.h);
|
||||||
finCols = mkFins(f, outer); bellyCols = mkBelly(b);
|
finCols = mkFins(r, f, outer); bellyCols = mkBelly(r, b);
|
||||||
} else if (whichBody > 1/3) {
|
} else if (whichBody > 1/3) {
|
||||||
type = 'fin-belly';
|
type = 'fin-belly';
|
||||||
const [f, b] = Oklch.complementary(outer.h, 2);
|
const [f, b] = r.complementary(outer.h, 2);
|
||||||
finCols = mkFins(f!, outer); bellyCols = mkBelly(b!);
|
finCols = mkFins(r, f!, outer); bellyCols = mkBelly(r, b!);
|
||||||
} else {
|
} else {
|
||||||
type = 'fin-body';
|
type = 'fin-body';
|
||||||
finCols = mkFins(Oklch.analogous1(outer.h), outer);
|
finCols = mkFins(r, r.analogous1(outer.h), outer);
|
||||||
bellyCols = mkBelly(Oklch.complementary1(outer.h));
|
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);
|
return merge(outerCols, sockCols, finCols, bellyCols, miscCols, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function mkSpines(outer: Oklch): Oklch {
|
function mkSpines(r: Rand, outer: Oklch): Oklch {
|
||||||
return outer.with({
|
return outer.with({
|
||||||
l: x => x * 0.8,
|
l: x => x * 0.8,
|
||||||
c: x => x * 1.1,
|
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({
|
return outer.with({
|
||||||
l: x => randBetween(max(x, 0.94), 0.985), // exception to MAXL
|
l: x => r.float(max(x, 0.94), 0.985), // exception to MAXL
|
||||||
c: x => randBetween(min(x, 0.1), MINC_LIGHT),
|
c: x => r.float(min(x, 0.1), MINC_LIGHT),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mkStripes(): Oklch {
|
function mkStripes(r: Rand): Oklch {
|
||||||
return new Oklch({
|
return new Oklch({
|
||||||
l: randBetween(0.8, MAXL),
|
l: r.float(0.8, MAXL),
|
||||||
c: randBetween(MINC_LIGHT, MAXC_LIGHT),
|
c: r.float(MINC_LIGHT, MAXC_LIGHT),
|
||||||
h: rand() * 360
|
h: r.baseHue(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mkCuffs(sock: Oklch): Oklch {
|
function mkCuffs(r: Rand, sock: Oklch): Oklch {
|
||||||
return sock.with({
|
return sock.with({
|
||||||
l: x => randBetween(x * 0.85, x * 0.65),
|
l: l => r.float(l * 0.85, l * 0.65),
|
||||||
c: x => randBetween(x, MAXC_LIGHT),
|
c: c => r.float(c, MAXC_LIGHT),
|
||||||
h: x => randBetween(x + 8, x - 8),
|
h: h => r.float(h + 8, h - 8),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mkFins(h: number, outer: Oklch): FinCols {
|
function mkFins(r: Rand, h: Hue, outer: Oklch): FinCols {
|
||||||
const [fin1Hue, fin2Hue, fin3Hue] = Oklch.analogous(h, 3);
|
const [fin1Hue, fin2Hue, fin3Hue] = r.analogous(h, 3);
|
||||||
const [ll, cc] = oneOf(
|
const direction: 'lighter' | 'darker' = r.choice(['lighter', 'darker']);
|
||||||
[Oklch.lightFor, Oklch.dullFor], [Oklch.darkFor, Oklch.brightFor]);
|
|
||||||
|
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 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 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 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 };
|
return { fins1, fins2, fins3, vitiligo4 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mkBelly(h: number): BellyCols {
|
function mkBelly(r: Rand, h: Hue): BellyCols {
|
||||||
const [belly1Hue, belly2Hue] = Oklch.analogous(h, 2);
|
const [belly1Hue, belly2Hue] = r.analogous(h, 2);
|
||||||
const belly1 = new Oklch({
|
const belly1 = new Oklch({
|
||||||
l: randBetween(0.7, MAXL),
|
l: r.float(0.7, MAXL),
|
||||||
c: Oklch.baseChroma(1),
|
c: r.baseChroma(1),
|
||||||
h: belly1Hue!
|
h: belly1Hue!
|
||||||
});
|
});
|
||||||
const belly2 = belly1.with({
|
const belly2 = belly1.with({
|
||||||
|
@ -268,33 +288,33 @@ function mkBelly(h: number): BellyCols {
|
||||||
c: x => x * 0.9,
|
c: x => x * 0.9,
|
||||||
h: belly2Hue!,
|
h: belly2Hue!,
|
||||||
});
|
});
|
||||||
const vitiligo3 = mkVitiligo(belly1);
|
const vitiligo3 = mkVitiligo(r, belly1);
|
||||||
const vitiligo2 = mkVitiligo(belly2);
|
const vitiligo2 = mkVitiligo(r, belly2);
|
||||||
return { belly1, belly2, vitiligo2, vitiligo3 };
|
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({
|
const masks = new Oklch({
|
||||||
l: randBetween(0.8, MAXL),
|
l: r.float(0.8, MAXL),
|
||||||
c: randBetween(0.01, 0.06),
|
c: r.float(0.01, 0.06),
|
||||||
h: Oklch.analogous1(oneOf(o.outer, b.belly1, f.fins1).h)
|
h: r.analogous1(r.choice([o.outer, b.belly1, f.fins1]).h)
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
masks,
|
masks,
|
||||||
eyes: new Oklch({
|
eyes: new Oklch({
|
||||||
l: Oklch.baseLuma('light'),
|
l: r.baseLuma('light'),
|
||||||
c: randBetween(0.28, MAXC_LIGHT),
|
c: r.float(0.28, MAXC_LIGHT),
|
||||||
h: oneOf(Oklch.analogous1, Oklch.complementary1)(o.outer.h)
|
h: r.boolean() ? r.analogous1(o.outer.h) : r.complementary1(o.outer.h)
|
||||||
}),
|
}),
|
||||||
claws: masks.with({
|
claws: masks.with({
|
||||||
l: x => min(MAXL, x + randBetween(0, 0.1)),
|
l: x => min(MAXL, x + r.float(0, 0.1)),
|
||||||
c: randBetween(0.01, 0.06),
|
c: r.float(0.01, 0.06),
|
||||||
h: Oklch.analogous1,
|
h: h => r.analogous1(h),
|
||||||
}),
|
}),
|
||||||
lines: new Oklch({
|
lines: new Oklch({
|
||||||
l: randBetween(0.01, 0.06),
|
l: r.float(0.01, 0.06),
|
||||||
c: Oklch.baseChroma(0),
|
c: r.baseChroma(0),
|
||||||
h: Oklch.analogous1(o.outer.h)
|
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
|
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<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 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)}`;
|
||||||
|
}
|
||||||
|
|
BIN
rainbow-quox/front/eyeshine.webp
(Stored with Git LFS)
Normal file
BIN
rainbow-quox/front/eyeshine.webp
(Stored with Git LFS)
Normal file
Binary file not shown.
|
@ -6,20 +6,20 @@
|
||||||
<title>rainbow quox</title>
|
<title>rainbow quox</title>
|
||||||
|
|
||||||
<link rel=stylesheet href=style.css>
|
<link rel=stylesheet href=style.css>
|
||||||
<script src=canvas.js type=module></script>
|
<script src=quox.js type=module></script>
|
||||||
|
|
||||||
<div id=buttons>
|
<div id=buttons>
|
||||||
<button id=reroll>new quox</button>
|
<button id=reroll>new quox</button>
|
||||||
<button id=swap>swap view</button>
|
<button id=swap>swap view</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id=state-message class=empty> quox #<span id=state>0</span> </div>
|
||||||
|
|
||||||
<div id=pic-holder>
|
<div id=pic-holder>
|
||||||
<canvas id=pic width=1000 height=673>
|
<canvas id=main width=1000 height=673>
|
||||||
if the canvas isn't working,
|
the canvas failed to load. sorry.
|
||||||
the old version is <a href=old.html>here</a>
|
|
||||||
</canvas>
|
|
||||||
<canvas id=pic2 width=1000 height=673>
|
|
||||||
</canvas>
|
</canvas>
|
||||||
|
<canvas id=aux width=1000 height=673></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id=palette-holder>
|
<div id=palette-holder>
|
||||||
|
|
9
rainbow-quox/make-palette/cabal.project
Normal file
9
rainbow-quox/make-palette/cabal.project
Normal file
|
@ -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
|
12
rainbow-quox/make-palette/make-palette.cabal
Normal file
12
rainbow-quox/make-palette/make-palette.cabal
Normal file
|
@ -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
|
147
rainbow-quox/make-palette/make-palette.hs
Normal file
147
rainbow-quox/make-palette/make-palette.hs
Normal file
|
@ -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
|
40
rainbow-quox/palette.scss
Normal file
40
rainbow-quox/palette.scss
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -1,133 +0,0 @@
|
||||||
unit sub MAIN($out);
|
|
||||||
|
|
||||||
|
|
||||||
my $doc = q:to/EOP/;
|
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="1152" height="179"
|
|
||||||
viewBox="-53 -1 1151 175"
|
|
||||||
preserveAspectRatio="xMinYMin meet">
|
|
||||||
|
|
||||||
<style><![CDATA[
|
|
||||||
@import url(../fonts/muller/muller.css);
|
|
||||||
@import url(../fonts/pragmatapro/pragmatapro.css);
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--hue: 300;
|
|
||||||
color-scheme: light dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
rect, .textbg {
|
|
||||||
filter: drop-shadow(6px 6px 2px oklch(0.4 0.2 var(--hue) / 0.45));
|
|
||||||
transition: fill 0.2s ease;
|
|
||||||
fill: lightgray;
|
|
||||||
stroke: black;
|
|
||||||
stroke-width: 1;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
filter: drop-shadow(6px 6px 2px oklch(0.1 0.15 var(--hue) / 0.45));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
text {
|
|
||||||
font-family: 'Muller', sans-serif;
|
|
||||||
text-anchor: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item { --text: black; }
|
|
||||||
.item :is(text, use) { fill: var(--text); }
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hex {
|
|
||||||
font-family: monospace;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
]]></style>
|
|
||||||
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 ~= '</svg>';
|
|
||||||
|
|
||||||
$out.IO.spurt: $doc;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
sub make-item($layer, :$display = $layer, :$use?, :$upper?) {
|
|
||||||
state $index = 0;
|
|
||||||
my $str =
|
|
||||||
qq[<g class="item" id="i-$layer" transform="translate({$index * 100} 0)">\n];
|
|
||||||
with $upper {
|
|
||||||
$str ~= qq{}
|
|
||||||
<rect width="80" height="25" id="s-$upper" />
|
|
||||||
<rect width="80" height="50" y="30" id="s-$layer" />
|
|
||||||
};
|
|
||||||
} orwith $use {
|
|
||||||
$str ~= qq{}
|
|
||||||
<use href="#s-$use" />
|
|
||||||
<rect width="80" height="50" y="30" id="s-$layer" />
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
$str ~= qq{ <rect width="80" height="80" id="s-$layer" /> };
|
|
||||||
}
|
|
||||||
|
|
||||||
$str ~= qq{}
|
|
||||||
<polygon id="p-$layer" class="textbg" points="0,85 80,85 28,175 -52,175" />
|
|
||||||
};
|
|
||||||
|
|
||||||
with $upper {
|
|
||||||
$str ~= qq{}
|
|
||||||
<text class="name" x="20" y="100" transform="rotate(-60 20 100)">
|
|
||||||
$display
|
|
||||||
</text>
|
|
||||||
<text id="c-$layer" class="hex" x="42" y="102" transform="rotate(-60 42 102)">
|
|
||||||
#??????
|
|
||||||
</text>
|
|
||||||
<text id="c-$upper" class="hex" x="64" y="102" transform="rotate(-60 64 102)">
|
|
||||||
#??????
|
|
||||||
</text>
|
|
||||||
};
|
|
||||||
} orwith $use {
|
|
||||||
$str ~= qq{}
|
|
||||||
<text class="name" x="20" y="100" transform="rotate(-60 20 100)">
|
|
||||||
$layer
|
|
||||||
</text>
|
|
||||||
<text id="c-$layer" class="hex" x="42" y="102" transform="rotate(-60 42 102)">
|
|
||||||
#??????
|
|
||||||
</text>
|
|
||||||
<use href="#c-$use" />
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
$str ~= qq{}
|
|
||||||
<text class="name" x="25" y="100" transform="rotate(-60 25 100)">
|
|
||||||
$layer
|
|
||||||
</text>
|
|
||||||
<text id="c-$layer" class="hex" x="47" y="102" transform="rotate(-60 47 102)">
|
|
||||||
#??????
|
|
||||||
</text>
|
|
||||||
};
|
|
||||||
}
|
|
||||||
$str ~= qq{ </g> };
|
|
||||||
|
|
||||||
$index++;
|
|
||||||
|
|
||||||
$doc ~= $str
|
|
||||||
}
|
|
378
rainbow-quox/quox.ts
Normal file
378
rainbow-quox/quox.ts
Normal file
|
@ -0,0 +1,378 @@
|
||||||
|
import * as Color from './color.js';
|
||||||
|
|
||||||
|
type State = Color.Rand.State;
|
||||||
|
|
||||||
|
|
||||||
|
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.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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImageData(url: string, buf?: Buffer): Promise<ImageData> {
|
||||||
|
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<A>(f: (l: Layer) => A): Record<Layer, A> {
|
||||||
|
return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record<Layer, A>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function loadLayers(dir: string): Promise<Record<Layer, ImageData>> {
|
||||||
|
let buf = makeBufferIfLocks(WIDTH, HEIGHT);
|
||||||
|
return makeLayerInfoAsync(l => loadImageData(`./${dir}/${l}.webp`, buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type Position = [x: number, y: number];
|
||||||
|
type Positions = Record<Layer, Position>;
|
||||||
|
|
||||||
|
async function loadPos(dir: string): Promise<Positions> {
|
||||||
|
return (await fetch(`./${dir}/pos.json`)).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Side = 'front' | 'back';
|
||||||
|
|
||||||
|
function swapSide(s: Side): Side {
|
||||||
|
return s == 'front' ? 'back' : 'front';
|
||||||
|
}
|
||||||
|
|
||||||
|
type SideData = Record<Layer, [ImageData, Position]>;
|
||||||
|
|
||||||
|
type LayerData = {
|
||||||
|
front: SideData, back: SideData,
|
||||||
|
frontImage?: ImageData, backImage?: ImageData,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComposedData = Required<LayerData>;
|
||||||
|
|
||||||
|
async function loadData(): Promise<LayerData> {
|
||||||
|
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<ImageData> {
|
||||||
|
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<ComposedData> {
|
||||||
|
let { front, back } = data;
|
||||||
|
data.frontImage ??= await composeLayers(front);
|
||||||
|
data.backImage ??= await composeLayers(back);
|
||||||
|
return data as ComposedData;
|
||||||
|
|
||||||
|
function composeLayers(sdata: SideData): Promise<ImageData> {
|
||||||
|
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<ApplyStateOpts>): Promise<State> {
|
||||||
|
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<void>): Promise<void> {
|
||||||
|
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);
|
45
rainbow-quox/rand.ts
Normal file
45
rainbow-quox/rand.ts
Normal file
|
@ -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<A>(array: A[]): A {
|
||||||
|
return array[this.int(0, array.length)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean(): boolean { return this.float() > 0.5; }
|
||||||
|
}
|
|
@ -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%; }
|
|
142
rainbow-quox/style.scss
Normal file
142
rainbow-quox/style.scss
Normal file
|
@ -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 <button>
|
||||||
|
color: oklch(0.95 0.075 var(--c-hue));
|
||||||
|
background: oklch(0.5 0.2 var(--hue));
|
||||||
|
border: 3px solid oklch(0.2 0.05 var(--hue));
|
||||||
|
box-shadow: var(--shadow-color);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer layout {
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--hue: 300;
|
||||||
|
--c-hue: calc(180 + var(--hue));
|
||||||
|
|
||||||
|
--font: Muller, sans-serif;
|
||||||
|
font-family: var(--font);
|
||||||
|
|
||||||
|
@function bg($lcs) {
|
||||||
|
$lg: to bottom in oklch;
|
||||||
|
@each $l, $c in $lcs { $lg: $lg, oklch($l $c var(--hue)); }
|
||||||
|
@return url(bright-squares.png), linear-gradient($lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
background: bg([0.9 0.08, 0.7 0.1]);
|
||||||
|
--shadow-color: 6px 6px 0 oklch(0.4 0.2 var(--hue) / 0.45);
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: bg([0.3 0.08, 0.2 0.09]);
|
||||||
|
--shadow-color: 6px 6px 0 oklch(0.1 0.15 var(--hue) / 0.45);
|
||||||
|
}
|
||||||
|
background-blend-mode: screen;
|
||||||
|
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1em;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pic-holder, #main, #aux {
|
||||||
|
width: min(1000px, 75vw);
|
||||||
|
margin: auto;
|
||||||
|
aspect-ratio: 2000/1346;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pic-holder { position: relative; }
|
||||||
|
#main, #aux { position: absolute; inset: 0; }
|
||||||
|
|
||||||
|
#aux {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#buttons {
|
||||||
|
position: absolute; top: -5em; left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer layering {
|
||||||
|
#main { z-index: 0; }
|
||||||
|
#aux { z-index: 1; }
|
||||||
|
|
||||||
|
#buttons, #state-message, #palette-holder { z-index: 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer a {
|
||||||
|
|
||||||
|
button {
|
||||||
|
@include box;
|
||||||
|
transform-origin: center right;
|
||||||
|
padding-right: 3em;
|
||||||
|
|
||||||
|
$rotate: rotate(-60deg);
|
||||||
|
transform: $rotate;
|
||||||
|
&:not(:first-child) { margin-left: -5em; }
|
||||||
|
|
||||||
|
:root:not([data-running]) &:active,
|
||||||
|
[data-running=swap] &#swap,
|
||||||
|
[data-running=reroll] &#reroll {
|
||||||
|
transform: $rotate translate(-1.5em);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#back { position: absolute; top: 22px; left: 22px; }
|
||||||
|
|
||||||
|
#palette-holder {
|
||||||
|
width: min(90vw, 1126px);
|
||||||
|
margin: 2.5rem auto 0;
|
||||||
|
}
|
||||||
|
#palette { width: 100%; }
|
||||||
|
|
||||||
|
#state-message {
|
||||||
|
@include box;
|
||||||
|
position: absolute; top: 1em; right: -1em;
|
||||||
|
padding-right: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer come-in {
|
||||||
|
button, #state-message, #palette { @include transy; }
|
||||||
|
|
||||||
|
:root:not([data-ready]) {
|
||||||
|
button { transform: none; }
|
||||||
|
#state-message { transform: translateX(100%); }
|
||||||
|
#palette { transform: translateY(125%); }
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue