diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..afa8c93
--- /dev/null
+++ b/Makefile
@@ -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"
diff --git a/games.py b/games.py
index 0d93803..ff092b2 100644
--- a/games.py
+++ b/games.py
@@ -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""",
+ },
]
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..62e945f
--- /dev/null
+++ b/static/app.js
@@ -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 = 'No moves';
+ 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 += `
`;
+ html += `${num}.`;
+ html += `${w.san}`;
+ if (b) {
+ html += `${b.san}`;
+ }
+ html += `
`;
+ }
+ 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 ? "▮▮" : "▶";
+ 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);
+})();
diff --git a/static/gif.js b/static/gif.js
new file mode 100644
index 0000000..2e4d204
--- /dev/null
+++ b/static/gif.js
@@ -0,0 +1,3 @@
+// gif.js 0.2.0 - https://github.com/jnordberg/gif.js
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.GIF=f()}})(function(){var define,module,exports;return function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o0&&this._events[type].length>m){this._events[type].warned=true;console.error("(node) warning: possible EventEmitter memory "+"leak detected. %d listeners added. "+"Use emitter.setMaxListeners() to increase limit.",this._events[type].length);if(typeof console.trace==="function"){console.trace()}}}return this};EventEmitter.prototype.on=EventEmitter.prototype.addListener;EventEmitter.prototype.once=function(type,listener){if(!isFunction(listener))throw TypeError("listener must be a function");var fired=false;function g(){this.removeListener(type,g);if(!fired){fired=true;listener.apply(this,arguments)}}g.listener=listener;this.on(type,g);return this};EventEmitter.prototype.removeListener=function(type,listener){var list,position,length,i;if(!isFunction(listener))throw TypeError("listener must be a function");if(!this._events||!this._events[type])return this;list=this._events[type];length=list.length;position=-1;if(list===listener||isFunction(list.listener)&&list.listener===listener){delete this._events[type];if(this._events.removeListener)this.emit("removeListener",type,listener)}else if(isObject(list)){for(i=length;i-- >0;){if(list[i]===listener||list[i].listener&&list[i].listener===listener){position=i;break}}if(position<0)return this;if(list.length===1){list.length=0;delete this._events[type]}else{list.splice(position,1)}if(this._events.removeListener)this.emit("removeListener",type,listener)}return this};EventEmitter.prototype.removeAllListeners=function(type){var key,listeners;if(!this._events)return this;if(!this._events.removeListener){if(arguments.length===0)this._events={};else if(this._events[type])delete this._events[type];return this}if(arguments.length===0){for(key in this._events){if(key==="removeListener")continue;this.removeAllListeners(key)}this.removeAllListeners("removeListener");this._events={};return this}listeners=this._events[type];if(isFunction(listeners)){this.removeListener(type,listeners)}else if(listeners){while(listeners.length)this.removeListener(type,listeners[listeners.length-1])}delete this._events[type];return this};EventEmitter.prototype.listeners=function(type){var ret;if(!this._events||!this._events[type])ret=[];else if(isFunction(this._events[type]))ret=[this._events[type]];else ret=this._events[type].slice();return ret};EventEmitter.prototype.listenerCount=function(type){if(this._events){var evlistener=this._events[type];if(isFunction(evlistener))return 1;else if(evlistener)return evlistener.length}return 0};EventEmitter.listenerCount=function(emitter,type){return emitter.listenerCount(type)};function isFunction(arg){return typeof arg==="function"}function isNumber(arg){return typeof arg==="number"}function isObject(arg){return typeof arg==="object"&&arg!==null}function isUndefined(arg){return arg===void 0}},{}],2:[function(require,module,exports){var UA,browser,mode,platform,ua;ua=navigator.userAgent.toLowerCase();platform=navigator.platform.toLowerCase();UA=ua.match(/(opera|ie|firefox|chrome|version)[\s\/:]([\w\d\.]+)?.*?(safari|version[\s\/:]([\w\d\.]+)|$)/)||[null,"unknown",0];mode=UA[1]==="ie"&&document.documentMode;browser={name:UA[1]==="version"?UA[3]:UA[1],version:mode||parseFloat(UA[1]==="opera"&&UA[4]?UA[4]:UA[2]),platform:{name:ua.match(/ip(?:ad|od|hone)/)?"ios":(ua.match(/(?:webos|android)/)||platform.match(/mac|win|linux/)||["other"])[0]}};browser[browser.name]=true;browser[browser.name+parseInt(browser.version,10)]=true;browser.platform[browser.platform.name]=true;module.exports=browser},{}],3:[function(require,module,exports){var EventEmitter,GIF,browser,extend=function(child,parent){for(var key in parent){if(hasProp.call(parent,key))child[key]=parent[key]}function ctor(){this.constructor=child}ctor.prototype=parent.prototype;child.prototype=new ctor;child.__super__=parent.prototype;return child},hasProp={}.hasOwnProperty,indexOf=[].indexOf||function(item){for(var i=0,l=this.length;iref;i=0<=ref?++j:--j){results.push(null)}return results}.call(this);numWorkers=this.spawnWorkers();if(this.options.globalPalette===true){this.renderNextFrame()}else{for(i=j=0,ref=numWorkers;0<=ref?jref;i=0<=ref?++j:--j){this.renderNextFrame()}}this.emit("start");return this.emit("progress",0)};GIF.prototype.abort=function(){var worker;while(true){worker=this.activeWorkers.shift();if(worker==null){break}this.log("killing active worker");worker.terminate()}this.running=false;return this.emit("abort")};GIF.prototype.spawnWorkers=function(){var j,numWorkers,ref,results;numWorkers=Math.min(this.options.workers,this.frames.length);(function(){results=[];for(var j=ref=this.freeWorkers.length;ref<=numWorkers?jnumWorkers;ref<=numWorkers?j++:j--){results.push(j)}return results}).apply(this).forEach(function(_this){return function(i){var worker;_this.log("spawning worker "+i);worker=new Worker(_this.options.workerScript);worker.onmessage=function(event){_this.activeWorkers.splice(_this.activeWorkers.indexOf(worker),1);_this.freeWorkers.push(worker);return _this.frameFinished(event.data)};return _this.freeWorkers.push(worker)}}(this));return numWorkers};GIF.prototype.frameFinished=function(frame){var i,j,ref;this.log("frame "+frame.index+" finished - "+this.activeWorkers.length+" active");this.finishedFrames++;this.emit("progress",this.finishedFrames/this.frames.length);this.imageParts[frame.index]=frame;if(this.options.globalPalette===true){this.options.globalPalette=frame.globalPalette;this.log("global palette analyzed");if(this.frames.length>2){for(i=j=1,ref=this.freeWorkers.length;1<=ref?jref;i=1<=ref?++j:--j){this.renderNextFrame()}}}if(indexOf.call(this.imageParts,null)>=0){return this.renderNextFrame()}else{return this.finishRendering()}};GIF.prototype.finishRendering=function(){var data,frame,i,image,j,k,l,len,len1,len2,len3,offset,page,ref,ref1,ref2;len=0;ref=this.imageParts;for(j=0,len1=ref.length;j=this.frames.length){return}frame=this.frames[this.nextFrame++];worker=this.freeWorkers.shift();task=this.getTask(frame);this.log("starting frame "+(task.index+1)+" of "+this.frames.length);this.activeWorkers.push(worker);return worker.postMessage(task)};GIF.prototype.getContextData=function(ctx){return ctx.getImageData(0,0,this.options.width,this.options.height).data};GIF.prototype.getImageData=function(image){var ctx;if(this._canvas==null){this._canvas=document.createElement("canvas");this._canvas.width=this.options.width;this._canvas.height=this.options.height}ctx=this._canvas.getContext("2d");ctx.setFill=this.options.background;ctx.fillRect(0,0,this.options.width,this.options.height);ctx.drawImage(image,0,0);return this.getContextData(ctx)};GIF.prototype.getTask=function(frame){var index,task;index=this.frames.indexOf(frame);task={index:index,last:index===this.frames.length-1,delay:frame.delay,transparent:frame.transparent,width:this.options.width,height:this.options.height,quality:this.options.quality,dither:this.options.dither,globalPalette:this.options.globalPalette,repeat:this.options.repeat,canTransfer:browser.name==="chrome"};if(frame.data!=null){task.data=frame.data}else if(frame.context!=null){task.data=this.getContextData(frame.context)}else if(frame.image!=null){task.data=this.getImageData(frame.image)}else{throw new Error("Invalid frame")}return task};GIF.prototype.log=function(){var args;args=1<=arguments.length?slice.call(arguments,0):[];if(!this.options.debug){return}return console.log.apply(console,args)};return GIF}(EventEmitter);module.exports=GIF},{"./browser.coffee":2,events:1}]},{},[3])(3)});
+//# sourceMappingURL=gif.js.map
diff --git a/static/gif.worker.js b/static/gif.worker.js
new file mode 100644
index 0000000..269624e
--- /dev/null
+++ b/static/gif.worker.js
@@ -0,0 +1,3 @@
+// gif.worker.js 0.2.0 - https://github.com/jnordberg/gif.js
+(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o=ByteArray.pageSize)this.newPage();this.pages[this.page][this.cursor++]=val};ByteArray.prototype.writeUTFBytes=function(string){for(var l=string.length,i=0;i=0)this.dispose=disposalCode};GIFEncoder.prototype.setRepeat=function(repeat){this.repeat=repeat};GIFEncoder.prototype.setTransparent=function(color){this.transparent=color};GIFEncoder.prototype.addFrame=function(imageData){this.image=imageData;this.colorTab=this.globalPalette&&this.globalPalette.slice?this.globalPalette:null;this.getImagePixels();this.analyzePixels();if(this.globalPalette===true)this.globalPalette=this.colorTab;if(this.firstFrame){this.writeLSD();this.writePalette();if(this.repeat>=0){this.writeNetscapeExt()}}this.writeGraphicCtrlExt();this.writeImageDesc();if(!this.firstFrame&&!this.globalPalette)this.writePalette();this.writePixels();this.firstFrame=false};GIFEncoder.prototype.finish=function(){this.out.writeByte(59)};GIFEncoder.prototype.setQuality=function(quality){if(quality<1)quality=1;this.sample=quality};GIFEncoder.prototype.setDither=function(dither){if(dither===true)dither="FloydSteinberg";this.dither=dither};GIFEncoder.prototype.setGlobalPalette=function(palette){this.globalPalette=palette};GIFEncoder.prototype.getGlobalPalette=function(){return this.globalPalette&&this.globalPalette.slice&&this.globalPalette.slice(0)||this.globalPalette};GIFEncoder.prototype.writeHeader=function(){this.out.writeUTFBytes("GIF89a")};GIFEncoder.prototype.analyzePixels=function(){if(!this.colorTab){this.neuQuant=new NeuQuant(this.pixels,this.sample);this.neuQuant.buildColormap();this.colorTab=this.neuQuant.getColormap()}if(this.dither){this.ditherPixels(this.dither.replace("-serpentine",""),this.dither.match(/-serpentine/)!==null)}else{this.indexPixels()}this.pixels=null;this.colorDepth=8;this.palSize=7;if(this.transparent!==null){this.transIndex=this.findClosest(this.transparent,true)}};GIFEncoder.prototype.indexPixels=function(imgq){var nPix=this.pixels.length/3;this.indexedPixels=new Uint8Array(nPix);var k=0;for(var j=0;j=0&&x1+x=0&&y1+y>16,(c&65280)>>8,c&255,used)};GIFEncoder.prototype.findClosestRGB=function(r,g,b,used){if(this.colorTab===null)return-1;if(this.neuQuant&&!used){return this.neuQuant.lookupRGB(r,g,b)}var c=b|g<<8|r<<16;var minpos=0;var dmin=256*256*256;var len=this.colorTab.length;for(var i=0,index=0;i=0){disp=dispose&7}disp<<=2;this.out.writeByte(0|disp|0|transp);this.writeShort(this.delay);this.out.writeByte(this.transIndex);this.out.writeByte(0)};GIFEncoder.prototype.writeImageDesc=function(){this.out.writeByte(44);this.writeShort(0);this.writeShort(0);this.writeShort(this.width);this.writeShort(this.height);if(this.firstFrame||this.globalPalette){this.out.writeByte(0)}else{this.out.writeByte(128|0|0|0|this.palSize)}};GIFEncoder.prototype.writeLSD=function(){this.writeShort(this.width);this.writeShort(this.height);this.out.writeByte(128|112|0|this.palSize);this.out.writeByte(0);this.out.writeByte(0)};GIFEncoder.prototype.writeNetscapeExt=function(){this.out.writeByte(33);this.out.writeByte(255);this.out.writeByte(11);this.out.writeUTFBytes("NETSCAPE2.0");this.out.writeByte(3);this.out.writeByte(1);this.writeShort(this.repeat);this.out.writeByte(0)};GIFEncoder.prototype.writePalette=function(){this.out.writeBytes(this.colorTab);var n=3*256-this.colorTab.length;for(var i=0;i>8&255)};GIFEncoder.prototype.writePixels=function(){var enc=new LZWEncoder(this.width,this.height,this.indexedPixels,this.colorDepth);enc.encode(this.out)};GIFEncoder.prototype.stream=function(){return this.out};module.exports=GIFEncoder},{"./LZWEncoder.js":2,"./TypedNeuQuant.js":3}],2:[function(require,module,exports){var EOF=-1;var BITS=12;var HSIZE=5003;var masks=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535];function LZWEncoder(width,height,pixels,colorDepth){var initCodeSize=Math.max(2,colorDepth);var accum=new Uint8Array(256);var htab=new Int32Array(HSIZE);var codetab=new Int32Array(HSIZE);var cur_accum,cur_bits=0;var a_count;var free_ent=0;var maxcode;var clear_flg=false;var g_init_bits,ClearCode,EOFCode;function char_out(c,outs){accum[a_count++]=c;if(a_count>=254)flush_char(outs)}function cl_block(outs){cl_hash(HSIZE);free_ent=ClearCode+2;clear_flg=true;output(ClearCode,outs)}function cl_hash(hsize){for(var i=0;i=0){disp=hsize_reg-i;if(i===0)disp=1;do{if((i-=disp)<0)i+=hsize_reg;if(htab[i]===fcode){ent=codetab[i];continue outer_loop}}while(htab[i]>=0)}output(ent,outs);ent=c;if(free_ent<1<0){outs.writeByte(a_count);outs.writeBytes(accum,0,a_count);a_count=0}}function MAXCODE(n_bits){return(1<0)cur_accum|=code<=8){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}if(free_ent>maxcode||clear_flg){if(clear_flg){maxcode=MAXCODE(n_bits=g_init_bits);clear_flg=false}else{++n_bits;if(n_bits==BITS)maxcode=1<0){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}flush_char(outs)}}this.encode=encode}module.exports=LZWEncoder},{}],3:[function(require,module,exports){var ncycles=100;var netsize=256;var maxnetpos=netsize-1;var netbiasshift=4;var intbiasshift=16;var intbias=1<>betashift;var betagamma=intbias<>3;var radiusbiasshift=6;var radiusbias=1<>3);var i,v;for(i=0;i>=netbiasshift;network[i][1]>>=netbiasshift;network[i][2]>>=netbiasshift;network[i][3]=i}}function altersingle(alpha,i,b,g,r){network[i][0]-=alpha*(network[i][0]-b)/initalpha;network[i][1]-=alpha*(network[i][1]-g)/initalpha;network[i][2]-=alpha*(network[i][2]-r)/initalpha}function alterneigh(radius,i,b,g,r){var lo=Math.abs(i-radius);var hi=Math.min(i+radius,netsize);var j=i+1;var k=i-1;var m=1;var p,a;while(jlo){a=radpower[m++];if(jlo){p=network[k--];p[0]-=a*(p[0]-b)/alpharadbias;p[1]-=a*(p[1]-g)/alpharadbias;p[2]-=a*(p[2]-r)/alpharadbias}}}function contest(b,g,r){var bestd=~(1<<31);var bestbiasd=bestd;var bestpos=-1;var bestbiaspos=bestpos;var i,n,dist,biasdist,betafreq;for(i=0;i>intbiasshift-netbiasshift);if(biasdist>betashift;freq[i]-=betafreq;bias[i]+=betafreq<>1;for(j=previouscol+1;j>1;for(j=previouscol+1;j<256;j++)netindex[j]=maxnetpos}function inxsearch(b,g,r){var a,p,dist;var bestd=1e3;var best=-1;var i=netindex[g];var j=i-1;while(i=0){if(i=bestd)i=netsize;else{i++;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist=0){p=network[j];dist=g-p[1];if(dist>=bestd)j=-1;else{j--;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist>radiusbiasshift;if(rad<=1)rad=0;for(i=0;i=lengthcount)pix-=lengthcount;i++;if(delta===0)delta=1;if(i%delta===0){alpha-=alpha/alphadec;radius-=radius/radiusdec;rad=radius>>radiusbiasshift;if(rad<=1)rad=0;for(j=0;j
+
+
+
+
+ Chess Pressure
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Forked at move
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..0e917c4
--- /dev/null
+++ b/static/style.css
@@ -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; }
+}