Spaces:
Running
Running
Download app.js from shreyask/unpredictable: direct link, hf CLI and curl.
- Browser
- Download file 25.8 kB
-
https://huggingface.co/spaces/shreyask/unpredictable/resolve/main/app.js
- Command line
-
hf download hf://spaces/shreyask/unpredictable/app.js
-
curl -L -o app.js https://huggingface.co/spaces/shreyask/unpredictable/resolve/main/app.js
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 | |