yummy.cricket/rainbow-quox/script/quox.ts

324 lines
9.2 KiB
TypeScript

import * as Color from './color.js';
import { History } from './history.js';
import * as Layer from './layer.js';
import * as Palette from './palette.js';
function message(msg: string, size = 100) {
const ctx = getCanvasCtx('main');
ctx.save();
ctx.clearRect(0, 0, Layer.WIDTH, Layer.HEIGHT);
ctx.font = `bold ${size}px Muller, sans-serif`;
ctx.textAlign = 'center';
ctx.fillText(msg, Layer.WIDTH/2, Layer.HEIGHT/2, Layer.WIDTH-10);
ctx.restore();
}
function urlState(): string | undefined {
const hash = document.location.hash?.substring(1);
if (hash != '' && hash !== undefined) return decodeURI(hash);
}
function updateUrl(seed: string): void {
history.replaceState({}, '', `#${encodeURI(seed)}`);
}
type ApplyStateOpts = {
seed: string,
side?: Layer.Side,
firstLoad?: boolean,
history?: History,
done?: Done,
};
async function
applyState(data: Layer.Data, opts: ApplyStateOpts): Promise<string> {
const { seed, history } = opts;
let { side, firstLoad, done } = opts;
side ??= 'front';
firstLoad ??= false;
done ??= () => {};
const rand = new Color.Rand(seed);
const cols = Color.colors(rand, Color.KNOWN[seed]);
const newSeed = rand.alphaNum();
await Layer.recolorAll(data, cols);
updateBg(cols);
updateSvgs(cols);
updateLabel(seed);
updateUrl(seed);
if (firstLoad) {
await instantUpdateImage(side, data);
done();
} else {
await animateUpdateImage(side, data, done);
}
if (history) history.addSave(seed);
return newSeed;
}
type CanvasId = 'main' | 'aux';
function getCanvasCtx(id: CanvasId) {
const canvas = document.getElementById(id) as HTMLCanvasElement;
return canvas.getContext('2d')!;
}
async function
instantUpdateImage(side: Layer.Side, data: Layer.Data) {
const cdata = await Layer.ensureComposed(getCanvasCtx('aux'), data);
getCanvasCtx('main').putImageData(cdata[`${side}Image`], 0, 0);
}
type Done = () => void;
const noAnim = matchMedia('(prefers-reduced-motion: reduce)');
async function
animateUpdateImage(side: Layer.Side, data: Layer.Data, done: Done) {
if (noAnim.matches) {
await instantUpdateImage(side, data);
done();
return;
}
const duration = 200;
const main = getCanvasCtx('main');
const aux = getCanvasCtx('aux');
document.documentElement.dataset.running = 'reroll';
const cdata = await Layer.ensureComposed(aux, data);
Layer.redraw(aux, cdata, side);
aux.canvas.addEventListener('animationend', () => {
Layer.redraw(main, 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(newSide: Layer.Side, data: Layer.ComposedData, done: Done) {
if (noAnim.matches) {
await instantUpdateImage(newSide, data);
done();
return;
}
const duration = 400;
const main = getCanvasCtx('main');
const aux = getCanvasCtx('aux');
document.documentElement.dataset.running = 'swap';
Layer.redraw(aux, data, newSide);
aux.canvas.addEventListener('animationend', () => {
const image = aux.getImageData(0, 0, Layer.WIDTH, Layer.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.hue}`);
}
function updateLabel(seed: string) {
const stateLabel = document.getElementById('current-name');
if (stateLabel) stateLabel.innerHTML = seed;
}
function updateSvgs(cols: Color.Colors) {
const paletteObj = document.getElementById('palette') as HTMLObjectElement;
const palette = paletteObj.contentDocument as XMLDocument | null;
if (palette) {
palette.documentElement.style.setProperty('--hue', `${cols.outer.hue}`);
const get = (id: string) => palette.getElementById(id);
for (const layer of Color.allLayers) {
const col = cols[layer].css();
let elem;
// main group
if ((elem = get(`i-${layer}`))) {
if (cols[layer].luma < 0.6) {
elem.classList.add('light'); elem.classList.remove('dark');
} else {
elem.classList.add('dark'); elem.classList.remove('light');
}
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);
}
}
}
function showHistory(history: History, data: Layer.Data,
opts: Omit<ApplyStateOpts, 'seed' | 'history'>) {
const list = document.getElementById('history-items');
if (!list) return;
list.innerHTML = '';
const { side, firstLoad, done } = opts;
for (const item of history.iterItems()) {
const elem = item.asHtml();
const allOpts = { side, firstLoad, done, seed: item.name, history };
elem.addEventListener('click', () => void applyState(data, allOpts));
list.appendChild(elem);
}
setTimeout(() => {
document.documentElement.dataset.state = 'history';
const elem = document.createElement('div');
elem.id = 'history-close-target';
elem.addEventListener('click', closeHistory);
document.body.appendChild(elem);
});
}
function closeHistory() {
document.getElementById('history-items')?.
scroll({ top: 0, left: 0, behavior: 'smooth' });
const field = document.getElementById('history-close-target');
if (field) field.parentElement?.removeChild(field);
document.documentElement.dataset.state = 'ready';
}
function download(seed: string) {
const colors = Color.colors(new Color.Rand(seed));
const blob = Palette.make(seed, colors);
// there must be a better way to push out a file than
// this autohotkey-ass nonsense
const elem = document.createElement('a');
elem.download = `quox-${seed}.gpl`;
const url = URL.createObjectURL(blob);
elem.href = url;
elem.click();
URL.revokeObjectURL(url);
}
async function setup() {
message('loading layers…');
const aux = getCanvasCtx('aux');
const data = await Layer.loadData(aux)
.catch(e => { message(`${e}`, 30); throw e });
const history = History.load();
let prevSeed = urlState() ?? new Color.Rand().alphaNum();
let seed =
await applyState(data, { seed: prevSeed, history, firstLoad: true });
let side: Layer.Side = 'front';
const reroll = document.getElementById('reroll')!;
const swap = document.getElementById('swap')!;
addListeners();
function asyncHandler(h: (e: Event) => Promise<void>): (e: Event) => void {
return (e: Event) => void h(e);
}
// these ones don't need to be toggled
document.getElementById('hideui')?.addEventListener('click', () => {
document.documentElement.dataset.state = 'fullquox';
});
document.getElementById('showui')?.addEventListener('click', () => {
document.documentElement.dataset.state = 'ready';
});
document.getElementById('history-button')?.addEventListener('click', () => {
// does this need the add/remove listeners dance
// actually does anything any more?
showHistory(history, data, { side });
});
document.getElementById('close-history')?.addEventListener('click', closeHistory);
document.getElementById('current-name')?.addEventListener('focusout', asyncHandler(async e => {
const space = String.raw`(\n|\s|<br>|&nbsp;)`;
const re = new RegExp(`^${space}+|${space}+$`, 'msgu');
const elem = e.target as HTMLElement;
let str = elem.innerText.replaceAll(re, '');
if (!str) str = new Color.Rand().alphaNum();
elem.innerText = str;
// todo allow images cos it's funny
prevSeed = seed;
seed = await applyState(data, { side, seed: str, history });
}));
document.getElementById('download-button')?.addEventListener('click', () => {
download(prevSeed);
});
document.documentElement.dataset.state = 'ready';
function run(task: (k: Done) => Promise<void>): void {
removeListeners();
void task(addListeners);
}
function updateFromUrl() {
run(async k => {
const newSeed = urlState();
if (newSeed) {
const opts = { history, side, seed: newSeed, done: k };
prevSeed = seed;
seed = await applyState(data, opts);
}
});
}
function runReroll() {
run(async k => {
prevSeed = seed;
seed = await applyState(data, { side, seed, history, done: k });
});
}
function runSwap() {
run(async k => {
side = Layer.swapSide(side);
const cdata = await Layer.ensureComposed(aux, data);
await animateSwapImage(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', () => void setup());