From 138122a35bc841212c5626e29d7bc4a61cba475c Mon Sep 17 00:00:00 2001 From: Michael Pilosov Date: Sun, 5 Apr 2026 22:44:01 -0600 Subject: [PATCH] Initial commit: chess pressure visualization app --- .gitignore | 50 +++++++++++++++++++++ README.md | 63 ++++++++++++++++++++++++++ __init__.py | 0 app.py | 88 ++++++++++++++++++++++++++++++++++++ engine.py | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++ games.py | 112 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 439 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 app.py create mode 100644 engine.py create mode 100644 games.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b695864 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# mypy +.mypy_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee1a776 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Chess Pressure + +A web application that visualizes **pressure maps** on a chess board -- showing which squares are controlled by white vs. black across every move of a game. + +## What is "pressure"? + +For each square on the board, pressure is the net number of pieces attacking it. Positive values mean white controls the square; negative means black does. Two modes are available: + +- **Unweighted** -- each attacking piece contributes +1 or -1. +- **Weighted** -- each attacking piece contributes its material value (pawn=1, knight/bishop=3, rook=5, queen=9, king=1). + +## Features + +- Step through famous built-in games move by move and watch the pressure heatmap evolve. +- Upload your own PGN to analyze any game. +- Make moves from any position and see legal moves + pressure updates in real time. +- FastAPI backend with a lightweight static frontend. + +## Built-in Games + +| Game | Year | White | Black | +|------|------|-------|-------| +| The Immortal Game | 1851 | Anderssen | Kieseritzky | +| The Opera Game | 1858 | Morphy | Duke of Brunswick & Count Isouard | +| Kasparov's Immortal | 1999 | Kasparov | Topalov | +| Fischer vs Spassky, Game 6 | 1972 | Fischer | Spassky | +| Kasparov vs Deep Blue, Game 2 | 1997 | Deep Blue | Kasparov | + +## Quickstart + +```bash +# Install dependencies +pip install fastapi uvicorn python-chess + +# Run the server +python -m chess_pressure.app +``` + +The app starts on [http://localhost:8888](http://localhost:8888). + +## API + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/games` | List built-in games | +| GET | `/api/games/{id}` | Load a built-in game (headers, moves, pressure frames) | +| POST | `/api/parse` | Parse a PGN string (`{"pgn": "..."}`) | +| GET | `/api/legal?fen=...` | Get legal moves for a FEN position | +| POST | `/api/move` | Make a move (`{"fen": "...", "uci": "e2e4"}`) | + +## Project Structure + +``` +chess_pressure/ + __init__.py + app.py # FastAPI routes and static file serving + engine.py # Pressure computation, PGN parsing, move logic + games.py # Built-in famous games (PGN data) +``` + +## License + +MIT diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py new file mode 100644 index 0000000..482027f --- /dev/null +++ b/app.py @@ -0,0 +1,88 @@ +"""FastAPI application for chess pressure visualization.""" + +from __future__ import annotations + +from pathlib import Path + +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from .engine import make_move, parse_pgn +from .games import get_game_list, get_game_pgn + +STATIC = Path(__file__).resolve().parent.parent / "static" + +app = FastAPI(title="Chess Pressure", docs_url=None, redoc_url=None) + + +# --- Models --- + +class PGNUpload(BaseModel): + pgn: str + + +class MoveRequest(BaseModel): + fen: str + uci: str + + +# --- API --- + +@app.get("/api/games") +def list_games(): + return get_game_list() + + +@app.get("/api/games/{game_id}") +def load_game(game_id: str): + pgn = get_game_pgn(game_id) + if pgn is None: + raise HTTPException(404, "Game not found") + return parse_pgn(pgn) + + +@app.post("/api/parse") +def parse_uploaded_pgn(body: PGNUpload): + try: + return parse_pgn(body.pgn) + except ValueError as e: + raise HTTPException(400, str(e)) + + +@app.get("/api/legal") +def legal_moves(fen: str): + import chess + board = chess.Board(fen) + return [m.uci() for m in board.legal_moves] + + +@app.post("/api/move") +def do_move(body: MoveRequest): + try: + return make_move(body.fen, body.uci) + except ValueError as e: + raise HTTPException(400, str(e)) + + +# --- Static files --- + +app.mount("/static", StaticFiles(directory=str(STATIC)), name="static") + + +@app.get("/") +def index(): + return FileResponse( + str(STATIC / "index.html"), + headers={"Cache-Control": "no-cache, must-revalidate"}, + ) + + +def main(): + uvicorn.run("chess_pressure.app:app", host="0.0.0.0", port=8888, workers=2) + + +if __name__ == "__main__": + main() diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..ef83f27 --- /dev/null +++ b/engine.py @@ -0,0 +1,126 @@ +"""Chess pressure computation engine.""" + +from __future__ import annotations + +import chess +import chess.pgn +import io + +PIECE_VALUES = { + chess.PAWN: 1, + chess.KNIGHT: 3, + chess.BISHOP: 3, + chess.ROOK: 5, + chess.QUEEN: 9, + chess.KING: 1, +} + + +def compute_pressure(board: chess.Board, weighted: bool = False) -> list[int]: + """Compute net pressure for each square. + + Positive = white controls, negative = black controls. + """ + pressure = [0] * 64 + for square in chess.SQUARES: + piece = board.piece_at(square) + if piece is None: + continue + attacks = board.attacks(square) + weight = PIECE_VALUES[piece.piece_type] if weighted else 1 + sign = 1 if piece.color == chess.WHITE else -1 + for target in attacks: + pressure[target] += sign * weight + return pressure + + +def board_to_dict(board: chess.Board) -> dict: + """Serialize board state for the frontend.""" + return { + "fen": board.fen(), + "turn": "w" if board.turn == chess.WHITE else "b", + "is_check": board.is_check(), + "is_checkmate": board.is_checkmate(), + "is_stalemate": board.is_stalemate(), + "is_game_over": board.is_game_over(), + "fullmove": board.fullmove_number, + } + + +def parse_pgn(pgn_text: str) -> dict: + """Parse a PGN string and compute pressure for every position. + + Returns {headers, moves, frames} where frames[i] corresponds to position after moves[i]. + frames[0] is the starting position. + """ + game = chess.pgn.read_game(io.StringIO(pgn_text)) + if game is None: + raise ValueError("Could not parse PGN") + + headers = dict(game.headers) + board = game.board() + + moves = [] + frames = [] + + # Frame 0: starting position + frames.append({ + "board": board_to_dict(board), + "pressure": compute_pressure(board, weighted=False), + "pressure_weighted": compute_pressure(board, weighted=True), + }) + + for node in game.mainline(): + move = node.move + san = board.san(move) + board.push(move) + moves.append({ + "san": san, + "uci": move.uci(), + "ply": board.ply(), + }) + frames.append({ + "board": board_to_dict(board), + "pressure": compute_pressure(board, weighted=False), + "pressure_weighted": compute_pressure(board, weighted=True), + }) + + return { + "headers": headers, + "moves": moves, + "frames": frames, + "result": headers.get("Result", "*"), + } + + +def position_from_moves(move_list: list[str], start_fen: str | None = None) -> dict: + """Apply a list of UCI moves and return the current frame.""" + board = chess.Board(start_fen) if start_fen else chess.Board() + for uci in move_list: + board.push_uci(uci) + return { + "board": board_to_dict(board), + "pressure": compute_pressure(board, weighted=False), + "pressure_weighted": compute_pressure(board, weighted=True), + "legal_moves": [m.uci() for m in board.legal_moves], + } + + +def make_move(fen: str, uci_move: str) -> dict: + """Make a move from a FEN position, return new frame + legal moves.""" + board = chess.Board(fen) + move = chess.Move.from_uci(uci_move) + if move not in board.legal_moves: + raise ValueError(f"Illegal move: {uci_move}") + san = board.san(move) + board.push(move) + return { + "san": san, + "uci": uci_move, + "frame": { + "board": board_to_dict(board), + "pressure": compute_pressure(board, weighted=False), + "pressure_weighted": compute_pressure(board, weighted=True), + }, + "legal_moves": [m.uci() for m in board.legal_moves], + } diff --git a/games.py b/games.py new file mode 100644 index 0000000..0d93803 --- /dev/null +++ b/games.py @@ -0,0 +1,112 @@ +"""Built-in famous games for the dropdown.""" + +GAMES = [ + { + "id": "immortal", + "name": "The Immortal Game (1851)", + "white": "Anderssen", + "black": "Kieseritzky", + "pgn": """[Event "London"] +[Site "London"] +[Date "1851.06.21"] +[White "Adolf Anderssen"] +[Black "Lionel Kieseritzky"] +[Result "1-0"] + +1. e4 e5 2. f4 exf4 3. Bc4 Qh4+ 4. Kf1 b5 5. Bxb5 Nf6 6. Nf3 Qh6 7. d3 Nh5 +8. Nh4 Qg5 9. Nf5 c6 10. g4 Nf6 11. Rg1 cxb5 12. h4 Qg6 13. h5 Qg5 14. Qf3 +Ng8 15. Bxf4 Qf6 16. Nc3 Bc5 17. Nd5 Qxb2 18. Bd6 Bxg1 19. e5 Qxa1+ 20. Ke2 +Na6 21. Nxg7+ Kd8 22. Qf6+ Nxf6 23. Be7# 1-0""", + }, + { + "id": "opera", + "name": "The Opera Game (1858)", + "white": "Morphy", + "black": "Duke of Brunswick & Count Isouard", + "pgn": """[Event "Opera House"] +[Site "Paris"] +[Date "1858.11.02"] +[White "Paul Morphy"] +[Black "Duke of Brunswick and Count Isouard"] +[Result "1-0"] + +1. e4 e5 2. Nf3 d6 3. d4 Bg4 4. dxe5 Bxf3 5. Qxf3 dxe5 6. Bc4 Nf6 7. Qb3 Qe7 +8. Nc3 c6 9. Bg5 b5 10. Nxb5 cxb5 11. Bxb5+ Nbd7 12. O-O-O Rd8 13. Rxd7 Rxd7 +14. Rd1 Qe6 15. Bxd7+ Nxd7 16. Qb8+ Nxb8 17. Rd8# 1-0""", + }, + { + "id": "kasparov-topalov", + "name": "Kasparov's Immortal (1999)", + "white": "Kasparov", + "black": "Topalov", + "pgn": """[Event "Hoogovens"] +[Site "Wijk aan Zee"] +[Date "1999.01.20"] +[White "Garry Kasparov"] +[Black "Veselin Topalov"] +[Result "1-0"] + +1. e4 d6 2. d4 Nf6 3. Nc3 g6 4. Be3 Bg7 5. Qd2 c6 6. f3 b5 7. Nge2 Nbd7 +8. Bh6 Bxh6 9. Qxh6 Bb7 10. a3 e5 11. O-O-O Qe7 12. Kb1 a6 13. Nc1 O-O-O +14. Nb3 exd4 15. Rxd4 c5 16. Rd1 Nb6 17. g3 Kb8 18. Na5 Ba8 19. Bh3 d5 +20. Qf4+ Ka7 21. Re1 d4 22. Nd5 Nbxd5 23. exd5 Qd6 24. Rxd4 cxd4 25. Re7+ +Kb6 26. Qxd4+ Kxa5 27. b4+ Ka4 28. Qc3 Qxd5 29. Ra7 Bb7 30. Rxb7 Qc4 +31. Qxf6 Kxa3 32. Qxa6+ Kxb4 33. c3+ Kxc3 34. Qa1+ Kd2 35. Qb2+ Kd1 +36. Bf1 Rd2 37. Rd7 Rxd7 38. Bxc4 bxc4 39. Qxh8 Rd3 40. Qa8 c3 41. Qa4+ +Ke1 42. f4 f5 43. Kc1 Rd2 44. Qa7 1-0""", + }, + { + "id": "fischer-spassky-6", + "name": "Fischer vs Spassky, Game 6 (1972)", + "white": "Fischer", + "black": "Spassky", + "pgn": """[Event "World Championship"] +[Site "Reykjavik"] +[Date "1972.07.23"] +[White "Robert James Fischer"] +[Black "Boris Spassky"] +[Result "1-0"] + +1. c4 e6 2. Nf3 d5 3. d4 Nf6 4. Nc3 Be7 5. Bg5 O-O 6. e3 h6 7. Bh4 b6 +8. cxd5 Nxd5 9. Bxe7 Qxe7 10. Nxd5 exd5 11. Rc1 Be6 12. Qa4 c5 13. Qa3 Rc8 +14. Bb5 a6 15. dxc5 bxc5 16. O-O Ra7 17. Be2 Nd7 18. Nd4 Qf8 19. Nxe6 fxe6 +20. e4 d4 21. f4 Qe7 22. e5 Rb8 23. Bc4 Kh8 24. Qh3 Nf8 25. b3 a5 26. f5 +exf5 27. Rxf5 Nh7 28. Rcf1 Qd8 29. Qg3 Re7 30. h4 Rbb7 31. e6 Rbc7 32. Qe5 +Qe8 33. a4 Qd8 34. R1f2 Qe8 35. R2f3 Qd8 36. Bd3 Qe8 37. Qe4 Nf6 38. Rxf6 +gxf6 39. Rxf6 Kg8 40. Bc4 Kh8 41. Qf4 1-0""", + }, + { + "id": "deep-blue", + "name": "Kasparov vs Deep Blue, Game 2 (1997)", + "white": "Deep Blue", + "black": "Kasparov", + "pgn": """[Event "IBM Man-Machine"] +[Site "New York"] +[Date "1997.05.04"] +[White "Deep Blue"] +[Black "Garry Kasparov"] +[Result "1-0"] + +1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 +8. c3 O-O 9. h3 h6 10. d4 Re8 11. Nbd2 Bf8 12. Nf1 Bd7 13. Ng3 Na5 14. Bc2 +c5 15. b3 Nc6 16. d5 Ne7 17. Be3 Ng6 18. Qd2 Nh7 19. a4 Nh4 20. Nxh4 Qxh4 +21. Qe2 Qd8 22. b4 Qc7 23. Rec1 c4 24. Ra3 Rec8 25. Rca1 Qd8 26. f4 Nf6 +27. fxe5 dxe5 28. Qf1 Ne8 29. Qf2 Nd6 30. Bb6 Qe8 31. R3a2 Be7 32. Bc5 Bf8 +33. Nf5 Bxf5 34. exf5 f6 35. Bxd6 Bxd6 36. axb5 axb5 37. Be4 Rxa2 38. Qxa2 +Qd7 39. Qa7 Rc7 40. Qb6 Rb7 41. Ra8+ Kf7 42. Qa6 Qc7 43. Qc6 Qb6+ 44. Kf1 +Rb8 45. Ra6 1-0""", + }, +] + + +def get_game_list() -> list[dict]: + """Return list of available games (id, name, white, black).""" + return [{"id": g["id"], "name": g["name"], "white": g["white"], "black": g["black"]} for g in GAMES] + + +def get_game_pgn(game_id: str) -> str | None: + """Return PGN for a built-in game.""" + for g in GAMES: + if g["id"] == game_id: + return g["pgn"] + return None