Spaces:
Running
Running
Download app.js from BlueWhaleLab/TranSGrid: direct link, hf CLI and curl.
- Browser
- Download file 34.7 kB
-
https://huggingface.co/spaces/BlueWhaleLab/TranSGrid/resolve/main/app.js
- Command line
-
hf download hf://spaces/BlueWhaleLab/TranSGrid/app.js
-
curl -L -o app.js https://huggingface.co/spaces/BlueWhaleLab/TranSGrid/resolve/main/app.js
34.7 kB
| ; | |
| // The action rules mirror utils/board.py in the TranSGrid code repository. | |
| const ACTIONS = new Set([ | |
| "ROW_LEFT", "ROW_RIGHT", "ROW_UP", "ROW_DOWN", | |
| "COL_UP", "COL_DOWN", "COL_LEFT", "COL_RIGHT", | |
| "BLOCK_CW", "BLOCK_CCW", | |
| ]); | |
| const CELL = 52; | |
| function actionMoves(action, n) { | |
| if (!Array.isArray(action) || !ACTIONS.has(action[0])) { | |
| throw new Error(`Unknown action: ${JSON.stringify(action)}`); | |
| } | |
| const [name, a, b] = action; | |
| const block = name.startsWith("BLOCK"); | |
| if (action.length !== (block ? 3 : 2) || !Number.isInteger(a) || | |
| (block && !Number.isInteger(b))) { | |
| throw new Error(`Invalid arguments for ${name}`); | |
| } | |
| if (block ? n < 2 || a < 0 || b < 0 || a > n - 2 || b > n - 2 | |
| : a < 0 || a >= n) { | |
| throw new Error(`Action index out of range: ${JSON.stringify(action)}`); | |
| } | |
| const moves = []; | |
| const add = (r0, c0, r1, c1) => moves.push({ r0, c0, r1, c1 }); | |
| if (name === "ROW_LEFT" || name === "ROW_RIGHT") { | |
| const shift = name === "ROW_LEFT" ? -1 : 1; | |
| for (let c = 0; c < n; c++) add(a, c, a, (c + shift + n) % n); | |
| } else if (name === "ROW_UP" || name === "ROW_DOWN") { | |
| const r2 = (a + (name === "ROW_UP" ? -1 : 1) + n) % n; | |
| for (let c = 0; c < n; c++) { add(a, c, r2, c); add(r2, c, a, c); } | |
| } else if (name === "COL_UP" || name === "COL_DOWN") { | |
| const shift = name === "COL_UP" ? -1 : 1; | |
| for (let r = 0; r < n; r++) add(r, a, (r + shift + n) % n, a); | |
| } else if (name === "COL_LEFT" || name === "COL_RIGHT") { | |
| const c2 = (a + (name === "COL_LEFT" ? -1 : 1) + n) % n; | |
| for (let r = 0; r < n; r++) { add(r, a, r, c2); add(r, c2, r, a); } | |
| } else { | |
| const cycle = [[a, b], [a, b + 1], [a + 1, b + 1], [a + 1, b]]; | |
| if (name === "BLOCK_CCW") cycle.reverse(); | |
| for (let k = 0; k < 4; k++) { | |
| add(...cycle[k], ...cycle[(k + 1) % 4]); | |
| } | |
| } | |
| return moves; | |
| } | |
| function applyAction(grid, action) { | |
| const n = grid.length; | |
| const next = grid.map(row => row.slice()); | |
| for (const { r0, c0, r1, c1 } of actionMoves(action, n)) { | |
| next[r1][c1] = grid[r0][c0]; | |
| } | |
| return next; | |
| } | |
| function boardsEqual(a, b) { | |
| return JSON.stringify(a) === JSON.stringify(b); | |
| } | |
| function actionLabel(action) { | |
| return `${action[0]}(${action.slice(1).join(", ")})`; | |
| } | |
| function describeAction(action, n) { | |
| const [name, a, b] = action; | |
| switch (name) { | |
| case "ROW_LEFT": return `Row ${a} shifts left by one cell.`; | |
| case "ROW_RIGHT": return `Row ${a} shifts right by one cell.`; | |
| case "ROW_UP": return `Row ${a} swaps with row ${(a - 1 + n) % n}.`; | |
| case "ROW_DOWN": return `Row ${a} swaps with row ${(a + 1) % n}.`; | |
| case "COL_UP": return `Column ${a} shifts up by one cell.`; | |
| case "COL_DOWN": return `Column ${a} shifts down by one cell.`; | |
| case "COL_LEFT": return `Column ${a} swaps with column ${(a - 1 + n) % n}.`; | |
| case "COL_RIGHT": return `Column ${a} swaps with column ${(a + 1) % n}.`; | |
| case "BLOCK_CW": return `The 2 × 2 block at (${a}, ${b}) rotates clockwise.`; | |
| case "BLOCK_CCW": return `The 2 × 2 block at (${a}, ${b}) rotates counterclockwise.`; | |
| } | |
| return ""; | |
| } | |
| // Worked examples for the "Atomic actions" tab. Each one is applied to this | |
| // board with applyAction, so the "After" boards always follow the rules above. | |
| // No digit repeats within a row, a column or a 2 × 2 block, which makes every | |
| // moved cell visibly change. | |
| const ATOMIC_BOARD = [ | |
| [2, 9, 4, 7, 5, 6], | |
| [4, 7, 0, 3, 9, 1], | |
| [3, 2, 9, 8, 6, 0], | |
| [9, 6, 3, 2, 7, 8], | |
| [0, 4, 1, 6, 3, 5], | |
| [1, 5, 2, 9, 4, 7], | |
| ]; | |
| const ATOMIC_GROUPS = [ | |
| { | |
| title: "Row actions", params: "r", | |
| hint: n => `r is a row index from 0 to ${n - 1}.`, | |
| actions: [ | |
| { action: ["ROW_LEFT", 1], kind: "Shift", | |
| text: "Shifts row r one cell to the left. The first cell wraps around to the end of the row." }, | |
| { action: ["ROW_RIGHT", 1], kind: "Shift", | |
| text: "Shifts row r one cell to the right. The last cell wraps around to the start of the row." }, | |
| { action: ["ROW_UP", 3], kind: "Swap", | |
| text: "Swaps row r with the row above it. Row 0 wraps around and swaps with the last row." }, | |
| { action: ["ROW_DOWN", 3], kind: "Swap", | |
| text: "Swaps row r with the row below it. The last row wraps around and swaps with row 0." }, | |
| ], | |
| }, | |
| { | |
| title: "Column actions", params: "c", | |
| hint: n => `c is a column index from 0 to ${n - 1}.`, | |
| actions: [ | |
| { action: ["COL_UP", 1], kind: "Shift", | |
| text: "Shifts column c one cell up. The top cell wraps around to the bottom of the column." }, | |
| { action: ["COL_DOWN", 1], kind: "Shift", | |
| text: "Shifts column c one cell down. The bottom cell wraps around to the top of the column." }, | |
| { action: ["COL_LEFT", 3], kind: "Swap", | |
| text: "Swaps column c with the column to its left. Column 0 wraps around and swaps with the last column." }, | |
| { action: ["COL_RIGHT", 3], kind: "Swap", | |
| text: "Swaps column c with the column to its right. The last column wraps around and swaps with column 0." }, | |
| ], | |
| }, | |
| { | |
| title: "Block actions", params: "r, c", | |
| hint: n => `(r, c) is the top-left cell of a 2 × 2 block, so r and c run from 0 to ${n - 2}.`, | |
| actions: [ | |
| { action: ["BLOCK_CW", 2, 1], kind: "Rotate", | |
| text: "Rotates the 2 × 2 block whose top-left cell is (r, c) clockwise." }, | |
| { action: ["BLOCK_CCW", 2, 1], kind: "Rotate", | |
| text: "Rotates the 2 × 2 block whose top-left cell is (r, c) counterclockwise." }, | |
| ], | |
| }, | |
| ]; | |
| // Keep the pure board functions testable without loading the browser UI. | |
| if (typeof module !== "undefined") { | |
| module.exports = { actionMoves, applyAction, boardsEqual, ATOMIC_BOARD, ATOMIC_GROUPS }; | |
| } | |
| if (typeof document !== "undefined") { | |
| document.addEventListener("DOMContentLoaded", () => { | |
| const el = id => document.getElementById(id); | |
| const ui = { | |
| dataset: el("dataset"), level: el("level"), decoding: el("decoding"), | |
| length: el("length"), outcome: el("outcome"), random: el("random"), message: el("message"), | |
| gotoForm: el("goto-form"), gotoId: el("goto-id"), | |
| id: el("question-id"), questionLength: el("question-length"), | |
| title: el("step-label"), description: el("step-description"), | |
| resultBadge: el("result-badge"), initial: el("initial-board"), | |
| current: el("current-board"), target: el("target-board"), | |
| play: el("play"), pause: el("pause"), next: el("next"), | |
| reset: el("reset"), progress: el("progress"), actions: el("actions"), | |
| fileNote: el("file-note"), beamNote: el("beam-note"), summary: el("summary"), | |
| givenGroup: el("given-group"), givenActions: el("given-actions"), | |
| givenNote: el("given-note"), predictionNote: el("prediction-note"), | |
| tabs: Array.from(document.querySelectorAll('[role="tab"]')), | |
| atomicGroups: el("atomic-groups"), demoPanel: el("panel-interactive-demo"), | |
| footer: el("demo-footer"), | |
| }; | |
| const cache = new Map(); | |
| const state = { question: null, result: null, actions: [], chips: [], given: 0, grid: null, | |
| step: 0, playing: false, token: 0, loadSerial: 0 }; | |
| const closeDropdowns = []; | |
| function enhanceSelect(select) { | |
| const field = select.closest(".field"); | |
| const label = field.querySelector("label"); | |
| const dropdown = document.createElement("div"); | |
| dropdown.className = "dropdown"; | |
| const trigger = document.createElement("button"); | |
| trigger.type = "button"; | |
| trigger.className = "dropdown-trigger"; | |
| trigger.setAttribute("aria-haspopup", "listbox"); | |
| trigger.setAttribute("aria-expanded", "false"); | |
| const menu = document.createElement("div"); | |
| menu.className = "dropdown-menu"; | |
| menu.id = `${select.id}-menu`; | |
| menu.setAttribute("role", "listbox"); | |
| menu.setAttribute("aria-label", label.textContent.trim()); | |
| menu.hidden = true; | |
| trigger.setAttribute("aria-controls", menu.id); | |
| const options = Array.from(select.options).map(option => { | |
| const item = document.createElement("button"); | |
| item.type = "button"; | |
| item.className = "dropdown-option"; | |
| item.setAttribute("role", "option"); | |
| item.dataset.value = option.value; | |
| item.textContent = option.textContent; | |
| menu.append(item); | |
| item.addEventListener("click", () => { | |
| if (select.value !== option.value) { | |
| select.value = option.value; | |
| select.dispatchEvent(new Event("change", { bubbles: true })); | |
| } | |
| close(); | |
| trigger.focus(); | |
| }); | |
| return item; | |
| }); | |
| function sync() { | |
| trigger.textContent = select.selectedOptions[0].textContent; | |
| trigger.setAttribute("aria-label", `${label.textContent.trim()}: ${select.selectedOptions[0].textContent}`); | |
| options.forEach(item => item.setAttribute("aria-selected", String(item.dataset.value === select.value))); | |
| } | |
| function close() { | |
| menu.hidden = true; | |
| trigger.setAttribute("aria-expanded", "false"); | |
| } | |
| function open() { | |
| closeDropdowns.forEach(closeOther => closeOther()); | |
| menu.hidden = false; | |
| trigger.setAttribute("aria-expanded", "true"); | |
| options.find(item => item.dataset.value === select.value).focus(); | |
| } | |
| closeDropdowns.push(close); | |
| trigger.addEventListener("click", () => menu.hidden ? open() : close()); | |
| trigger.addEventListener("keydown", event => { | |
| if (event.key === "ArrowDown" || event.key === "ArrowUp") { | |
| event.preventDefault(); | |
| open(); | |
| } | |
| }); | |
| menu.addEventListener("keydown", event => { | |
| if (event.key === "Escape") { | |
| event.preventDefault(); | |
| close(); | |
| trigger.focus(); | |
| } else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { | |
| event.preventDefault(); | |
| const index = options.indexOf(document.activeElement); | |
| const next = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 | |
| : (index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length; | |
| options[next].focus(); | |
| } else if (event.key === "Tab") { | |
| close(); | |
| } | |
| }); | |
| document.addEventListener("click", event => { | |
| if (!dropdown.contains(event.target)) close(); | |
| }); | |
| label.addEventListener("click", event => { | |
| event.preventDefault(); | |
| trigger.focus(); | |
| }); | |
| select.addEventListener("change", sync); | |
| select.addEventListener("resync", sync); | |
| dropdown.append(trigger, menu); | |
| select.after(dropdown); | |
| select.hidden = true; | |
| sync(); | |
| } | |
| [ui.dataset, ui.level, ui.decoding, ui.length, ui.outcome].forEach(enhanceSelect); | |
| // Each dataset pairs a question file with the folder and file-name tag of its saved predictions. | |
| // SCAN reuses the TranSGrid questions; its predictions are given a prefix of the answer. | |
| const DATASETS = { | |
| Test: { data: "data/test.jsonl", results: "results/Test", tag: "-test12" }, | |
| TranSGrid: { data: "data/transgrid.jsonl", results: "results/TranSGrid", tag: "" }, | |
| Decoupled: { data: "data/transgrid_decoupled.jsonl", results: "results/decoupling", tag: "-decoupled" }, | |
| SCAN: { data: "data/transgrid.jsonl", results: "results/action_explicit_goal", tag: "-prefix" }, | |
| }; | |
| function datasetPath() { | |
| return DATASETS[ui.dataset.value].data; | |
| } | |
| function resultPath() { | |
| const { results, tag } = DATASETS[ui.dataset.value]; | |
| return `${results}/level${ui.level.value}_grid_pair${tag}-${ui.decoding.value}-bs128.jsonl`; | |
| } | |
| // In SCAN the first L - 1 actions of the reference solution are part of the model's input. | |
| function givenNote(given, steps) { | |
| return given === 0 ? "No action is given because the reference length is 1." | |
| : `SCAN gives the first L − 1 = ${given} actions of the reference solution (L = ${steps}).`; | |
| } | |
| function stepTitle(index) { | |
| const suffix = index < state.given ? " · given" : ""; | |
| return `Step ${index + 1}/${state.actions.length}: ${actionLabel(state.actions[index])}${suffix}`; | |
| } | |
| async function readJsonl(path) { | |
| if (!cache.has(path)) { | |
| const promise = fetch(path).then(async response => { | |
| if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); | |
| const content = await response.text(); | |
| return content.split(/\r?\n/).filter(line => line.trim()).map((line, i) => { | |
| try { return JSON.parse(line); } | |
| catch { throw new Error(`${path}: invalid JSON on line ${i + 1}`); } | |
| }); | |
| }); | |
| cache.set(path, promise); | |
| promise.catch(() => cache.delete(path)); | |
| } | |
| return cache.get(path); | |
| } | |
| class SavedPredictionError extends Error {} | |
| function message(text, error = false) { | |
| ui.message.textContent = text; | |
| ui.message.classList.toggle("error", error); | |
| } | |
| function cancelPlayback() { | |
| state.token++; | |
| state.playing = false; | |
| } | |
| function buttons() { | |
| const ready = !!state.question; | |
| ui.play.disabled = !ready || state.playing; | |
| ui.pause.disabled = !state.playing; | |
| ui.next.disabled = !ready || state.playing || state.step >= state.actions.length; | |
| ui.reset.disabled = !ready || state.playing || state.step === 0; | |
| ui.play.textContent = ready && state.step >= state.actions.length ? "↻ Replay" : "▶ Play"; | |
| } | |
| function drawDigit(ctx, digit, x, y, color = "#14213b") { | |
| ctx.fillStyle = color; | |
| ctx.font = "700 29px system-ui, sans-serif"; | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "middle"; | |
| ctx.fillText(String(digit), x + CELL / 2, y + CELL / 2 + 1); | |
| } | |
| function drawBoard(canvas, grid, options = {}) { | |
| if (!grid) return; | |
| const n = grid.length; | |
| const ratio = Math.max(1, window.devicePixelRatio || 1); | |
| const size = n * CELL; | |
| if (canvas.width !== Math.round(size * ratio)) { | |
| canvas.width = Math.round(size * ratio); | |
| canvas.height = Math.round(size * ratio); | |
| } | |
| const ctx = canvas.getContext("2d"); | |
| ctx.setTransform(ratio, 0, 0, ratio, 0, 0); | |
| ctx.clearRect(0, 0, size, size); | |
| const moves = options.moves || []; | |
| const moving = new Map(moves.map(m => [`${m.r0},${m.c0}`, m])); | |
| for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) { | |
| const x = c * CELL, y = r * CELL; | |
| const affected = moving.has(`${r},${c}`); | |
| const changed = options.compareWith && grid[r][c] !== options.compareWith[r][c]; | |
| ctx.fillStyle = affected ? "#fff0b9" | |
| : changed && options.diffBackground ? "#fff0f3" : "#f7f9fc"; | |
| ctx.fillRect(x, y, CELL, CELL); | |
| ctx.strokeStyle = "#aeb9c9"; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(x + .5, y + .5, CELL, CELL); | |
| if (!affected || options.phase === "before") { | |
| drawDigit(ctx, grid[r][c], x, y, changed ? "#b4234e" : "#14213b"); | |
| } | |
| } | |
| if (options.phase === "before") { | |
| ctx.fillStyle = "#cf3434"; | |
| ctx.font = "700 18px system-ui, sans-serif"; | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "middle"; | |
| for (const m of moves) { | |
| let dr = m.r1 - m.r0, dc = m.c1 - m.c0; | |
| if (dr > 1) dr -= n; else if (dr < -1) dr += n; | |
| if (dc > 1) dc -= n; else if (dc < -1) dc += n; | |
| const arrow = dc < 0 ? "←" : dc > 0 ? "→" : dr < 0 ? "↑" : "↓"; | |
| ctx.fillText(arrow, m.c0 * CELL + CELL - 11, m.r0 * CELL + CELL - 12); | |
| } | |
| } else if (options.phase === "tween") { | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.rect(0, 0, size, size); | |
| ctx.clip(); | |
| for (const m of moves) { | |
| let dr = m.r1 - m.r0, dc = m.c1 - m.c0; | |
| if (dr > 1) dr -= n; else if (dr < -1) dr += n; | |
| if (dc > 1) dc -= n; else if (dc < -1) dc += n; | |
| const x = (m.c0 + dc * options.t) * CELL; | |
| const y = (m.r0 + dr * options.t) * CELL; | |
| const value = grid[m.r0][m.c0]; | |
| const color = options.compareWith && value !== options.compareWith[m.r0][m.c0] | |
| ? "#b4234e" : "#14213b"; | |
| drawDigit(ctx, value, x, y, color); | |
| if (x < 0 || x > size - CELL) drawDigit(ctx, value, x + (x < 0 ? size : -size), y, color); | |
| if (y < 0 || y > size - CELL) drawDigit(ctx, value, x, y + (y < 0 ? size : -size), color); | |
| } | |
| ctx.restore(); | |
| } | |
| } | |
| function render() { | |
| if (!state.question) return; | |
| const n = state.question.n; | |
| drawBoard(ui.initial, state.question.initial, { | |
| compareWith: state.question.target, diffBackground: true, | |
| }); | |
| drawBoard(ui.current, state.grid, { | |
| compareWith: state.question.target, diffBackground: true, | |
| }); | |
| drawBoard(ui.target, state.question.target, { compareWith: state.grid }); | |
| ui.progress.textContent = `${state.step} / ${state.actions.length} actions`; | |
| if (state.step === 0) { | |
| ui.title.textContent = "Start"; | |
| ui.description.textContent = "The current board matches the initial board."; | |
| } else { | |
| const action = state.actions[state.step - 1]; | |
| ui.title.textContent = stepTitle(state.step - 1); | |
| ui.description.textContent = describeAction(action, n); | |
| } | |
| for (let i = 0; i < state.chips.length; i++) { | |
| const child = state.chips[i]; | |
| child.classList.toggle("done", i < state.step); | |
| child.classList.toggle("active", state.playing && i === state.step); | |
| } | |
| if (state.step === state.actions.length) { | |
| const solved = boardsEqual(state.grid, state.question.target); | |
| if (state.result.solved !== solved) { | |
| message("Warning: replay outcome differs from the saved result. Check that the dataset and result files match.", true); | |
| } | |
| } | |
| buttons(); | |
| } | |
| async function selectExample(randomize = false, wantedId = null) { | |
| const serial = ++state.loadSerial; | |
| cancelPlayback(); | |
| const previous = state.question; | |
| state.question = null; | |
| state.grid = null; | |
| buttons(); | |
| for (const canvas of [ui.initial, ui.current, ui.target]) { | |
| canvas.width = 0; | |
| canvas.height = 0; | |
| } | |
| ui.id.textContent = "—"; | |
| ui.questionLength.textContent = "—"; | |
| ui.resultBadge.hidden = true; | |
| ui.title.textContent = "Loading example"; | |
| ui.description.textContent = ""; | |
| ui.progress.textContent = "0 / 0 actions"; | |
| ui.actions.replaceChildren(); | |
| ui.givenActions.replaceChildren(); | |
| ui.givenGroup.hidden = true; | |
| ui.givenNote.textContent = ""; | |
| ui.predictionNote.textContent = ""; | |
| ui.summary.textContent = ""; | |
| state.chips = []; | |
| ui.fileNote.textContent = ""; | |
| const dataPath = datasetPath(), predictionPath = resultPath(); | |
| message(`Loading ${dataPath} and ${predictionPath}…`); | |
| try { | |
| const [questions, predictions] = await Promise.all([ | |
| readJsonl(dataPath), readJsonl(predictionPath), | |
| ]); | |
| if (serial !== state.loadSerial) return; | |
| const requestedLength = ui.length.value, requestedOutcome = ui.outcome.value; | |
| const predictionById = new Map(predictions.map(row => [row.id, row])); | |
| const inLength = questions.filter(q => requestedLength === "all" || | |
| Number(q.steps) === Number(requestedLength)); | |
| const answered = inLength.filter(q => predictionById.has(q.id)); | |
| const solvedCount = answered.filter(q => predictionById.get(q.id).solved).length; | |
| const count = value => value.toLocaleString("en-US"); | |
| const optionText = select => select.selectedOptions[0].textContent; | |
| if (answered.length) { | |
| ui.summary.textContent = [optionText(ui.dataset), optionText(ui.level), optionText(ui.decoding), | |
| ...(requestedLength === "all" ? [] : [`reference length ${requestedLength}`])].join(" · ") + | |
| `: ${count(solvedCount)} of ${count(answered.length)} questions solved ` + | |
| `(${(100 * solvedCount / answered.length).toFixed(1)}%).`; | |
| } | |
| const eligible = requestedOutcome === "all" ? inLength : inLength.filter(q => | |
| predictionById.get(q.id)?.solved === (requestedOutcome === "solved")); | |
| const wanted = wantedId === null ? null : questions.find(q => q.id === wantedId); | |
| if (!eligible.length && !wanted) { | |
| ui.title.textContent = "No matching question"; | |
| message("No question matches this reference length and outcome for the selected model. Try changing one of them."); | |
| return; | |
| } | |
| let question = wanted || (!randomize && previous && eligible.find(q => q.id === previous.id)); | |
| if (!question) { | |
| const pool = randomize && previous && eligible.length > 1 | |
| ? eligible.filter(q => q.id !== previous.id) : eligible; | |
| question = pool[Math.floor(Math.random() * pool.length)]; | |
| } | |
| const prediction = predictionById.get(question.id); | |
| if (!prediction) throw new Error(`Question ${question.id} is missing from ${predictionPath}`); | |
| if (!boardsEqual(question.initial, prediction.initial) || | |
| !boardsEqual(question.target, prediction.target)) { | |
| throw new Error(`Question ${question.id} does not match ${predictionPath}`); | |
| } | |
| if (!Array.isArray(prediction.pred)) throw new Error(`Question ${question.id} has no pred action list.`); | |
| const hasGiven = prediction.given != null; | |
| const given = prediction.given ?? 0; | |
| if (!Number.isInteger(given) || given < 0 || given > prediction.pred.length) { | |
| throw new Error(`Question ${question.id} has an invalid given prefix in ${predictionPath}`); | |
| } | |
| try { | |
| let finalGrid = question.initial.map(row => row.slice()); | |
| for (const action of prediction.pred) finalGrid = applyAction(finalGrid, action); | |
| } catch (error) { | |
| throw new SavedPredictionError(`Question ${question.id}: the saved prediction contains an illegal action (${error.message}). Draw another question.`); | |
| } | |
| state.question = question; | |
| state.result = prediction; | |
| state.actions = prediction.pred; | |
| state.given = given; | |
| state.grid = question.initial.map(row => row.slice()); | |
| state.step = 0; | |
| ui.id.textContent = String(question.id); | |
| ui.questionLength.textContent = String(question.steps); | |
| ui.resultBadge.textContent = prediction.solved ? "Solved" : "Not solved"; | |
| ui.resultBadge.className = `result-badge ${prediction.solved ? "solved" : "unsolved"}`; | |
| ui.resultBadge.title = "Outcome of the saved model prediction"; | |
| ui.resultBadge.hidden = false; | |
| state.chips = state.actions.map((action, i) => { | |
| const item = document.createElement("li"); | |
| item.textContent = `${i + 1}. ${actionLabel(action)}`; | |
| return item; | |
| }); | |
| ui.givenActions.replaceChildren(...state.chips.slice(0, given)); | |
| ui.actions.replaceChildren(...state.chips.slice(given)); | |
| ui.givenGroup.hidden = !hasGiven; | |
| if (hasGiven) ui.givenNote.textContent = givenNote(given, question.steps); | |
| if (state.actions.length === given) { | |
| ui.predictionNote.textContent = hasGiven ? "The model predicted no further actions." | |
| : "The model predicted no actions."; | |
| } | |
| ui.fileNote.textContent = `Data: ${dataPath} · Prediction: ${predictionPath}`; | |
| ui.beamNote.hidden = ui.decoding.value !== "beam8"; | |
| message(""); | |
| render(); | |
| } catch (error) { | |
| if (serial !== state.loadSerial) return; | |
| const saved = error instanceof SavedPredictionError; | |
| ui.title.textContent = saved ? "Question unavailable" : "Could not load example"; | |
| message(saved ? error.message | |
| : `${error.message}. Upload the file at the path shown above, then draw a question again.`, true); | |
| } | |
| } | |
| function reset() { | |
| cancelPlayback(); | |
| if (!state.question) return; | |
| state.grid = state.question.initial.map(row => row.slice()); | |
| state.step = 0; | |
| render(); | |
| } | |
| const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); | |
| // Highlights the moving cells, then slides them. Resolves to false if | |
| // isCancelled() turns true part-way through. | |
| async function playMoves(canvas, grid, moves, options, isCancelled) { | |
| drawBoard(canvas, grid, { ...options, moves, phase: "before" }); | |
| await sleep(500); | |
| if (isCancelled()) return false; | |
| for (let frame = 1; frame <= 8; frame++) { | |
| drawBoard(canvas, grid, { ...options, moves, phase: "tween", t: frame / 9 }); | |
| await sleep(70); | |
| if (isCancelled()) return false; | |
| } | |
| return true; | |
| } | |
| async function animateAction(token) { | |
| if (!state.question || state.step >= state.actions.length || token !== state.token) return false; | |
| const action = state.actions[state.step]; | |
| const moves = actionMoves(action, state.question.n); | |
| ui.title.textContent = stepTitle(state.step); | |
| ui.description.textContent = describeAction(action, state.question.n); | |
| const played = await playMoves( | |
| ui.current, state.grid, moves, | |
| { compareWith: state.question.target, diffBackground: true }, | |
| () => token !== state.token, | |
| ); | |
| if (!played) return false; | |
| state.grid = applyAction(state.grid, action); | |
| state.step++; | |
| render(); | |
| return true; | |
| } | |
| async function nextAction() { | |
| if (!state.question || state.playing || state.step >= state.actions.length) return; | |
| state.playing = true; | |
| const token = ++state.token; | |
| buttons(); | |
| await animateAction(token); | |
| if (token === state.token) { | |
| state.playing = false; | |
| render(); | |
| } | |
| } | |
| async function play() { | |
| if (!state.question || state.playing) return; | |
| if (state.step >= state.actions.length) reset(); | |
| state.playing = true; | |
| const token = ++state.token; | |
| buttons(); | |
| while (state.step < state.actions.length && token === state.token) { | |
| if (!await animateAction(token)) break; | |
| await sleep(350); | |
| } | |
| if (token === state.token) { | |
| state.playing = false; | |
| render(); | |
| } | |
| } | |
| function node(tag, className, text) { | |
| const element = document.createElement(tag); | |
| if (className) element.className = className; | |
| if (text !== undefined) element.textContent = text; | |
| return element; | |
| } | |
| // Atomic actions tab: one card per action, each replaying on its own board. | |
| const atomicCards = []; | |
| function drawAtomicStart(card) { | |
| drawBoard(card.before, ATOMIC_BOARD, { moves: card.moves, phase: "before" }); | |
| } | |
| function resetAtomicCard(card) { | |
| card.token++; | |
| card.playing = false; | |
| card.play.removeAttribute("aria-disabled"); | |
| drawAtomicStart(card); | |
| } | |
| async function playAtomicCard(card) { | |
| if (card.playing) return; | |
| card.playing = true; | |
| card.play.setAttribute("aria-disabled", "true"); | |
| const token = ++card.token; | |
| const isCancelled = () => token !== card.token; | |
| if (await playMoves(card.before, ATOMIC_BOARD, card.moves, {}, isCancelled)) { | |
| drawBoard(card.before, card.after, { compareWith: ATOMIC_BOARD, diffBackground: true }); | |
| await sleep(900); | |
| } | |
| if (!isCancelled()) resetAtomicCard(card); | |
| } | |
| function buildAtomicCard(group, item) { | |
| const n = ATOMIC_BOARD.length; | |
| const label = actionLabel(item.action); | |
| const card = { | |
| moves: actionMoves(item.action, n), | |
| after: applyAction(ATOMIC_BOARD, item.action), | |
| token: 0, playing: false, | |
| before: node("canvas"), afterCanvas: node("canvas"), | |
| play: node("button", "atomic-play", "▶ Play"), | |
| }; | |
| const rows = grid => grid.map((row, r) => `row ${r}: ${row.join(" ")}`).join("; "); | |
| for (const [canvas, when, grid] of [[card.before, "before", ATOMIC_BOARD], [card.afterCanvas, "after", card.after]]) { | |
| canvas.setAttribute("role", "img"); | |
| canvas.setAttribute("aria-label", `Board ${when} ${label}. ${rows(grid)}`); | |
| } | |
| card.play.type = "button"; | |
| card.play.setAttribute("aria-label", `Play ${label}`); | |
| card.play.addEventListener("click", () => playAtomicCard(card)); | |
| const name = node("h4", "atomic-name"); | |
| name.append(node("code", "", `${item.action[0]}(${group.params})`)); | |
| const title = node("div", "atomic-title"); | |
| title.append(name, node("span", "atomic-kind", item.kind)); | |
| const head = node("header", "atomic-head"); | |
| head.append(title, card.play); | |
| const figure = (caption, canvas) => { | |
| const element = node("figure"); | |
| element.append(node("figcaption", "", caption), canvas); | |
| return element; | |
| }; | |
| const arrow = node("span", "atomic-arrow", "→"); | |
| arrow.setAttribute("aria-hidden", "true"); | |
| const boards = node("div", "atomic-boards"); | |
| boards.append(figure("Before", card.before), arrow, figure("After", card.afterCanvas)); | |
| const example = node("p", "atomic-example", "Example: "); | |
| example.append(node("code", "", label)); | |
| const root = node("article", "atomic-card"); | |
| root.append(head, node("p", "atomic-text", item.text), example, boards); | |
| drawAtomicStart(card); | |
| drawBoard(card.afterCanvas, card.after, { compareWith: ATOMIC_BOARD, diffBackground: true }); | |
| atomicCards.push(card); | |
| return root; | |
| } | |
| function buildAtomicPanel() { | |
| const n = ATOMIC_BOARD.length; | |
| ui.atomicGroups.replaceChildren(...ATOMIC_GROUPS.map(group => { | |
| const grid = node("div", "atomic-grid"); | |
| grid.append(...group.actions.map(item => buildAtomicCard(group, item))); | |
| const section = node("section", "atomic-group"); | |
| section.append(node("h3", "", group.title), node("p", "atomic-hint", group.hint(n)), grid); | |
| return section; | |
| })); | |
| } | |
| // Tabs follow the WAI-ARIA pattern; the URL hash makes a tab linkable. | |
| let activeTab = ui.tabs[0]; | |
| // The demo's data (about 5 MB) is only fetched once its tab is first opened. | |
| let demoStarted = false; | |
| function startDemo() { | |
| if (demoStarted) return; | |
| demoStarted = true; | |
| selectExample(true); | |
| } | |
| function selectTab(tab, focus = false) { | |
| if (tab !== activeTab) { | |
| activeTab = tab; | |
| closeDropdowns.forEach(close => close()); | |
| for (const other of ui.tabs) { | |
| const selected = other === tab; | |
| other.setAttribute("aria-selected", String(selected)); | |
| other.tabIndex = selected ? 0 : -1; | |
| el(other.getAttribute("aria-controls")).hidden = !selected; | |
| } | |
| ui.footer.hidden = ui.demoPanel.hidden; | |
| if (!ui.demoPanel.hidden) startDemo(); | |
| // Stop whatever was animating in the panel that just got hidden. | |
| cancelPlayback(); | |
| render(); | |
| atomicCards.forEach(resetAtomicCard); | |
| } | |
| if (focus) tab.focus(); | |
| } | |
| function chooseTab(tab, focus = false) { | |
| selectTab(tab, focus); | |
| try { history.replaceState(null, "", `#${tab.dataset.hash}`); } | |
| catch { /* Some embedding contexts forbid changing the URL. */ } | |
| } | |
| function tabFromHash() { | |
| return ui.tabs.find(tab => tab.dataset.hash === location.hash.slice(1)); | |
| } | |
| ui.tabs.forEach((tab, index) => { | |
| tab.addEventListener("click", () => chooseTab(tab)); | |
| tab.addEventListener("keydown", event => { | |
| if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; | |
| const last = ui.tabs.length - 1; | |
| const target = event.key === "ArrowRight" ? (index + 1) % ui.tabs.length | |
| : event.key === "ArrowLeft" ? (index + last) % ui.tabs.length | |
| : event.key === "Home" ? 0 : event.key === "End" ? last : -1; | |
| if (target < 0) return; | |
| event.preventDefault(); | |
| chooseTab(ui.tabs[target], true); | |
| }); | |
| }); | |
| window.addEventListener("hashchange", () => selectTab(tabFromHash() ?? ui.tabs[0])); | |
| ui.gotoForm.addEventListener("submit", async event => { | |
| event.preventDefault(); | |
| const text = ui.gotoId.value.trim(); | |
| if (!/^\d+$/.test(text)) { | |
| message("Enter a question ID as a whole number.", true); | |
| return; | |
| } | |
| const id = Number(text); | |
| try { | |
| const [questions, predictions] = await Promise.all([readJsonl(datasetPath()), readJsonl(resultPath())]); | |
| const question = questions.find(q => q.id === id); | |
| if (!question) { | |
| const ids = questions.map(q => q.id); | |
| message(`Question ${id} is not in ${ui.dataset.selectedOptions[0].textContent}. IDs run from ${Math.min(...ids)} to ${Math.max(...ids)}.`, true); | |
| return; | |
| } | |
| // Going to a question overrides any length or outcome filter that would hide it. | |
| const row = predictions.find(prediction => prediction.id === id); | |
| const hidden = (ui.length.value !== "all" && Number(question.steps) !== Number(ui.length.value)) || | |
| (ui.outcome.value !== "all" && row?.solved !== (ui.outcome.value === "solved")); | |
| if (hidden) { | |
| for (const select of [ui.length, ui.outcome]) { | |
| select.value = "all"; | |
| select.dispatchEvent(new Event("resync")); | |
| } | |
| } | |
| ui.gotoId.value = ""; | |
| selectExample(false, id); | |
| } catch (error) { | |
| message(`${error.message}. Upload the file at the path shown above, then try again.`, true); | |
| } | |
| }); | |
| ui.random.addEventListener("click", () => selectExample(true)); | |
| ui.dataset.addEventListener("change", () => selectExample(true)); | |
| ui.level.addEventListener("change", () => selectExample(false)); | |
| ui.decoding.addEventListener("change", () => selectExample(false)); | |
| ui.length.addEventListener("change", () => selectExample(false)); | |
| ui.outcome.addEventListener("change", () => selectExample(false)); | |
| ui.play.addEventListener("click", play); | |
| ui.pause.addEventListener("click", () => { cancelPlayback(); render(); }); | |
| ui.next.addEventListener("click", nextAction); | |
| ui.reset.addEventListener("click", reset); | |
| buildAtomicPanel(); | |
| const initialTab = tabFromHash(); | |
| if (initialTab) selectTab(initialTab); | |
| if (!ui.demoPanel.hidden) startDemo(); | |
| }); | |
| } | |