Initial commit: chess pressure visualization app
This commit is contained in:
commit
138122a35b
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@ -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/
|
||||
63
README.md
Normal file
63
README.md
Normal file
@ -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
|
||||
0
__init__.py
Normal file
0
__init__.py
Normal file
88
app.py
Normal file
88
app.py
Normal file
@ -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()
|
||||
126
engine.py
Normal file
126
engine.py
Normal file
@ -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],
|
||||
}
|
||||
112
games.py
Normal file
112
games.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user