"use strict"; // 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(); }); }