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 + + + + +
+

Chess Pressure

+
+ +
+
+ +
+
+
+
+
+
+ +
+ + + + + +
+
+ + + + +
+
+
+ +
+
+ + +
+ +
+ +
+ + + +
+ +
+ +
+
+
+ + +
+

Export GIF

+ +

480 × 480px — respects current view settings

+ +
+ + +
+
+
+ + +
+

Upload PGN

+ +
+ + + + +
+
+
+ + + + + + + + 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; } +}