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); updateSvgs(oklch, rgb); updateLabel(initState); updateUrl(initState); if (firstLoad) { await instantUpdateImage(side, await ensureComposed(buf, data)); } else { 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 updateSvgs(oklch: Color.Colors, rgb: Color.Rgbs) { const paletteObj = document.getElementById('palette') as HTMLObjectElement; const palette = paletteObj.contentDocument as XMLDocument | null; if (palette) { 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); } } const showuiObj = document.getElementById('showui') as HTMLObjectElement; const showui = showuiObj.contentDocument as XMLDocument | null; if (showui) { showui.documentElement.style.setProperty('--hue', `${oklch.outer.h}`); } } 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(); // 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.documentElement.dataset.state = 'ready'; 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);