Add frontend, Makefile, and additional built-in games

This commit is contained in:
Michael Pilosov 2026-04-05 22:51:03 -06:00
parent 937eacc448
commit 6eb5672351
19 changed files with 1237 additions and 0 deletions

26
Makefile Normal file
View File

@ -0,0 +1,26 @@
.PHONY: dev serve lint fmt help
# Development server with auto-reload
dev:
uv run uvicorn chess_pressure.app:app --host 0.0.0.0 --port 8888 --reload
# Production server
serve:
uv run chess-pressure
# Lint
lint: fmt
uvx ruff check chess_pressure/
# Format
fmt:
uvx ruff format chess_pressure/
uvx ruff check --fix chess_pressure/
help:
@echo "chess-pressure"
@echo ""
@echo " make dev dev server with reload (:8888)"
@echo " make serve production server (:8888)"
@echo " make lint ruff format + check"
@echo " make fmt ruff format + auto-fix"

View File

@ -96,6 +96,103 @@ c5 15. b3 Nc6 16. d5 Ne7 17. Be3 Ng6 18. Qd2 Nh7 19. a4 Nh4 20. Nxh4 Qxh4
Qd7 39. Qa7 Rc7 40. Qb6 Rb7 41. Ra8+ Kf7 42. Qa6 Qc7 43. Qc6 Qb6+ 44. Kf1
Rb8 45. Ra6 1-0""",
},
{
"id": "polgar-anand",
"name": "Polgar vs Anand (1999)",
"white": "Polgar",
"black": "Anand",
"pgn": """[Event "Dos Hermanas"]
[Site "Dos Hermanas"]
[Date "1999.04.??"]
[White "Judit Polgar"]
[Black "Viswanathan Anand"]
[Result "1-0"]
1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6 6.Be3 e6 7.g4 e5 8.Nf5 g6
9.g5 gxf5 10.exf5 d5 11.Qf3 d4 12.O-O-O Nbd7 13.Bd2 dxc3 14.Bxc3 Bg7 15.Rg1
O-O 16.gxf6 Qxf6 17.Qe3 Kh8 18.f4 Qb6 19.Qg3 Qh6 20.Rd6 f6 21.Bd2 e4 22.Bc4
b5 23.Be6 Ra7 24.Rc6 a5 25.Be3 Rb7 26.Bd5 Rb8 27.Rc7 b4 28.b3 Rb5 29.Bc6 Rxf5
30.Rxc8 Rxc8 31.Bxd7 Rcc5 32.Bxf5 Rxf5 33.Rd1 Kg8 34.Qg2 1-0""",
},
{
"id": "polgar-kasparov",
"name": "Polgar vs Kasparov (2002)",
"white": "Polgar",
"black": "Kasparov",
"pgn": """[Event "Russia vs Rest of the World"]
[Site "Moscow"]
[Date "2002.09.09"]
[White "Judit Polgar"]
[Black "Garry Kasparov"]
[Result "1-0"]
1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O Nxe4 5.d4 Nd6 6.Bxc6 dxc6 7.dxe5 Nf5
8.Qxd8+ Kxd8 9.Nc3 h6 10.Rd1+ Ke8 11.h3 Be7 12.Ne2 Nh4 13.Nxh4 Bxh4 14.Be3
Bf5 15.Nd4 Bh7 16.g4 Be7 17.Kg2 h5 18.Nf5 Bf8 19.Kf3 Bg6 20.Rd2 hxg4+ 21.hxg4
Rh3+ 22.Kg2 Rh7 23.Kg3 f6 24.Bf4 Bxf5 25.gxf5 fxe5 26.Re1 Bd6 27.Bxe5 Kd7
28.c4 c5 29.Bxd6 cxd6 30.Re6 Rah8 31.Rexd6+ Kc8 32.R2d5 Rh3+ 33.Kg2 Rh2+
34.Kf3 R2h3+ 35.Ke4 b6 36.Rc6+ Kb8 37.Rd7 Rh2 38.Ke3 Rf8 39.Rcc7 Rxf5
40.Rb7+ Kc8 41.Rdc7+ Kd8 42.Rxg7 Kc8 1-0""",
},
{
"id": "hou-caruana",
"name": "Hou Yifan vs Caruana (2017)",
"white": "Hou Yifan",
"black": "Caruana",
"pgn": """[Event "GRENKE Chess Classic"]
[Site "Karlsruhe"]
[Date "2017.04.15"]
[White "Hou Yifan"]
[Black "Fabiano Caruana"]
[Result "1-0"]
1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O Nxe4 5.Re1 Nd6 6.Nxe5 Be7 7.Bf1 O-O 8.d4
Nf5 9.Nf3 d5 10.c3 Bd6 11.Nbd2 Nce7 12.Qc2 c6 13.Bd3 g6 14.Nf1 f6 15.h3 Rf7
16.Bd2 Bd7 17.Re2 c5 18.dxc5 Bxc5 19.Bf4 Rc8 20.Rae1 g5 21.Ng3 Nxg3 22.Bxg3
a5 23.Qd2 a4 24.b4 axb3 25.axb3 Ng6 26.h4 gxh4 27.Nxh4 Nxh4 28.Bxh4 Qf8
29.Qf4 Bd6 30.Qd4 Rd8 31.Re3 Bc8 32.b4 Kg7 33.Bb5 Bc7 34.Re8 Qd6 35.Bg3 Qb6
36.Qd3 Bd7 37.Bxd7 Rdxd7 38.Qf5 Bxg3 39.Qg4+ Kh6 40.Qh3+ 1-0""",
},
{
"id": "ju-lei",
"name": "Ju Wenjun vs Lei Tingjie, WCC G12 (2023)",
"white": "Ju Wenjun",
"black": "Lei Tingjie",
"pgn": """[Event "Women's World Championship"]
[Site "Shanghai/Chongqing"]
[Date "2023.07.22"]
[White "Ju Wenjun"]
[Black "Lei Tingjie"]
[Result "1-0"]
1.d4 d5 2.Nf3 Nf6 3.e3 c5 4.dxc5 e6 5.b4 a5 6.c3 axb4 7.cxb4 b6 8.Bb5+ Bd7
9.Bxd7+ Nbxd7 10.a4 bxc5 11.b5 Qc7 12.Bb2 Bd6 13.O-O O-O 14.Nbd2 Rfc8 15.Qc2
c4 16.Bc3 Nc5 17.a5 Nb3 18.Bxf6 Nxa1 19.Bxa1 Qxa5 20.Qc3 Qxc3 21.Bxc3 Rcb8
22.Nd4 e5 23.Nf5 Bf8 24.Bxe5 Rxb5 25.g4 g6 26.Nd4 Rb2 27.Nb1 Bg7 28.Bxg7 Kxg7
29.Nc3 Ra5 30.Rd1 Rb6 31.Nde2 Rb3 32.Kg2 h6 33.Kf3 f6 34.Rc1 Kf7 35.Nf4 d4
36.exd4 g5 37.Ne2 f5 38.gxf5 Rxf5+ 39.Ke3 g4 40.Nf4 Rb8 41.d5 Rf6 42.Rc2 Ra8
43.Nb5 Rb6 44.Nd4 Ra3+ 45.Ke4 c3 46.Nfe2 Rb2 47.Kd3 Rb1 48.Nxc3 Rh1 49.f3 gxf3
50.Nxf3 Rf1 51.Nd4 Ke7 52.Kc4 Rf4 53.Rb2 Rh4 54.Rb7+ Kf6 55.Rb2 Ra8 56.Kc5 Rh3
57.Ncb5 Re3 58.d6 Ke5 59.Nc6+ Ke4 60.d7 Rd3 61.Nd6+ Kf4 62.Rb8 1-0""",
},
{
"id": "shirov-polgar",
"name": "Shirov vs Polgar (1994)",
"white": "Shirov",
"black": "Polgar",
"pgn": """[Event "Buenos Aires Sicilian"]
[Site "Buenos Aires"]
[Date "1994.10.22"]
[White "Alexei Shirov"]
[Black "Judit Polgar"]
[Result "0-1"]
1.e4 c5 2.Nf3 e6 3.d4 cxd4 4.Nxd4 Nc6 5.Nc3 d6 6.g4 a6 7.Be3 Nge7 8.Nb3 b5
9.f4 Bb7 10.Qf3 g5 11.fxg5 Ne5 12.Qg2 b4 13.Ne2 h5 14.gxh5 Nf5 15.Bf2 Qxg5
16.Na5 Ne3 17.Qg3 Qxg3 18.Nxg3 Nxc2+ 19.Kd1 Nxa1 20.Nxb7 b3 21.axb3 Nxb3
22.Kc2 Nc5 23.Nxc5 dxc5 24.Be1 Nf3 25.Bc3 Nd4+ 26.Kd3 Bd6 27.Bg2 Be5 28.Kc4
Ke7 29.Ra1 Nc6 0-1""",
},
]

658
static/app.js Normal file
View File

@ -0,0 +1,658 @@
/* Chess Pressure — main application */
(function () {
"use strict";
// --- State ---
let gameData = null; // {headers, moves, frames, result}
let currentIndex = 0; // which frame we're viewing
let playing = false;
let playTimer = null;
let pressureMode = "equal"; // "equal" or "weighted"
let board = null;
let forkPoint = null; // index where fork happened, null if on original line
let originalData = null; // saved original game before fork
let forkedMoves = []; // UCI moves made after fork point
let showPieces = true;
let showBoard = false;
let selectedSquare = null; // tap-to-move: currently selected source square
let legalMoves = []; // legal moves from selected square
const PLAY_SPEED = 800; // ms between auto-advance
// --- Pressure colors ---
function pressureColor(value, maxAbs) {
if (value === 0 || maxAbs === 0) return "transparent";
const intensity = Math.min(Math.abs(value) / maxAbs, 1);
const alpha = 0.15 + intensity * 0.55;
if (value > 0) return `rgba(50, 130, 220, ${alpha})`; // blue = white
return `rgba(220, 50, 50, ${alpha})`; // red = black
}
// --- Pressure rendering (applied directly to board squares) ---
const FILES = "abcdefgh";
const PRESSURE_SCALE = { equal: 5, weighted: 15 };
function renderPressure(frame) {
const key = pressureMode === "weighted" ? "pressure_weighted" : "pressure";
const pressure = frame[key];
const maxAbs = PRESSURE_SCALE[pressureMode];
for (let rank = 1; rank <= 8; rank++) {
for (let fileIdx = 0; fileIdx < 8; fileIdx++) {
const sq = FILES[fileIdx] + rank;
const pyIdx = (rank - 1) * 8 + fileIdx; // python-chess square index
const el = document.querySelector(`[data-square="${sq}"]`);
if (!el) continue;
const val = pressure[pyIdx];
const isLight = (rank + fileIdx) % 2 === 1;
if (!showBoard) {
// Pure pressure view — theme-aware neutral background
const isDark = document.documentElement.dataset.theme !== "light";
const neutral = isDark ? [26, 26, 26] : [245, 245, 245];
if (val === 0) {
el.style.backgroundColor = `rgb(${neutral[0]}, ${neutral[1]}, ${neutral[2]})`;
} else {
const intensity = Math.min(Math.abs(val) / maxAbs, 1);
const alpha = 0.3 + intensity * 0.7;
const tint = val > 0 ? [50, 130, 220] : [220, 50, 50];
const base = neutral;
const r = Math.round(base[0] * (1 - alpha) + tint[0] * alpha);
const g = Math.round(base[1] * (1 - alpha) + tint[1] * alpha);
const b = Math.round(base[2] * (1 - alpha) + tint[2] * alpha);
el.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
}
} else {
const base = isLight ? [192, 192, 192] : [120, 120, 120]; // grayscale
if (val === 0) {
el.style.backgroundColor = `rgb(${base[0]}, ${base[1]}, ${base[2]})`;
} else {
const intensity = Math.min(Math.abs(val) / maxAbs, 1);
const alpha = 0.2 + intensity * 0.6;
const tint = val > 0 ? [50, 130, 220] : [220, 50, 50];
const r = Math.round(base[0] * (1 - alpha) + tint[0] * alpha);
const g = Math.round(base[1] * (1 - alpha) + tint[1] * alpha);
const b = Math.round(base[2] * (1 - alpha) + tint[2] * alpha);
el.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
}
}
}
}
}
// --- Move list ---
// --- Tap-to-move ---
function clearSelection() {
selectedSquare = null;
legalMoves = [];
document.querySelectorAll(".square-selected, .square-target").forEach((el) => {
el.classList.remove("square-selected", "square-target");
});
}
function handleSquareClick(square) {
if (!gameData) return;
const frame = gameData.frames[currentIndex];
const fen = frame.board.fen;
if (selectedSquare) {
// Check if this square is a legal target
const uci = selectedSquare + square;
const isTarget = legalMoves.some((m) => m === uci || m === uci + "q");
if (isTarget) {
// Make the move
const moveUci = legalMoves.find((m) => m.startsWith(uci)) || uci;
clearSelection();
doMove(fen, moveUci);
return;
}
}
// Select a new piece (or deselect)
if (square === selectedSquare) {
clearSelection();
return;
}
// Fetch legal moves for this square
fetch("/api/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fen, uci: square + square }), // dummy — we just need to check if piece exists
}).catch(() => {});
// Check if there's a piece here by seeing if any legal move starts from this square
// We need to ask the server for legal moves from this position
fetch(`/api/legal?fen=${encodeURIComponent(fen)}`).then(r => r.ok ? r.json() : null).then(data => {
if (!data) return;
const fromMoves = data.filter((m) => m.startsWith(square));
if (fromMoves.length === 0) {
clearSelection();
return;
}
clearSelection();
selectedSquare = square;
legalMoves = fromMoves;
// Highlight
const srcEl = document.querySelector(`[data-square="${square}"]`);
if (srcEl) srcEl.classList.add("square-selected");
fromMoves.forEach((m) => {
const target = m.substring(2, 4);
const el = document.querySelector(`[data-square="${target}"]`);
if (el) el.classList.add("square-target");
});
});
}
function doMove(fen, uci) {
fetch("/api/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fen, uci }),
})
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
if (forkPoint === null && currentIndex < gameData.frames.length - 1) {
originalData = JSON.parse(JSON.stringify(gameData));
forkPoint = currentIndex;
gameData.moves = gameData.moves.slice(0, currentIndex);
gameData.frames = gameData.frames.slice(0, currentIndex + 1);
forkedMoves = [];
}
gameData.moves.push({ san: data.san, uci: data.uci, ply: currentIndex + 1 });
gameData.frames.push(data.frame);
if (forkPoint !== null) forkedMoves.push(data.uci);
goTo(gameData.frames.length - 1);
updateForkUI();
});
}
// --- Move list ---
function renderMoveList() {
const el = document.getElementById("move-list");
if (!gameData || !gameData.moves.length) {
el.innerHTML = '<span style="color:var(--fg2)">No moves</span>';
return;
}
let html = "";
for (let i = 0; i < gameData.moves.length; i += 2) {
const num = Math.floor(i / 2) + 1;
const w = gameData.moves[i];
const b = gameData.moves[i + 1];
const wClass = currentIndex === i + 1 ? "active" : "";
const bClass = b && currentIndex === i + 2 ? "active" : "";
const wForked = forkPoint !== null && i >= forkPoint ? " forked" : "";
const bForked = forkPoint !== null && i + 1 >= forkPoint ? " forked" : "";
html += `<div class="move-row">`;
html += `<span class="move-num">${num}.</span>`;
html += `<span class="move${wClass ? " " + wClass : ""}${wForked}" data-idx="${i + 1}">${w.san}</span>`;
if (b) {
html += `<span class="move${bClass ? " " + bClass : ""}${bForked}" data-idx="${i + 2}">${b.san}</span>`;
}
html += `</div>`;
}
el.innerHTML = html;
// Scroll active into view within the move list only (not the page)
const active = el.querySelector(".move.active");
if (active) {
const container = el;
const top = active.offsetTop - container.offsetTop;
const bottom = top + active.offsetHeight;
if (top < container.scrollTop) {
container.scrollTop = top;
} else if (bottom > container.scrollTop + container.clientHeight) {
container.scrollTop = bottom - container.clientHeight;
}
}
// Click handlers
el.querySelectorAll(".move[data-idx]").forEach((m) => {
m.addEventListener("click", () => goTo(parseInt(m.dataset.idx)));
});
}
// --- Navigation ---
function goTo(index) {
if (!gameData) return;
index = Math.max(0, Math.min(index, gameData.frames.length - 1));
currentIndex = index;
const frame = gameData.frames[index];
board.position(frame.board.fen, false);
renderPressure(frame);
renderMoveList();
updateSlider();
updateStatus(frame);
}
function updateSlider() {
const slider = document.getElementById("slider");
slider.max = gameData ? gameData.frames.length - 1 : 0;
slider.value = currentIndex;
}
function updateStatus(frame) {
const el = document.getElementById("status-bar");
const b = frame.board;
let text = b.turn === "w" ? "White to move" : "Black to move";
if (b.is_checkmate) text = (b.turn === "w" ? "Black" : "White") + " wins by checkmate";
else if (b.is_stalemate) text = "Stalemate";
else if (b.is_check) text += " (check)";
if (gameData.result && gameData.result !== "*") text += `${gameData.result}`;
text += ` | Move ${b.fullmove}`;
el.textContent = text;
}
function updateGameInfo() {
const el = document.getElementById("game-info");
if (!gameData || !gameData.headers) {
el.textContent = "";
return;
}
const h = gameData.headers;
const parts = [];
if (h.White) parts.push(`${h.White} vs ${h.Black || "?"}`);
if (h.Event) parts.push(h.Event);
if (h.Date) parts.push(h.Date);
el.textContent = parts.join(" — ");
}
// --- Playback ---
function togglePlay() {
playing = !playing;
document.getElementById("btn-play").innerHTML = playing ? "&#9646;&#9646;" : "&#9654;";
if (playing) {
playTimer = setInterval(() => {
if (currentIndex >= gameData.frames.length - 1) {
togglePlay();
return;
}
goTo(currentIndex + 1);
}, PLAY_SPEED);
} else {
clearInterval(playTimer);
}
}
// --- Interactive moves ---
function onDrop(source, target) {
if (source === target) {
// Tap — treat as click-to-select/move
handleSquareClick(source);
return "snapback";
}
clearSelection();
const frame = gameData.frames[currentIndex];
const fen = frame.board.fen;
let uci = source + target;
// Auto-promote to queen for pawn reaching last rank
const rank = target.charAt(1);
const piece = frame.board.fen.split(" ")[0]; // just for checking
if ((rank === "8" || rank === "1") && uci.length === 4) {
uci += "q";
}
doMove(fen, uci);
return "snapback";
}
function updateForkUI() {
const el = document.getElementById("fork-controls");
if (forkPoint !== null) {
el.style.display = "flex";
document.getElementById("fork-point").textContent = Math.floor(forkPoint / 2) + 1;
} else {
el.style.display = "none";
}
}
function resetFork() {
if (!originalData) return;
gameData = originalData;
originalData = null;
forkPoint = null;
forkedMoves = [];
goTo(0);
updateForkUI();
}
// --- Load game ---
async function loadGame(gameId) {
const r = await fetch(`/api/games/${gameId}`);
if (!r.ok) return;
gameData = await r.json();
forkPoint = null;
originalData = null;
forkedMoves = [];
currentIndex = 0;
goTo(0);
updateGameInfo();
updateForkUI();
}
async function loadPGN(pgn) {
const r = await fetch("/api/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pgn }),
});
if (!r.ok) {
alert("Failed to parse PGN");
return;
}
gameData = await r.json();
forkPoint = null;
originalData = null;
forkedMoves = [];
currentIndex = 0;
goTo(0);
updateGameInfo();
updateForkUI();
document.getElementById("game-select").value = "";
}
// --- Init ---
async function init() {
// Build board
board = Chessboard("board", {
draggable: true,
position: "start",
pieceTheme: "/static/img/chesspieces/wikipedia/{piece}.png",
onDrop: onDrop,
});
// Tap destination squares (onDrop only fires on pieces, not empty squares)
document.getElementById("board").addEventListener("click", (e) => {
if (!selectedSquare) return;
const sqEl = e.target.closest("[data-square]");
if (sqEl) handleSquareClick(sqEl.dataset.square);
});
// Restore theme before first render
const saved = localStorage.getItem("theme");
if (saved) document.documentElement.dataset.theme = saved;
// Load game list
const r = await fetch("/api/games");
const games = await r.json();
const select = document.getElementById("game-select");
games.forEach((g) => {
const opt = document.createElement("option");
opt.value = g.id;
opt.textContent = g.name;
select.appendChild(opt);
});
// Load first game by default
if (games.length) {
await loadGame(games[0].id);
select.value = games[0].id;
}
// Show board after first render (prevents tan flash)
document.getElementById("board").classList.add("ready");
// --- Event listeners ---
select.addEventListener("change", (e) => {
if (e.target.value === "__new") {
loadPGN('[White "You"]\n[Black "Opponent"]\n[Result "*"]\n\n*');
} else if (e.target.value) {
loadGame(e.target.value);
}
});
document.getElementById("slider").addEventListener("input", (e) => {
goTo(parseInt(e.target.value));
});
document.getElementById("btn-start").addEventListener("click", () => goTo(0));
document.getElementById("btn-prev").addEventListener("click", () => goTo(currentIndex - 1));
document.getElementById("btn-play").addEventListener("click", togglePlay);
document.getElementById("btn-next").addEventListener("click", () => goTo(currentIndex + 1));
document.getElementById("btn-end").addEventListener("click", () =>
goTo(gameData ? gameData.frames.length - 1 : 0)
);
document.querySelectorAll('input[name="pmode"]').forEach((r) => {
r.addEventListener("change", (e) => {
pressureMode = e.target.value;
if (gameData) renderPressure(gameData.frames[currentIndex]);
});
});
document.getElementById("toggle-pieces").addEventListener("change", (e) => {
showPieces = e.target.checked;
document.getElementById("board").classList.toggle("hide-pieces", !showPieces);
});
document.getElementById("toggle-board").addEventListener("change", (e) => {
showBoard = e.target.checked;
if (gameData) renderPressure(gameData.frames[currentIndex]);
});
document.getElementById("theme-toggle").addEventListener("click", () => {
const html = document.documentElement;
html.dataset.theme = html.dataset.theme === "dark" ? "light" : "dark";
localStorage.setItem("theme", html.dataset.theme);
if (gameData) renderPressure(gameData.frames[currentIndex]);
});
// PGN upload dialog
const dialog = document.getElementById("pgn-dialog");
document.getElementById("btn-upload").addEventListener("click", () => dialog.showModal());
document.getElementById("pgn-cancel").addEventListener("click", () => dialog.close());
document.getElementById("pgn-file-btn").addEventListener("click", () =>
document.getElementById("pgn-file").click()
);
document.getElementById("pgn-file").addEventListener("change", (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => {
document.getElementById("pgn-input").value = ev.target.result;
};
reader.readAsText(file);
}
});
document.getElementById("pgn-submit").addEventListener("click", () => {
const pgn = document.getElementById("pgn-input").value.trim();
if (pgn) {
loadPGN(pgn);
dialog.close();
}
});
// Fork reset
document.getElementById("btn-reset-fork").addEventListener("click", resetFork);
// GIF export
const exportDialog = document.getElementById("export-dialog");
document.getElementById("btn-export").addEventListener("click", () => exportDialog.showModal());
document.getElementById("export-cancel").addEventListener("click", () => exportDialog.close());
document.getElementById("export-go").addEventListener("click", exportGif);
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "TEXTAREA" || e.target.tagName === "INPUT") return;
if (e.key === "ArrowLeft") goTo(currentIndex - 1);
else if (e.key === "ArrowRight") goTo(currentIndex + 1);
else if (e.key === "Home") goTo(0);
else if (e.key === "End") goTo(gameData ? gameData.frames.length - 1 : 0);
else if (e.key === " ") { e.preventDefault(); togglePlay(); }
});
// Responsive board resize
// Re-apply pressure after any board redraw (resize, scroll zoom, etc.)
window.addEventListener("resize", () => {
board.resize();
if (gameData) renderPressure(gameData.frames[currentIndex]);
});
// Catch chessboard.js redraws that reset square colors (e.g. scroll-triggered resize)
const boardEl = document.getElementById("board");
let repaintTimer = null;
const observer = new MutationObserver(() => {
if (repaintTimer) return;
repaintTimer = setTimeout(() => {
repaintTimer = null;
if (gameData) renderPressure(gameData.frames[currentIndex]);
}, 50);
});
observer.observe(boardEl, { childList: true, subtree: true });
// Prevent board touch events from scrolling the page
boardEl.addEventListener("touchstart", (e) => { e.stopPropagation(); }, { passive: false });
boardEl.addEventListener("touchmove", (e) => { e.preventDefault(); e.stopPropagation(); }, { passive: false });
}
// --- GIF Export ---
const EXPORT_SIZE = 480;
const SQ = EXPORT_SIZE / 8;
let pieceImages = {};
function loadPieceImages() {
const pieces = ["wK","wQ","wR","wB","wN","wP","bK","bQ","bR","bB","bN","bP"];
const promises = pieces.map((p) => new Promise((resolve) => {
const img = new Image();
img.onload = () => { pieceImages[p] = img; resolve(); };
img.onerror = () => resolve();
img.src = `/static/img/chesspieces/wikipedia/${p}.png`;
}));
return Promise.all(promises);
}
function computeSquareColor(val, maxAbs, rank, fileIdx) {
const isDark = document.documentElement.dataset.theme !== "light";
const isLight = (rank + fileIdx) % 2 === 1;
if (!showBoard) {
const neutral = isDark ? [26, 26, 26] : [245, 245, 245];
if (val === 0) return `rgb(${neutral[0]},${neutral[1]},${neutral[2]})`;
const intensity = Math.min(Math.abs(val) / maxAbs, 1);
const alpha = 0.3 + intensity * 0.7;
const tint = val > 0 ? [50, 130, 220] : [220, 50, 50];
const r = Math.round(neutral[0] * (1 - alpha) + tint[0] * alpha);
const g = Math.round(neutral[1] * (1 - alpha) + tint[1] * alpha);
const b = Math.round(neutral[2] * (1 - alpha) + tint[2] * alpha);
return `rgb(${r},${g},${b})`;
} else {
const base = isLight ? [192, 192, 192] : [120, 120, 120];
if (val === 0) return `rgb(${base[0]},${base[1]},${base[2]})`;
const intensity = Math.min(Math.abs(val) / maxAbs, 1);
const alpha = 0.2 + intensity * 0.6;
const tint = val > 0 ? [50, 130, 220] : [220, 50, 50];
const r = Math.round(base[0] * (1 - alpha) + tint[0] * alpha);
const g = Math.round(base[1] * (1 - alpha) + tint[1] * alpha);
const b = Math.round(base[2] * (1 - alpha) + tint[2] * alpha);
return `rgb(${r},${g},${b})`;
}
}
function drawFrame(ctx, frame) {
const key = pressureMode === "weighted" ? "pressure_weighted" : "pressure";
const pressure = frame[key];
const maxAbs = PRESSURE_SCALE[pressureMode];
const fen = frame.board.fen.split(" ")[0];
// Draw squares
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
const rank = 8 - row;
const pyIdx = (rank - 1) * 8 + col;
const color = computeSquareColor(pressure[pyIdx], maxAbs, rank, col);
ctx.fillStyle = color;
ctx.fillRect(col * SQ, row * SQ, SQ, SQ);
}
}
// Draw pieces if enabled
if (showPieces) {
const rows = fen.split("/");
for (let row = 0; row < 8; row++) {
let col = 0;
for (const ch of rows[row]) {
if (ch >= "1" && ch <= "8") {
col += parseInt(ch);
} else {
const color = ch === ch.toUpperCase() ? "w" : "b";
const pieceMap = { k:"K", q:"Q", r:"R", b:"B", n:"N", p:"P" };
const key = color + pieceMap[ch.toLowerCase()];
const img = pieceImages[key];
if (img) {
ctx.drawImage(img, col * SQ, row * SQ, SQ, SQ);
}
col++;
}
}
}
}
}
async function exportGif() {
if (!gameData || gameData.frames.length === 0) return;
await loadPieceImages();
const speed = parseInt(document.getElementById("export-speed").value);
const progressEl = document.getElementById("export-progress");
const barEl = document.getElementById("export-bar");
const pctEl = document.getElementById("export-pct");
const goBtn = document.getElementById("export-go");
progressEl.style.display = "flex";
goBtn.disabled = true;
goBtn.textContent = "Exporting...";
const gif = new GIF({
workers: 2,
quality: 10,
width: EXPORT_SIZE,
height: EXPORT_SIZE,
workerScript: "/static/gif.worker.js",
});
const canvas = document.createElement("canvas");
canvas.width = EXPORT_SIZE;
canvas.height = EXPORT_SIZE;
const ctx = canvas.getContext("2d");
const total = gameData.frames.length;
for (let i = 0; i < total; i++) {
drawFrame(ctx, gameData.frames[i]);
gif.addFrame(ctx, { copy: true, delay: speed });
const pct = Math.round(((i + 1) / total) * 50);
barEl.value = pct;
pctEl.textContent = pct + "%";
}
gif.on("progress", (p) => {
const pct = 50 + Math.round(p * 50);
barEl.value = pct;
pctEl.textContent = pct + "%";
});
gif.on("finished", (blob) => {
barEl.value = 100;
pctEl.textContent = "100%";
goBtn.disabled = false;
goBtn.textContent = "Export";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chess-pressure.gif";
a.click();
URL.revokeObjectURL(url);
setTimeout(() => { progressEl.style.display = "none"; }, 1000);
});
gif.render();
}
document.addEventListener("DOMContentLoaded", init);
})();

3
static/gif.js Normal file

File diff suppressed because one or more lines are too long

3
static/gif.worker.js Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

111
static/index.html Normal file
View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chess Pressure</title>
<link rel="stylesheet" href="/static/style.css?v=21">
<link rel="stylesheet" href="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.css">
</head>
<body>
<header>
<h1>Chess Pressure</h1>
<div class="header-controls">
<button id="theme-toggle" title="Toggle dark/light mode">
<span class="icon-sun">&#9788;</span>
<span class="icon-moon">&#9790;</span>
</button>
</div>
</header>
<main>
<div class="board-panel">
<div class="board-wrap">
<div id="board"></div>
</div>
<div class="playback">
<input type="range" id="slider" min="0" max="0" value="0">
<div class="playback-buttons">
<button id="btn-start" title="Start">&#9198;</button>
<button id="btn-prev" title="Back">&#9664;</button>
<button id="btn-play" title="Play/Pause">&#9654;</button>
<button id="btn-next" title="Forward">&#9654;</button>
<button id="btn-end" title="End">&#9197;</button>
</div>
<div class="pressure-mode">
<label><input type="radio" name="pmode" value="equal" checked> Equal weight</label>
<label><input type="radio" name="pmode" value="weighted"> Piece value</label>
<label><input type="checkbox" id="toggle-pieces" checked> Pieces</label>
<label><input type="checkbox" id="toggle-board"> Board</label>
</div>
</div>
</div>
<div class="side-panel">
<div class="game-selector">
<select id="game-select">
<option value="__new">New game</option>
</select>
<button id="btn-upload" title="Upload PGN">PGN</button>
</div>
<div class="game-info" id="game-info"></div>
<div class="move-list" id="move-list"></div>
<div class="fork-controls" id="fork-controls" style="display:none">
<span class="fork-badge">Forked at move <span id="fork-point"></span></span>
<button id="btn-reset-fork">Reset to original</button>
</div>
<div class="export-row">
<button id="btn-export">Export GIF</button>
</div>
<div class="status-bar" id="status-bar"></div>
</div>
</main>
<dialog id="export-dialog">
<form method="dialog">
<h2>Export GIF</h2>
<label>Speed per move:
<select id="export-speed">
<option value="250">0.25s (fast)</option>
<option value="500" selected>0.5s</option>
<option value="1000">1s</option>
<option value="2000">2s (slow)</option>
</select>
</label>
<p class="export-info">480 &times; 480px &mdash; respects current view settings</p>
<div class="export-progress" id="export-progress" style="display:none">
<progress id="export-bar" max="100" value="0"></progress>
<span id="export-pct">0%</span>
</div>
<div class="dialog-buttons">
<button type="button" id="export-go">Export</button>
<button type="button" id="export-cancel">Cancel</button>
</div>
</form>
</dialog>
<dialog id="pgn-dialog">
<form method="dialog">
<h2>Upload PGN</h2>
<textarea id="pgn-input" rows="12" placeholder="Paste PGN here..."></textarea>
<div class="dialog-buttons">
<button type="button" id="pgn-file-btn">Choose file</button>
<input type="file" id="pgn-file" accept=".pgn,.txt" style="display:none">
<button type="submit" id="pgn-submit">Load</button>
<button type="button" id="pgn-cancel">Cancel</button>
</div>
</form>
</dialog>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.10.3/chess.min.js"></script>
<script src="/static/gif.js"></script>
<script src="/static/app.js?v=21"></script>
</body>
</html>

339
static/style.css Normal file
View File

@ -0,0 +1,339 @@
/* --- Reset & Base --- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a1a;
--bg2: #242424;
--fg: #e0e0e0;
--fg2: #999;
--border: #333;
--accent: #6ca;
--move-bg: #2a2a2a;
--move-active: #3a3a3a;
--fork-color: #f90;
}
[data-theme="light"] {
--bg: #f5f5f5;
--bg2: #fff;
--fg: #1a1a1a;
--fg2: #666;
--border: #ddd;
--accent: #287;
--move-bg: #eee;
--move-active: #ddd;
--fork-color: #c60;
}
html { font-size: 15px; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
background: var(--bg);
color: var(--fg);
min-height: 100dvh;
}
/* --- Header --- */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.2rem;
border-bottom: 1px solid var(--border);
}
header h1 {
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.header-controls { display: flex; gap: 0.5rem; }
#theme-toggle {
background: none;
border: 1px solid var(--border);
color: var(--fg);
padding: 0.3rem 0.6rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
[data-theme="dark"] .icon-sun { display: none; }
[data-theme="light"] .icon-moon { display: none; }
/* --- Layout --- */
main {
display: flex;
gap: 1.2rem;
padding: 1.2rem;
max-width: 1100px;
margin: 0 auto;
}
.board-panel { flex: 0 0 auto; }
.side-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.8rem;
min-width: 0;
}
/* --- Board --- */
.board-wrap {
position: relative;
width: min(480px, calc(100vw - 2.4rem));
aspect-ratio: 1;
touch-action: none;
-webkit-overflow-scrolling: auto;
}
#board {
width: 100%;
height: 100%;
touch-action: none;
}
/* Board square colors are set by renderPressure() in JS */
.hide-pieces .piece-417db { opacity: 0 !important; }
/* Hide board until first render to prevent tan flash */
#board { visibility: hidden; }
#board.ready { visibility: visible; }
/* Tap-to-move highlights */
.square-selected { box-shadow: inset 0 0 0 3px rgba(255, 255, 100, 0.8) !important; }
.square-target { box-shadow: inset 0 0 0 3px rgba(100, 255, 100, 0.6) !important; }
.square-target::after {
content: "";
display: block;
width: 30%;
height: 30%;
margin: 35%;
border-radius: 50%;
background: rgba(100, 255, 100, 0.4);
}
/* --- Playback --- */
.playback {
margin-top: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
#slider {
width: 100%;
accent-color: var(--accent);
touch-action: none;
}
.playback-buttons {
display: flex;
gap: 0.3rem;
justify-content: center;
}
.playback-buttons button {
background: var(--bg2);
border: 1px solid var(--border);
color: var(--fg);
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.playback-buttons button:hover { background: var(--move-active); }
#btn-next { transform: scaleX(1); }
.pressure-mode {
display: flex;
gap: 1rem;
justify-content: center;
font-size: 0.8rem;
color: var(--fg2);
}
.pressure-mode label { cursor: pointer; }
/* --- Game selector --- */
.game-selector {
display: flex;
gap: 0.5rem;
min-width: 0;
}
.game-selector select {
flex: 1;
min-width: 0;
background: var(--bg2);
color: var(--fg);
border: 1px solid var(--border);
padding: 0.4rem;
border-radius: 4px;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
}
.game-selector button,
.fork-controls button {
background: var(--bg2);
border: 1px solid var(--border);
color: var(--fg);
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.game-selector button:hover,
.fork-controls button:hover { background: var(--move-active); }
/* --- Game info --- */
.game-info {
font-size: 0.8rem;
color: var(--fg2);
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
}
/* --- Move list --- */
.move-list {
flex: 1;
overflow-y: auto;
font-size: 0.85rem;
line-height: 1.6;
max-height: 50vh;
padding: 0.4rem 0;
}
.move-row {
display: flex;
gap: 0.3rem;
padding: 0 0.3rem;
}
.move-num {
color: var(--fg2);
min-width: 2.2rem;
text-align: right;
}
.move {
padding: 0.1rem 0.4rem;
border-radius: 3px;
cursor: pointer;
min-width: 4rem;
}
.move:hover { background: var(--move-bg); }
.move.active { background: var(--move-active); font-weight: 600; }
.move.forked { border-left: 2px solid var(--fork-color); }
/* --- Fork controls --- */
.fork-controls {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0;
font-size: 0.8rem;
}
.fork-badge {
color: var(--fork-color);
font-weight: 600;
}
/* --- Export --- */
.export-row {
padding: 0.4rem 0;
}
.export-row button {
background: var(--bg2);
border: 1px solid var(--border);
color: var(--fg);
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
width: 100%;
}
.export-row button:hover { background: var(--move-active); }
dialog label {
display: block;
font-size: 0.85rem;
margin-bottom: 0.6rem;
}
dialog select {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.3rem;
font-size: 0.85rem;
}
.export-info {
font-size: 0.8rem;
color: var(--fg2);
margin: 0.4rem 0;
}
.export-progress {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.6rem 0;
}
.export-progress progress {
flex: 1;
height: 6px;
accent-color: var(--accent);
}
#export-pct { font-size: 0.8rem; color: var(--fg2); }
/* --- Status bar --- */
.status-bar {
font-size: 0.8rem;
color: var(--fg2);
padding: 0.4rem 0;
border-top: 1px solid var(--border);
}
/* --- PGN Dialog --- */
dialog {
background: var(--bg2);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
max-width: 500px;
width: 90vw;
margin: auto;
top: 50%;
transform: translateY(-50%);
}
dialog::backdrop { background: rgba(0,0,0,0.6); }
dialog h2 { font-size: 1rem; margin-bottom: 0.8rem; }
dialog textarea {
width: 100%;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.6rem;
font-family: monospace;
font-size: 0.8rem;
resize: vertical;
}
.dialog-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.8rem;
justify-content: flex-end;
}
.dialog-buttons button {
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.dialog-buttons button:hover { background: var(--move-active); }
/* --- Mobile --- */
@media (max-width: 720px) {
main {
flex-direction: column;
padding: 0.8rem;
}
.board-wrap { width: 100%; }
.move-list { max-height: 30vh; }
}