TranSGrid / app.js
chenwen245299's picture
Upload 63 files
ab6cffe verified
Raw History Blame Contribute Delete
34.7 kB
"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();
});
}