unpredictable / app.js
shreyask's picture
Share card links to the Space page
8acd11e verified
Raw History Blame Contribute Delete
25.8 kB
// src/calibration.json
var calibration_default = {
randomWalk: 0.8068229166666667,
measured: {
"Timestral-5M": {
randomWalk: {
mean: 0.8068229166666667,
min: 0.7520833333333333,
max: 0.8708333333333333,
n: 20
},
sine: {
mean: 0.930885416666667,
min: 0.7916666666666666,
max: 0.9895833333333334,
n: 20
},
flatThenJump: {
mean: 0.8483333333333333,
min: 0.6854166666666667,
max: 0.9854166666666667,
n: 20
},
noise: {
mean: 0.7763020833333334,
min: 0.7572916666666667,
max: 0.7927083333333333,
n: 20
}
},
"Timestral-28M": {
randomWalk: {
mean: 0.8169270833333334,
min: 0.746875,
max: 0.8802083333333334,
n: 20
},
sine: {
mean: 0.9510416666666668,
min: 0.884375,
max: 0.9979166666666667,
n: 20
},
flatThenJump: {
mean: 0.84734375,
min: 0.6552083333333333,
max: 0.9947916666666666,
n: 20
},
noise: {
mean: 0.80234375,
min: 0.7854166666666667,
max: 0.8260416666666667,
n: 20
}
}
}
};
// src/config.ts
var SAMPLE_HZ = 32;
var PATCH = 32;
var CONTEXT = 512;
var NQ = 21;
var WARMUP = 2 * SAMPLE_HZ;
var PLAY = 30 * SAMPLE_HZ;
var LO = 2;
var HI = 18;
var OUTER_LO = 0;
var OUTER_HI = 20;
var MEDIAN = 10;
var MODELS = {
normal: { name: "Timestral-5M", file: "models/timestral-5m.onnx", bytes: 21212151, backend: "wasm" },
hard: { name: "Timestral-28M", file: "models/timestral-28m.onnx", bytes: 111840101, backend: "webgpu" }
};
var REFERENCE = calibration_default.randomWalk;
var SHARE_URL = "hf.co/spaces/shreyask/unpredictable";
// src/game/sampler.ts
function createSampler(hz = SAMPLE_HZ, lagMs = 50) {
const dt = 1e3 / hz;
let events = [];
let next = NaN;
const valueAt = (t) => {
let i = events.length - 1;
while (i > 0 && events[i].t > t) i--;
const a = events[i], b = events[i + 1];
if (!b || t <= a.t) return a.y;
return a.y + (b.y - a.y) * (t - a.t) / (b.t - a.t);
};
return {
push(tMs, y) {
if (events.length && tMs <= events.at(-1).t) return;
events.push({ t: tMs, y });
if (Number.isNaN(next)) next = tMs;
},
drain(nowMs) {
const out = [];
if (!events.length) return out;
while (next <= nowMs - lagMs) {
out.push(valueAt(next));
next += dt;
}
let keep = events.length - 1;
while (keep > 0 && events[keep].t > next) keep--;
events = events.slice(keep);
return out;
},
reset() {
events = [];
next = NaN;
}
};
}
// src/game/round.ts
var bandFor = (i, warmup = WARMUP, patch = PATCH) => warmup + Math.floor((i - warmup) / patch) * patch;
function createRound({ warmup = WARMUP, play: play2 = PLAY, patch = PATCH, context = CONTEXT } = {}) {
const samples = [];
const bands = /* @__PURE__ */ new Map();
const pending = /* @__PURE__ */ new Set();
const end = warmup + play2;
return {
samples,
bands,
get done() {
return samples.length >= end;
},
push(y) {
if (samples.length >= end) return null;
samples.push(Math.min(1, Math.max(0, y)));
const n = samples.length;
if (n >= warmup && n < end && (n - warmup) % patch === 0) {
pending.add(n);
return { issue: n, context: samples.slice(Math.max(0, n - context)) };
}
return null;
},
deliver(issue, q) {
if (!pending.delete(issue)) return;
bands.set(issue, { issue, q, arrived: samples.length });
},
result() {
let caught = 0, scored = 0, unscored = 0;
const escapes = [];
for (let i = warmup; i < Math.min(samples.length, end); i++) {
const b = bands.get(bandFor(i, warmup, patch));
if (!b || i < b.arrived) {
unscored++;
continue;
}
const h2 = i - b.issue;
const y = samples[i];
scored++;
if (y >= b.q[h2 * NQ + LO] && y <= b.q[h2 * NQ + HI]) caught++;
else escapes.push(i);
}
return { caught, scored, unscored, escapes, caughtPct: scored ? caught / scored : null };
}
};
}
// src/model/forecaster.ts
var CACHE_NAME = "unpredictable-models-v1";
function pickBackend(preferred, hasWebGPU) {
return preferred === "webgpu" && !hasWebGPU ? "wasm" : preferred;
}
async function detectWebGPU() {
try {
return !!await navigator.gpu?.requestAdapter();
} catch {
return false;
}
}
async function loadModelBytes(file, onProgress, expectedBytes) {
const url = new URL(file, location.href).href;
let cache2 = null;
try {
cache2 = await caches.open(CACHE_NAME);
const hit = await cache2.match(url);
if (hit) {
const buf = await hit.arrayBuffer();
if (expectedBytes === void 0 || buf.byteLength === expectedBytes) return buf;
await cache2.delete(url).catch(() => false);
}
} catch {
cache2 = null;
}
const res = await fetch(url);
if (!res.ok || !res.body) throw new Error(`model download failed (HTTP ${res.status})`);
const total = Number(res.headers.get("content-length")) || 0;
const reader = res.body.getReader();
const parts = [];
let loaded = 0;
for (; ; ) {
const { done, value } = await reader.read();
if (done) break;
parts.push(value);
loaded += value.length;
onProgress?.(loaded, expectedBytes || total || loaded);
}
const bytes = new Uint8Array(loaded);
let o = 0;
for (const p of parts) bytes.set(p, (o += p.length) - p.length);
try {
await cache2?.put(url, new Response(bytes.slice()));
} catch {
}
return bytes.buffer;
}
async function createForecaster(file, preferred, onProgress, opts = {}) {
const backend = pickBackend(preferred, opts.hasWebGPU ?? await detectWebGPU());
const bytes = await loadModelBytes(file, onProgress, opts.expectedBytes);
opts.onStage?.("starting");
const worker = opts.makeWorker ? opts.makeWorker() : new Worker(new URL("./worker.js", location.href), { type: "module" });
let next = 0;
let dead = null;
let used = backend;
const waiting = /* @__PURE__ */ new Map();
let rejectLoaded = () => {
};
const fail = (err) => {
dead ??= err;
rejectLoaded(dead);
for (const w of waiting.values()) w.reject(dead);
waiting.clear();
};
const loaded = new Promise((resolve, reject) => {
rejectLoaded = reject;
worker.onmessage = (e) => {
const m = e.data;
if (m.type === "loaded") {
used = m.backend;
return resolve();
}
if (m.type === "result") {
waiting.get(m.id)?.resolve({ quantiles: m.quantiles, ms: m.ms });
waiting.delete(m.id);
} else if (m.type === "error") {
if (m.id === void 0) return fail(new Error(m.message));
waiting.get(m.id)?.reject(new Error(m.message));
waiting.delete(m.id);
}
};
worker.onerror = (e) => fail(new Error(e.message || "worker crashed"));
worker.onmessageerror = () => fail(new Error("worker message could not be deserialized"));
});
worker.postMessage({ type: "load", url: file, backend, bytes }, [bytes]);
try {
await loaded;
} catch (err) {
worker.terminate();
throw err;
}
return {
backend: used,
get dead() {
return dead !== null;
},
forecast(context) {
if (dead) return Promise.reject(dead);
const id = next++;
const ctx = Float64Array.from(context);
return new Promise((resolve, reject) => {
waiting.set(id, { resolve, reject });
worker.postMessage({ type: "forecast", id, context: ctx }, [ctx.buffer]);
});
},
close() {
fail(new Error("forecaster closed"));
worker.terminate();
}
};
}
// src/ui/geometry.ts
function makeView(width, height) {
const penX = 0.6 * width;
const pad = Math.round(height * 0.06);
return { width, height, penX, pxPerSample: penX / (6 * SAMPLE_HZ), top: pad, plotH: height - 2 * pad };
}
var toY = (v, view) => view.top + (1 - v) * view.plotH;
var sampleX = (i, n, view) => view.penX - (n - 1 - i) * view.pxPerSample;
function bandPolygon(q, lo, hi, issue, n, view) {
const steps = q.length / NQ;
const upper = [];
const lower = [];
for (let h2 = 0; h2 < steps; h2++) {
const x = sampleX(issue + h2, n, view);
upper.push([x, toY(q[h2 * NQ + hi], view)]);
lower.push([x, toY(q[h2 * NQ + lo], view)]);
}
return [...upper, ...lower.reverse()];
}
var pointerToValue = (clientY, rectTop, rectHeight, view) => 1 - ((clientY - rectTop) * (view.height / rectHeight) - view.top) / view.plotH;
// src/ui/card-layout.ts
var pct = (x) => `${Math.round(x * 100)}%`;
function cardLayout(i) {
const texts = [
{ text: i.caughtPct === null ? "Timestral couldn't score this round" : `Timestral caught me ${pct(i.caughtPct)} of the time`, x: 60, y: 110, size: 56, weight: 700, align: "left" }
];
if (i.reference !== null) texts.push({ text: `A random scribble: ${pct(i.reference)}`, x: 60, y: 165, size: 28, weight: 400, align: "left" });
texts.push(
{ text: `${i.model} \xB7 ran on my device in ${i.p50Ms.toFixed(1)} ms`, x: 60, y: 630, size: 26, weight: 400, align: "left" },
{ text: i.url, x: 1140, y: 630, size: 26, weight: 700, align: "right" }
);
return { width: 1200, height: 675, strip: { x: 60, y: 210, w: 1080, h: 360 }, texts };
}
// src/ui/render.ts
function readColors(el) {
const s = getComputedStyle(el);
const v = (n) => s.getPropertyValue(n).trim();
return { band: v("--band"), faint: v("--faint"), pen: v("--pen"), escape: v("--escape"), muted: v("--muted") };
}
function fill(ctx, poly, color) {
ctx.beginPath();
poly.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
function drawRound(ctx, round, view, c, escapes, n, from, penWidth = 3) {
ctx.save();
ctx.beginPath();
ctx.rect(0, view.top, view.width, view.plotH);
ctx.clip();
for (const b of round.bands.values()) {
if (sampleX(b.issue + PATCH, n, view) < 0 || b.issue < from) continue;
fill(ctx, bandPolygon(b.q, OUTER_LO, OUTER_HI, b.issue, n, view), c.faint);
fill(ctx, bandPolygon(b.q, LO, HI, b.issue, n, view), c.band);
ctx.beginPath();
for (let h2 = 0; h2 < PATCH; h2++) {
const x = sampleX(b.issue + h2, n, view), y = toY(b.q[h2 * NQ + MEDIAN], view);
h2 ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.strokeStyle = c.muted;
ctx.lineWidth = 1;
ctx.stroke();
}
const s = round.samples;
ctx.lineWidth = penWidth;
ctx.lineJoin = "round";
let run = false, runColor = "";
const flush = () => run && (ctx.strokeStyle = runColor, ctx.stroke());
for (let i = Math.max(from + 1, 1); i < s.length; i++) {
const x0 = sampleX(i - 1, n, view);
if (x0 < -10) continue;
const color = escapes.has(i) ? c.escape : c.pen;
if (!run || color !== runColor) {
flush();
ctx.beginPath();
ctx.moveTo(x0, toY(s[i - 1], view));
run = true;
runColor = color;
}
ctx.lineTo(sampleX(i, n, view), toY(s[i], view));
}
flush();
ctx.restore();
}
function drawPlay(ctx, round, view, c, escapes) {
ctx.clearRect(0, 0, view.width, view.height);
drawRound(ctx, round, view, c, escapes, round.samples.length, 0);
const s = round.samples;
if (s.length) {
ctx.beginPath();
ctx.arc(view.penX, toY(s.at(-1), view), 7, 0, 2 * Math.PI);
ctx.fillStyle = c.pen;
ctx.fill();
}
}
function drawStrip(ctx, round, w, h2, c, escapes) {
const n = round.samples.length;
const view = { ...makeView(w, h2), penX: w, pxPerSample: w / Math.max(1, n - WARMUP) };
ctx.clearRect(0, 0, w, h2);
drawRound(ctx, round, view, c, escapes, n, WARMUP, 2);
}
// src/ui/card.ts
function canvasToBlob(canvas) {
return new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(new Error("couldn't render the card")), "image/png"));
}
function prepareCard(round, p50, model) {
const layout = cardLayout({ caughtPct: round.result().caughtPct, reference: REFERENCE, model, p50Ms: p50, url: SHARE_URL });
const canvas = document.createElement("canvas");
canvas.width = layout.width;
canvas.height = layout.height;
const ctx = canvas.getContext("2d");
const colors = readColors(document.documentElement);
const s = layout.strip;
ctx.save();
ctx.translate(s.x, s.y);
drawStrip(ctx, round, s.w, s.h, colors, new Set(round.result().escapes));
ctx.restore();
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
ctx.fillRect(0, 0, layout.width, layout.height);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = getComputedStyle(document.body).color;
for (const t of layout.texts) {
ctx.font = `${t.weight} ${t.size}px system-ui, sans-serif`;
ctx.textAlign = t.align;
ctx.fillText(t.text, t.x, t.y);
}
return { text: layout.texts[0].text, blob: canvasToBlob(canvas) };
}
async function runShare(btn, go) {
if (btn.disabled) return;
btn.disabled = true;
try {
const how = await go();
if (how !== "cancelled") btn.textContent = how === "copied" ? "Copied image" : how === "downloaded" ? "Downloaded image" : "Shared";
} catch {
btn.textContent = "Couldn't share";
} finally {
btn.disabled = false;
}
}
async function deliverCard(blobOrPromise, text, nav = navigator, doc = document) {
const blob = blobOrPromise instanceof Blob ? blobOrPromise : await blobOrPromise;
const file = new File([blob], "unpredictable.png", { type: "image/png" });
if (nav.canShare?.({ files: [file] })) {
try {
await nav.share({ files: [file], text });
return "shared";
} catch (err) {
if (err?.name === "AbortError") return "cancelled";
}
}
try {
await nav.clipboard.write([new ClipboardItem({ "image/png": Promise.resolve(blob) })]);
return "copied";
} catch {
const url = URL.createObjectURL(blob);
const a = Object.assign(doc.createElement("a"), { href: url, download: "unpredictable.png" });
doc.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1e4);
return "downloaded";
}
}
// src/model/cache.ts
var MAX_FAILS = 3;
function createForecasterCache(create) {
const have = /* @__PURE__ */ new Map();
const loading = /* @__PURE__ */ new Map();
const listeners = /* @__PURE__ */ new Map();
const last = /* @__PURE__ */ new Map();
const fails = /* @__PURE__ */ new Map();
const hooks = (m) => ({
onProgress: (l, t) => {
last.set(m, { ...last.get(m), progress: [l, t] });
listeners.get(m)?.onProgress(l, t);
},
onStage: (s) => {
last.set(m, { ...last.get(m), starting: true });
listeners.get(m)?.onStage(s);
}
});
const begin = (m, backend) => {
last.delete(m);
const p = create(m, backend, hooks(m)).then(
(f) => {
if (loading.get(m) === p) have.set(m, f), loading.delete(m), fails.delete(m);
else f.close();
return f;
},
(err) => {
if (loading.get(m) === p) loading.delete(m);
return Promise.reject(err);
}
);
loading.set(m, p);
return p;
};
return {
/** The latest listener for a mode wins; it is brought up to date with what already happened. */
get(m, listener) {
listeners.set(m, listener);
const f = have.get(m);
if (f) return Promise.resolve(f);
const seen = last.get(m);
if (seen?.progress) listener.onProgress(...seen.progress);
if (seen?.starting) listener.onStage("starting");
return loading.get(m) ?? begin(m);
},
ok(m, f) {
if (have.get(m) === f) fails.delete(m);
},
/**
* A forecast on `f` failed. When it is dead, or MAX_FAILS in a row failed, drop it so the next get() reloads;
* a webgpu forecaster is replaced right away by a wasm one. Returns the replacement load, if any.
*/
fail(m, f) {
if (have.get(m) !== f) return null;
const n = (fails.get(m) ?? 0) + 1;
fails.set(m, n);
if (!f.dead && n < MAX_FAILS) return null;
have.delete(m);
fails.delete(m);
f.close();
return f.backend === "webgpu" ? begin(m, "wasm") : null;
},
has: (m) => have.has(m)
};
}
// src/game/pause.ts
function createPause(sampler) {
let lastY = null;
let paused = false;
return {
/** Record the pen height whenever a sample is pushed. */
note(y) {
lastY = y;
},
hide() {
sampler.reset();
paused = true;
},
show(nowMs) {
if (lastY !== null) sampler.push(nowMs, lastY);
},
/** Call with the number of samples the latest drain produced; samples flowing ends the pause. */
flowed(n) {
if (n > 0) paused = false;
},
get paused() {
return paused;
}
};
}
// src/ui/copy.ts
function unscoredLine(hard, p50Ms, unscored, arrived, issued) {
if (p50Ms > 0 && arrived === issued && unscored <= 2 * issued) {
return `${hard ? "Hard" : "Normal"}'s model takes ~${Math.round(p50Ms)} ms here, so the first sample or two of each second isn't scored (${unscored} in total).`;
}
const what = unscored === 1 ? "1 sample wasn't" : `${unscored} samples weren't`;
const their = unscored === 1 ? "its forecast" : "their forecasts";
return `${what} scored because ${their} ${arrived < issued ? "never arrived" : "arrived late"}.`;
}
// src/app.ts
var app = document.getElementById("app");
var h = (tag, props = {}, ...kids) => {
const el = Object.assign(document.createElement(tag), props);
el.append(...kids);
return el;
};
var pct2 = (x) => `${Math.round(x * 100)}%`;
var START_LINE = "Timestral sees your last 16 seconds and bets on your next one. Escape its bet.";
var mode = "normal";
var cache = createForecasterCache(
(m, backend, hooks) => createForecaster(MODELS[m].file, backend ?? MODELS[m].backend, hooks.onProgress, { expectedBytes: MODELS[m].bytes, onStage: hooks.onStage })
);
var generation = 0;
var cancelLoop = null;
function start() {
cancelLoop?.();
const g = ++generation;
const m0 = mode;
const bar = h("progress", { max: 1, value: 0 });
const status = h("p", { className: "muted" }, "Loading model\u2026");
const toggle = h("div", { className: "row" });
for (const m of ["normal", "hard"]) {
const b = h("button", { className: m === mode ? "primary" : "" }, `${m === "normal" ? "Normal" : "Hard"} \xB7 ${MODELS[m].name} \xB7 ${Math.round(MODELS[m].bytes / 1e6)} MB`);
b.onclick = () => {
if (m === mode) return;
mode = m;
start();
};
toggle.append(b);
}
app.replaceChildren(
h("h1", {}, "Can you outrun a forecast?"),
h("p", {}, START_LINE),
toggle,
bar,
status
);
cache.get(m0, { onProgress: (l, t) => (bar.max = t, bar.value = l), onStage: () => (bar.removeAttribute("value"), status.textContent = "Starting the model\u2026") }).then(
(f) => {
if (g !== generation) return;
bar.remove();
status.textContent = "Move to start";
play(m0, f);
},
(err) => {
if (g !== generation) return;
status.textContent = `The model didn't load (${err instanceof Error ? err.message : String(err)}).`;
const retry = h("button", {}, "Retry");
retry.onclick = () => start();
app.append(retry);
}
);
}
function play(m, first) {
let forecaster = first;
cancelLoop?.();
generation++;
const canvas = h("canvas", { className: "play" });
const info = h("div", { className: "bar" });
const hint = h("p", { className: "muted" }, "Move to start");
app.replaceChildren(h("h1", {}, "Unpredictable"), h("p", { className: "muted" }, START_LINE), info, canvas, hint);
const ctx = canvas.getContext("2d");
let view = makeView(1, 1);
const resize = () => {
const dpr = devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
view = makeView(rect.width, rect.height);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
const colors = readColors(document.documentElement);
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const sampler = createSampler();
const round = createRound();
const escapes = /* @__PURE__ */ new Set();
const latencies = [];
let p50 = 0;
let raf = 0, stopped = false;
let failing = false;
let started = false, keyY = 0.5, keyDir = 0, lastFrame = 0, flashUntil = 0;
const pointerY = (e) => {
const r = canvas.getBoundingClientRect();
return pointerToValue(e.clientY, r.top, r.height, view);
};
canvas.onpointermove = (e) => {
started = true;
const y = pointerY(e);
pause.note(y);
sampler.push(performance.now(), y);
};
canvas.onpointerdown = (e) => canvas.setPointerCapture(e.pointerId);
const onKey = (e) => {
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
e.preventDefault();
started = true;
keyDir = e.type === "keyup" ? 0 : e.key === "ArrowUp" ? 1 : -1;
};
addEventListener("keydown", onKey);
addEventListener("keyup", onKey);
const pause = createPause(sampler);
const onVisibility = () => {
if (document.hidden) {
keyDir = 0;
lastFrame = 0;
pause.hide();
} else pause.show(performance.now());
};
document.addEventListener("visibilitychange", onVisibility);
const cancel = () => {
stopped = true;
cancelAnimationFrame(raf);
observer.disconnect();
removeEventListener("keydown", onKey);
removeEventListener("keyup", onKey);
document.removeEventListener("visibilitychange", onVisibility);
canvas.onpointermove = canvas.onpointerdown = null;
if (cancelLoop === cancel) cancelLoop = null;
};
cancelLoop = cancel;
const frame = (now) => {
if (stopped) return;
if (keyDir) {
keyY = Math.min(1, Math.max(0, keyY + keyDir * 0.8 * ((now - (lastFrame || now)) / 1e3)));
pause.note(keyY);
sampler.push(now, keyY);
}
lastFrame = now;
if (started && !document.hidden) {
const ys = sampler.drain(now);
pause.flowed(ys.length);
for (const y of ys) {
const req = round.push(y);
if (req) {
const f = forecaster;
f.forecast(req.context).then(
(r) => {
cache.ok(m, f);
failing = false;
if (stopped) return;
latencies.push(r.ms);
p50 = [...latencies].sort((a, b) => a - b)[Math.floor(latencies.length / 2)];
round.deliver(req.issue, r.quantiles);
},
(err) => {
console.error("forecast failed", err);
failing = true;
const next = cache.fail(m, f);
next?.then((nf) => {
if (!stopped && forecaster === f) forecaster = nf, failing = false;
}, () => {
});
}
);
}
}
}
const res = round.result();
for (const i of res.escapes) {
if (!escapes.has(i)) {
escapes.add(i);
flashUntil = reduced ? 0 : now + 120;
}
}
drawPlay(ctx, round, view, colors, escapes);
if (now < flashUntil) {
ctx.fillStyle = colors.escape;
ctx.globalAlpha = 0.08;
ctx.fillRect(0, 0, view.width, view.height);
ctx.globalAlpha = 1;
}
const left = Math.max(0, (PLAY + WARMUP - round.samples.length) / SAMPLE_HZ);
info.textContent = `${left.toFixed(1)} s left \xB7 caught ${res.caughtPct === null ? "\u2014" : pct2(res.caughtPct)} \xB7 ${p50.toFixed(1)} ms/forecast (${forecaster.backend})${failing ? " \xB7 Forecasts are failing on this device." : ""}`;
hint.textContent = !started ? "Move to start" : pause.paused ? "Paused, move to resume" : "";
if (round.done) {
cancel();
return result(round, escapes, p50, m);
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
}
function result(round, escapes, p50, m) {
const res = round.result();
const strip = h("canvas", { className: "strip" });
const head = res.caughtPct === null ? "No forecasts arrived, so this round has no score." : `Timestral caught you ${pct2(res.caughtPct)} of the time`;
const again = h("button", { className: "primary" }, "Play again");
again.onclick = () => (mode = m, start());
const share = h("button", {}, "Share card");
const card = prepareCard(round, p50, MODELS[m].name);
let ready = null;
card.blob.then((b) => ready = b, () => {
});
share.onclick = () => runShare(share, () => deliverCard(ready ?? card.blob, card.text));
const buttons = h("div", { className: "row" }, share, again);
if (m === "normal") {
const hard = h("button", {}, "Try Hard mode");
hard.onclick = () => (mode = "hard", start());
buttons.append(hard);
}
app.replaceChildren(
h("p", { className: "headline" }, head),
...REFERENCE !== null ? [h("p", { className: "muted" }, `A random scribble: ${pct2(REFERENCE)}`)] : [],
...res.unscored ? [h("p", { className: "muted" }, unscoredLine(m === "hard", p50, res.unscored, round.bands.size, PLAY / PATCH))] : [],
strip,
buttons
);
const dpr = devicePixelRatio || 1;
const r = strip.getBoundingClientRect();
strip.width = r.width * dpr;
strip.height = r.height * dpr;
const sctx = strip.getContext("2d");
sctx.scale(dpr, dpr);
drawStrip(sctx, round, r.width, r.height, readColors(document.documentElement), escapes);
}
start();
//# sourceMappingURL=app.js.map