Add frontend, Makefile, and additional built-in games
26
Makefile
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
.PHONY: dev serve lint fmt help
|
||||||
|
|
||||||
|
# Development server with auto-reload
|
||||||
|
dev:
|
||||||
|
uv run uvicorn chess_pressure.app:app --host 0.0.0.0 --port 8888 --reload
|
||||||
|
|
||||||
|
# Production server
|
||||||
|
serve:
|
||||||
|
uv run chess-pressure
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
lint: fmt
|
||||||
|
uvx ruff check chess_pressure/
|
||||||
|
|
||||||
|
# Format
|
||||||
|
fmt:
|
||||||
|
uvx ruff format chess_pressure/
|
||||||
|
uvx ruff check --fix chess_pressure/
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "chess-pressure"
|
||||||
|
@echo ""
|
||||||
|
@echo " make dev dev server with reload (:8888)"
|
||||||
|
@echo " make serve production server (:8888)"
|
||||||
|
@echo " make lint ruff format + check"
|
||||||
|
@echo " make fmt ruff format + auto-fix"
|
||||||
97
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
|
Qd7 39. Qa7 Rc7 40. Qb6 Rb7 41. Ra8+ Kf7 42. Qa6 Qc7 43. Qc6 Qb6+ 44. Kf1
|
||||||
Rb8 45. Ra6 1-0""",
|
Rb8 45. Ra6 1-0""",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "polgar-anand",
|
||||||
|
"name": "Polgar vs Anand (1999)",
|
||||||
|
"white": "Polgar",
|
||||||
|
"black": "Anand",
|
||||||
|
"pgn": """[Event "Dos Hermanas"]
|
||||||
|
[Site "Dos Hermanas"]
|
||||||
|
[Date "1999.04.??"]
|
||||||
|
[White "Judit Polgar"]
|
||||||
|
[Black "Viswanathan Anand"]
|
||||||
|
[Result "1-0"]
|
||||||
|
|
||||||
|
1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6 6.Be3 e6 7.g4 e5 8.Nf5 g6
|
||||||
|
9.g5 gxf5 10.exf5 d5 11.Qf3 d4 12.O-O-O Nbd7 13.Bd2 dxc3 14.Bxc3 Bg7 15.Rg1
|
||||||
|
O-O 16.gxf6 Qxf6 17.Qe3 Kh8 18.f4 Qb6 19.Qg3 Qh6 20.Rd6 f6 21.Bd2 e4 22.Bc4
|
||||||
|
b5 23.Be6 Ra7 24.Rc6 a5 25.Be3 Rb7 26.Bd5 Rb8 27.Rc7 b4 28.b3 Rb5 29.Bc6 Rxf5
|
||||||
|
30.Rxc8 Rxc8 31.Bxd7 Rcc5 32.Bxf5 Rxf5 33.Rd1 Kg8 34.Qg2 1-0""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "polgar-kasparov",
|
||||||
|
"name": "Polgar vs Kasparov (2002)",
|
||||||
|
"white": "Polgar",
|
||||||
|
"black": "Kasparov",
|
||||||
|
"pgn": """[Event "Russia vs Rest of the World"]
|
||||||
|
[Site "Moscow"]
|
||||||
|
[Date "2002.09.09"]
|
||||||
|
[White "Judit Polgar"]
|
||||||
|
[Black "Garry Kasparov"]
|
||||||
|
[Result "1-0"]
|
||||||
|
|
||||||
|
1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O Nxe4 5.d4 Nd6 6.Bxc6 dxc6 7.dxe5 Nf5
|
||||||
|
8.Qxd8+ Kxd8 9.Nc3 h6 10.Rd1+ Ke8 11.h3 Be7 12.Ne2 Nh4 13.Nxh4 Bxh4 14.Be3
|
||||||
|
Bf5 15.Nd4 Bh7 16.g4 Be7 17.Kg2 h5 18.Nf5 Bf8 19.Kf3 Bg6 20.Rd2 hxg4+ 21.hxg4
|
||||||
|
Rh3+ 22.Kg2 Rh7 23.Kg3 f6 24.Bf4 Bxf5 25.gxf5 fxe5 26.Re1 Bd6 27.Bxe5 Kd7
|
||||||
|
28.c4 c5 29.Bxd6 cxd6 30.Re6 Rah8 31.Rexd6+ Kc8 32.R2d5 Rh3+ 33.Kg2 Rh2+
|
||||||
|
34.Kf3 R2h3+ 35.Ke4 b6 36.Rc6+ Kb8 37.Rd7 Rh2 38.Ke3 Rf8 39.Rcc7 Rxf5
|
||||||
|
40.Rb7+ Kc8 41.Rdc7+ Kd8 42.Rxg7 Kc8 1-0""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hou-caruana",
|
||||||
|
"name": "Hou Yifan vs Caruana (2017)",
|
||||||
|
"white": "Hou Yifan",
|
||||||
|
"black": "Caruana",
|
||||||
|
"pgn": """[Event "GRENKE Chess Classic"]
|
||||||
|
[Site "Karlsruhe"]
|
||||||
|
[Date "2017.04.15"]
|
||||||
|
[White "Hou Yifan"]
|
||||||
|
[Black "Fabiano Caruana"]
|
||||||
|
[Result "1-0"]
|
||||||
|
|
||||||
|
1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O Nxe4 5.Re1 Nd6 6.Nxe5 Be7 7.Bf1 O-O 8.d4
|
||||||
|
Nf5 9.Nf3 d5 10.c3 Bd6 11.Nbd2 Nce7 12.Qc2 c6 13.Bd3 g6 14.Nf1 f6 15.h3 Rf7
|
||||||
|
16.Bd2 Bd7 17.Re2 c5 18.dxc5 Bxc5 19.Bf4 Rc8 20.Rae1 g5 21.Ng3 Nxg3 22.Bxg3
|
||||||
|
a5 23.Qd2 a4 24.b4 axb3 25.axb3 Ng6 26.h4 gxh4 27.Nxh4 Nxh4 28.Bxh4 Qf8
|
||||||
|
29.Qf4 Bd6 30.Qd4 Rd8 31.Re3 Bc8 32.b4 Kg7 33.Bb5 Bc7 34.Re8 Qd6 35.Bg3 Qb6
|
||||||
|
36.Qd3 Bd7 37.Bxd7 Rdxd7 38.Qf5 Bxg3 39.Qg4+ Kh6 40.Qh3+ 1-0""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ju-lei",
|
||||||
|
"name": "Ju Wenjun vs Lei Tingjie, WCC G12 (2023)",
|
||||||
|
"white": "Ju Wenjun",
|
||||||
|
"black": "Lei Tingjie",
|
||||||
|
"pgn": """[Event "Women's World Championship"]
|
||||||
|
[Site "Shanghai/Chongqing"]
|
||||||
|
[Date "2023.07.22"]
|
||||||
|
[White "Ju Wenjun"]
|
||||||
|
[Black "Lei Tingjie"]
|
||||||
|
[Result "1-0"]
|
||||||
|
|
||||||
|
1.d4 d5 2.Nf3 Nf6 3.e3 c5 4.dxc5 e6 5.b4 a5 6.c3 axb4 7.cxb4 b6 8.Bb5+ Bd7
|
||||||
|
9.Bxd7+ Nbxd7 10.a4 bxc5 11.b5 Qc7 12.Bb2 Bd6 13.O-O O-O 14.Nbd2 Rfc8 15.Qc2
|
||||||
|
c4 16.Bc3 Nc5 17.a5 Nb3 18.Bxf6 Nxa1 19.Bxa1 Qxa5 20.Qc3 Qxc3 21.Bxc3 Rcb8
|
||||||
|
22.Nd4 e5 23.Nf5 Bf8 24.Bxe5 Rxb5 25.g4 g6 26.Nd4 Rb2 27.Nb1 Bg7 28.Bxg7 Kxg7
|
||||||
|
29.Nc3 Ra5 30.Rd1 Rb6 31.Nde2 Rb3 32.Kg2 h6 33.Kf3 f6 34.Rc1 Kf7 35.Nf4 d4
|
||||||
|
36.exd4 g5 37.Ne2 f5 38.gxf5 Rxf5+ 39.Ke3 g4 40.Nf4 Rb8 41.d5 Rf6 42.Rc2 Ra8
|
||||||
|
43.Nb5 Rb6 44.Nd4 Ra3+ 45.Ke4 c3 46.Nfe2 Rb2 47.Kd3 Rb1 48.Nxc3 Rh1 49.f3 gxf3
|
||||||
|
50.Nxf3 Rf1 51.Nd4 Ke7 52.Kc4 Rf4 53.Rb2 Rh4 54.Rb7+ Kf6 55.Rb2 Ra8 56.Kc5 Rh3
|
||||||
|
57.Ncb5 Re3 58.d6 Ke5 59.Nc6+ Ke4 60.d7 Rd3 61.Nd6+ Kf4 62.Rb8 1-0""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shirov-polgar",
|
||||||
|
"name": "Shirov vs Polgar (1994)",
|
||||||
|
"white": "Shirov",
|
||||||
|
"black": "Polgar",
|
||||||
|
"pgn": """[Event "Buenos Aires Sicilian"]
|
||||||
|
[Site "Buenos Aires"]
|
||||||
|
[Date "1994.10.22"]
|
||||||
|
[White "Alexei Shirov"]
|
||||||
|
[Black "Judit Polgar"]
|
||||||
|
[Result "0-1"]
|
||||||
|
|
||||||
|
1.e4 c5 2.Nf3 e6 3.d4 cxd4 4.Nxd4 Nc6 5.Nc3 d6 6.g4 a6 7.Be3 Nge7 8.Nb3 b5
|
||||||
|
9.f4 Bb7 10.Qf3 g5 11.fxg5 Ne5 12.Qg2 b4 13.Ne2 h5 14.gxh5 Nf5 15.Bf2 Qxg5
|
||||||
|
16.Na5 Ne3 17.Qg3 Qxg3 18.Nxg3 Nxc2+ 19.Kd1 Nxa1 20.Nxb7 b3 21.axb3 Nxb3
|
||||||
|
22.Kc2 Nc5 23.Nxc5 dxc5 24.Be1 Nf3 25.Bc3 Nd4+ 26.Kd3 Bd6 27.Bg2 Be5 28.Kc4
|
||||||
|
Ke7 29.Ra1 Nc6 0-1""",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
658
static/app.js
Normal file
@ -0,0 +1,658 @@
|
|||||||
|
/* Chess Pressure — main application */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let gameData = null; // {headers, moves, frames, result}
|
||||||
|
let currentIndex = 0; // which frame we're viewing
|
||||||
|
let playing = false;
|
||||||
|
let playTimer = null;
|
||||||
|
let pressureMode = "equal"; // "equal" or "weighted"
|
||||||
|
let board = null;
|
||||||
|
let forkPoint = null; // index where fork happened, null if on original line
|
||||||
|
let originalData = null; // saved original game before fork
|
||||||
|
let forkedMoves = []; // UCI moves made after fork point
|
||||||
|
let showPieces = true;
|
||||||
|
let showBoard = false;
|
||||||
|
let selectedSquare = null; // tap-to-move: currently selected source square
|
||||||
|
let legalMoves = []; // legal moves from selected square
|
||||||
|
|
||||||
|
const PLAY_SPEED = 800; // ms between auto-advance
|
||||||
|
|
||||||
|
// --- Pressure colors ---
|
||||||
|
function pressureColor(value, maxAbs) {
|
||||||
|
if (value === 0 || maxAbs === 0) return "transparent";
|
||||||
|
const intensity = Math.min(Math.abs(value) / maxAbs, 1);
|
||||||
|
const alpha = 0.15 + intensity * 0.55;
|
||||||
|
if (value > 0) return `rgba(50, 130, 220, ${alpha})`; // blue = white
|
||||||
|
return `rgba(220, 50, 50, ${alpha})`; // red = black
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pressure rendering (applied directly to board squares) ---
|
||||||
|
const FILES = "abcdefgh";
|
||||||
|
|
||||||
|
const PRESSURE_SCALE = { equal: 5, weighted: 15 };
|
||||||
|
|
||||||
|
function renderPressure(frame) {
|
||||||
|
const key = pressureMode === "weighted" ? "pressure_weighted" : "pressure";
|
||||||
|
const pressure = frame[key];
|
||||||
|
const maxAbs = PRESSURE_SCALE[pressureMode];
|
||||||
|
|
||||||
|
for (let rank = 1; rank <= 8; rank++) {
|
||||||
|
for (let fileIdx = 0; fileIdx < 8; fileIdx++) {
|
||||||
|
const sq = FILES[fileIdx] + rank;
|
||||||
|
const pyIdx = (rank - 1) * 8 + fileIdx; // python-chess square index
|
||||||
|
const el = document.querySelector(`[data-square="${sq}"]`);
|
||||||
|
if (!el) continue;
|
||||||
|
|
||||||
|
const val = pressure[pyIdx];
|
||||||
|
const isLight = (rank + fileIdx) % 2 === 1;
|
||||||
|
|
||||||
|
if (!showBoard) {
|
||||||
|
// Pure pressure view — theme-aware neutral background
|
||||||
|
const isDark = document.documentElement.dataset.theme !== "light";
|
||||||
|
const neutral = isDark ? [26, 26, 26] : [245, 245, 245];
|
||||||
|
if (val === 0) {
|
||||||
|
el.style.backgroundColor = `rgb(${neutral[0]}, ${neutral[1]}, ${neutral[2]})`;
|
||||||
|
} else {
|
||||||
|
const intensity = Math.min(Math.abs(val) / maxAbs, 1);
|
||||||
|
const alpha = 0.3 + intensity * 0.7;
|
||||||
|
const tint = val > 0 ? [50, 130, 220] : [220, 50, 50];
|
||||||
|
const base = neutral;
|
||||||
|
const r = Math.round(base[0] * (1 - alpha) + tint[0] * alpha);
|
||||||
|
const g = Math.round(base[1] * (1 - alpha) + tint[1] * alpha);
|
||||||
|
const b = Math.round(base[2] * (1 - alpha) + tint[2] * alpha);
|
||||||
|
el.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const base = isLight ? [192, 192, 192] : [120, 120, 120]; // grayscale
|
||||||
|
if (val === 0) {
|
||||||
|
el.style.backgroundColor = `rgb(${base[0]}, ${base[1]}, ${base[2]})`;
|
||||||
|
} else {
|
||||||
|
const intensity = Math.min(Math.abs(val) / maxAbs, 1);
|
||||||
|
const alpha = 0.2 + intensity * 0.6;
|
||||||
|
const tint = val > 0 ? [50, 130, 220] : [220, 50, 50];
|
||||||
|
const r = Math.round(base[0] * (1 - alpha) + tint[0] * alpha);
|
||||||
|
const g = Math.round(base[1] * (1 - alpha) + tint[1] * alpha);
|
||||||
|
const b = Math.round(base[2] * (1 - alpha) + tint[2] * alpha);
|
||||||
|
el.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Move list ---
|
||||||
|
// --- Tap-to-move ---
|
||||||
|
function clearSelection() {
|
||||||
|
selectedSquare = null;
|
||||||
|
legalMoves = [];
|
||||||
|
document.querySelectorAll(".square-selected, .square-target").forEach((el) => {
|
||||||
|
el.classList.remove("square-selected", "square-target");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSquareClick(square) {
|
||||||
|
if (!gameData) return;
|
||||||
|
const frame = gameData.frames[currentIndex];
|
||||||
|
const fen = frame.board.fen;
|
||||||
|
|
||||||
|
if (selectedSquare) {
|
||||||
|
// Check if this square is a legal target
|
||||||
|
const uci = selectedSquare + square;
|
||||||
|
const isTarget = legalMoves.some((m) => m === uci || m === uci + "q");
|
||||||
|
if (isTarget) {
|
||||||
|
// Make the move
|
||||||
|
const moveUci = legalMoves.find((m) => m.startsWith(uci)) || uci;
|
||||||
|
clearSelection();
|
||||||
|
doMove(fen, moveUci);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select a new piece (or deselect)
|
||||||
|
if (square === selectedSquare) {
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch legal moves for this square
|
||||||
|
fetch("/api/move", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fen, uci: square + square }), // dummy — we just need to check if piece exists
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Check if there's a piece here by seeing if any legal move starts from this square
|
||||||
|
// We need to ask the server for legal moves from this position
|
||||||
|
fetch(`/api/legal?fen=${encodeURIComponent(fen)}`).then(r => r.ok ? r.json() : null).then(data => {
|
||||||
|
if (!data) return;
|
||||||
|
const fromMoves = data.filter((m) => m.startsWith(square));
|
||||||
|
if (fromMoves.length === 0) {
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearSelection();
|
||||||
|
selectedSquare = square;
|
||||||
|
legalMoves = fromMoves;
|
||||||
|
|
||||||
|
// Highlight
|
||||||
|
const srcEl = document.querySelector(`[data-square="${square}"]`);
|
||||||
|
if (srcEl) srcEl.classList.add("square-selected");
|
||||||
|
fromMoves.forEach((m) => {
|
||||||
|
const target = m.substring(2, 4);
|
||||||
|
const el = document.querySelector(`[data-square="${target}"]`);
|
||||||
|
if (el) el.classList.add("square-target");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function doMove(fen, uci) {
|
||||||
|
fetch("/api/move", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fen, uci }),
|
||||||
|
})
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((data) => {
|
||||||
|
if (!data) return;
|
||||||
|
if (forkPoint === null && currentIndex < gameData.frames.length - 1) {
|
||||||
|
originalData = JSON.parse(JSON.stringify(gameData));
|
||||||
|
forkPoint = currentIndex;
|
||||||
|
gameData.moves = gameData.moves.slice(0, currentIndex);
|
||||||
|
gameData.frames = gameData.frames.slice(0, currentIndex + 1);
|
||||||
|
forkedMoves = [];
|
||||||
|
}
|
||||||
|
gameData.moves.push({ san: data.san, uci: data.uci, ply: currentIndex + 1 });
|
||||||
|
gameData.frames.push(data.frame);
|
||||||
|
if (forkPoint !== null) forkedMoves.push(data.uci);
|
||||||
|
goTo(gameData.frames.length - 1);
|
||||||
|
updateForkUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Move list ---
|
||||||
|
function renderMoveList() {
|
||||||
|
const el = document.getElementById("move-list");
|
||||||
|
if (!gameData || !gameData.moves.length) {
|
||||||
|
el.innerHTML = '<span style="color:var(--fg2)">No moves</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = "";
|
||||||
|
for (let i = 0; i < gameData.moves.length; i += 2) {
|
||||||
|
const num = Math.floor(i / 2) + 1;
|
||||||
|
const w = gameData.moves[i];
|
||||||
|
const b = gameData.moves[i + 1];
|
||||||
|
const wClass = currentIndex === i + 1 ? "active" : "";
|
||||||
|
const bClass = b && currentIndex === i + 2 ? "active" : "";
|
||||||
|
const wForked = forkPoint !== null && i >= forkPoint ? " forked" : "";
|
||||||
|
const bForked = forkPoint !== null && i + 1 >= forkPoint ? " forked" : "";
|
||||||
|
html += `<div class="move-row">`;
|
||||||
|
html += `<span class="move-num">${num}.</span>`;
|
||||||
|
html += `<span class="move${wClass ? " " + wClass : ""}${wForked}" data-idx="${i + 1}">${w.san}</span>`;
|
||||||
|
if (b) {
|
||||||
|
html += `<span class="move${bClass ? " " + bClass : ""}${bForked}" data-idx="${i + 2}">${b.san}</span>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
// Scroll active into view within the move list only (not the page)
|
||||||
|
const active = el.querySelector(".move.active");
|
||||||
|
if (active) {
|
||||||
|
const container = el;
|
||||||
|
const top = active.offsetTop - container.offsetTop;
|
||||||
|
const bottom = top + active.offsetHeight;
|
||||||
|
if (top < container.scrollTop) {
|
||||||
|
container.scrollTop = top;
|
||||||
|
} else if (bottom > container.scrollTop + container.clientHeight) {
|
||||||
|
container.scrollTop = bottom - container.clientHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click handlers
|
||||||
|
el.querySelectorAll(".move[data-idx]").forEach((m) => {
|
||||||
|
m.addEventListener("click", () => goTo(parseInt(m.dataset.idx)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigation ---
|
||||||
|
function goTo(index) {
|
||||||
|
if (!gameData) return;
|
||||||
|
index = Math.max(0, Math.min(index, gameData.frames.length - 1));
|
||||||
|
currentIndex = index;
|
||||||
|
const frame = gameData.frames[index];
|
||||||
|
board.position(frame.board.fen, false);
|
||||||
|
renderPressure(frame);
|
||||||
|
renderMoveList();
|
||||||
|
updateSlider();
|
||||||
|
updateStatus(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlider() {
|
||||||
|
const slider = document.getElementById("slider");
|
||||||
|
slider.max = gameData ? gameData.frames.length - 1 : 0;
|
||||||
|
slider.value = currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(frame) {
|
||||||
|
const el = document.getElementById("status-bar");
|
||||||
|
const b = frame.board;
|
||||||
|
let text = b.turn === "w" ? "White to move" : "Black to move";
|
||||||
|
if (b.is_checkmate) text = (b.turn === "w" ? "Black" : "White") + " wins by checkmate";
|
||||||
|
else if (b.is_stalemate) text = "Stalemate";
|
||||||
|
else if (b.is_check) text += " (check)";
|
||||||
|
if (gameData.result && gameData.result !== "*") text += ` — ${gameData.result}`;
|
||||||
|
text += ` | Move ${b.fullmove}`;
|
||||||
|
el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGameInfo() {
|
||||||
|
const el = document.getElementById("game-info");
|
||||||
|
if (!gameData || !gameData.headers) {
|
||||||
|
el.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const h = gameData.headers;
|
||||||
|
const parts = [];
|
||||||
|
if (h.White) parts.push(`${h.White} vs ${h.Black || "?"}`);
|
||||||
|
if (h.Event) parts.push(h.Event);
|
||||||
|
if (h.Date) parts.push(h.Date);
|
||||||
|
el.textContent = parts.join(" — ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Playback ---
|
||||||
|
function togglePlay() {
|
||||||
|
playing = !playing;
|
||||||
|
document.getElementById("btn-play").innerHTML = playing ? "▮▮" : "▶";
|
||||||
|
if (playing) {
|
||||||
|
playTimer = setInterval(() => {
|
||||||
|
if (currentIndex >= gameData.frames.length - 1) {
|
||||||
|
togglePlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goTo(currentIndex + 1);
|
||||||
|
}, PLAY_SPEED);
|
||||||
|
} else {
|
||||||
|
clearInterval(playTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interactive moves ---
|
||||||
|
function onDrop(source, target) {
|
||||||
|
if (source === target) {
|
||||||
|
// Tap — treat as click-to-select/move
|
||||||
|
handleSquareClick(source);
|
||||||
|
return "snapback";
|
||||||
|
}
|
||||||
|
clearSelection();
|
||||||
|
const frame = gameData.frames[currentIndex];
|
||||||
|
const fen = frame.board.fen;
|
||||||
|
let uci = source + target;
|
||||||
|
// Auto-promote to queen for pawn reaching last rank
|
||||||
|
const rank = target.charAt(1);
|
||||||
|
const piece = frame.board.fen.split(" ")[0]; // just for checking
|
||||||
|
if ((rank === "8" || rank === "1") && uci.length === 4) {
|
||||||
|
uci += "q";
|
||||||
|
}
|
||||||
|
doMove(fen, uci);
|
||||||
|
return "snapback";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateForkUI() {
|
||||||
|
const el = document.getElementById("fork-controls");
|
||||||
|
if (forkPoint !== null) {
|
||||||
|
el.style.display = "flex";
|
||||||
|
document.getElementById("fork-point").textContent = Math.floor(forkPoint / 2) + 1;
|
||||||
|
} else {
|
||||||
|
el.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFork() {
|
||||||
|
if (!originalData) return;
|
||||||
|
gameData = originalData;
|
||||||
|
originalData = null;
|
||||||
|
forkPoint = null;
|
||||||
|
forkedMoves = [];
|
||||||
|
goTo(0);
|
||||||
|
updateForkUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Load game ---
|
||||||
|
async function loadGame(gameId) {
|
||||||
|
const r = await fetch(`/api/games/${gameId}`);
|
||||||
|
if (!r.ok) return;
|
||||||
|
gameData = await r.json();
|
||||||
|
forkPoint = null;
|
||||||
|
originalData = null;
|
||||||
|
forkedMoves = [];
|
||||||
|
currentIndex = 0;
|
||||||
|
goTo(0);
|
||||||
|
updateGameInfo();
|
||||||
|
updateForkUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPGN(pgn) {
|
||||||
|
const r = await fetch("/api/parse", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ pgn }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
alert("Failed to parse PGN");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gameData = await r.json();
|
||||||
|
forkPoint = null;
|
||||||
|
originalData = null;
|
||||||
|
forkedMoves = [];
|
||||||
|
currentIndex = 0;
|
||||||
|
goTo(0);
|
||||||
|
updateGameInfo();
|
||||||
|
updateForkUI();
|
||||||
|
document.getElementById("game-select").value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
async function init() {
|
||||||
|
// Build board
|
||||||
|
board = Chessboard("board", {
|
||||||
|
draggable: true,
|
||||||
|
position: "start",
|
||||||
|
pieceTheme: "/static/img/chesspieces/wikipedia/{piece}.png",
|
||||||
|
onDrop: onDrop,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tap destination squares (onDrop only fires on pieces, not empty squares)
|
||||||
|
document.getElementById("board").addEventListener("click", (e) => {
|
||||||
|
if (!selectedSquare) return;
|
||||||
|
const sqEl = e.target.closest("[data-square]");
|
||||||
|
if (sqEl) handleSquareClick(sqEl.dataset.square);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore theme before first render
|
||||||
|
const saved = localStorage.getItem("theme");
|
||||||
|
if (saved) document.documentElement.dataset.theme = saved;
|
||||||
|
|
||||||
|
// Load game list
|
||||||
|
const r = await fetch("/api/games");
|
||||||
|
const games = await r.json();
|
||||||
|
const select = document.getElementById("game-select");
|
||||||
|
games.forEach((g) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = g.id;
|
||||||
|
opt.textContent = g.name;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load first game by default
|
||||||
|
if (games.length) {
|
||||||
|
await loadGame(games[0].id);
|
||||||
|
select.value = games[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show board after first render (prevents tan flash)
|
||||||
|
document.getElementById("board").classList.add("ready");
|
||||||
|
|
||||||
|
// --- Event listeners ---
|
||||||
|
select.addEventListener("change", (e) => {
|
||||||
|
if (e.target.value === "__new") {
|
||||||
|
loadPGN('[White "You"]\n[Black "Opponent"]\n[Result "*"]\n\n*');
|
||||||
|
} else if (e.target.value) {
|
||||||
|
loadGame(e.target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("slider").addEventListener("input", (e) => {
|
||||||
|
goTo(parseInt(e.target.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-start").addEventListener("click", () => goTo(0));
|
||||||
|
document.getElementById("btn-prev").addEventListener("click", () => goTo(currentIndex - 1));
|
||||||
|
document.getElementById("btn-play").addEventListener("click", togglePlay);
|
||||||
|
document.getElementById("btn-next").addEventListener("click", () => goTo(currentIndex + 1));
|
||||||
|
document.getElementById("btn-end").addEventListener("click", () =>
|
||||||
|
goTo(gameData ? gameData.frames.length - 1 : 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="pmode"]').forEach((r) => {
|
||||||
|
r.addEventListener("change", (e) => {
|
||||||
|
pressureMode = e.target.value;
|
||||||
|
if (gameData) renderPressure(gameData.frames[currentIndex]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("toggle-pieces").addEventListener("change", (e) => {
|
||||||
|
showPieces = e.target.checked;
|
||||||
|
document.getElementById("board").classList.toggle("hide-pieces", !showPieces);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("toggle-board").addEventListener("change", (e) => {
|
||||||
|
showBoard = e.target.checked;
|
||||||
|
if (gameData) renderPressure(gameData.frames[currentIndex]);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("theme-toggle").addEventListener("click", () => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
html.dataset.theme = html.dataset.theme === "dark" ? "light" : "dark";
|
||||||
|
localStorage.setItem("theme", html.dataset.theme);
|
||||||
|
if (gameData) renderPressure(gameData.frames[currentIndex]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PGN upload dialog
|
||||||
|
const dialog = document.getElementById("pgn-dialog");
|
||||||
|
document.getElementById("btn-upload").addEventListener("click", () => dialog.showModal());
|
||||||
|
document.getElementById("pgn-cancel").addEventListener("click", () => dialog.close());
|
||||||
|
document.getElementById("pgn-file-btn").addEventListener("click", () =>
|
||||||
|
document.getElementById("pgn-file").click()
|
||||||
|
);
|
||||||
|
document.getElementById("pgn-file").addEventListener("change", (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
document.getElementById("pgn-input").value = ev.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("pgn-submit").addEventListener("click", () => {
|
||||||
|
const pgn = document.getElementById("pgn-input").value.trim();
|
||||||
|
if (pgn) {
|
||||||
|
loadPGN(pgn);
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fork reset
|
||||||
|
document.getElementById("btn-reset-fork").addEventListener("click", resetFork);
|
||||||
|
|
||||||
|
// GIF export
|
||||||
|
const exportDialog = document.getElementById("export-dialog");
|
||||||
|
document.getElementById("btn-export").addEventListener("click", () => exportDialog.showModal());
|
||||||
|
document.getElementById("export-cancel").addEventListener("click", () => exportDialog.close());
|
||||||
|
document.getElementById("export-go").addEventListener("click", exportGif);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.target.tagName === "TEXTAREA" || e.target.tagName === "INPUT") return;
|
||||||
|
if (e.key === "ArrowLeft") goTo(currentIndex - 1);
|
||||||
|
else if (e.key === "ArrowRight") goTo(currentIndex + 1);
|
||||||
|
else if (e.key === "Home") goTo(0);
|
||||||
|
else if (e.key === "End") goTo(gameData ? gameData.frames.length - 1 : 0);
|
||||||
|
else if (e.key === " ") { e.preventDefault(); togglePlay(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Responsive board resize
|
||||||
|
// Re-apply pressure after any board redraw (resize, scroll zoom, etc.)
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
board.resize();
|
||||||
|
if (gameData) renderPressure(gameData.frames[currentIndex]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch chessboard.js redraws that reset square colors (e.g. scroll-triggered resize)
|
||||||
|
const boardEl = document.getElementById("board");
|
||||||
|
let repaintTimer = null;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (repaintTimer) return;
|
||||||
|
repaintTimer = setTimeout(() => {
|
||||||
|
repaintTimer = null;
|
||||||
|
if (gameData) renderPressure(gameData.frames[currentIndex]);
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
observer.observe(boardEl, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// Prevent board touch events from scrolling the page
|
||||||
|
boardEl.addEventListener("touchstart", (e) => { e.stopPropagation(); }, { passive: false });
|
||||||
|
boardEl.addEventListener("touchmove", (e) => { e.preventDefault(); e.stopPropagation(); }, { passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GIF Export ---
|
||||||
|
const EXPORT_SIZE = 480;
|
||||||
|
const SQ = EXPORT_SIZE / 8;
|
||||||
|
let pieceImages = {};
|
||||||
|
|
||||||
|
function loadPieceImages() {
|
||||||
|
const pieces = ["wK","wQ","wR","wB","wN","wP","bK","bQ","bR","bB","bN","bP"];
|
||||||
|
const promises = pieces.map((p) => new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { pieceImages[p] = img; resolve(); };
|
||||||
|
img.onerror = () => resolve();
|
||||||
|
img.src = `/static/img/chesspieces/wikipedia/${p}.png`;
|
||||||
|
}));
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSquareColor(val, maxAbs, rank, fileIdx) {
|
||||||
|
const isDark = document.documentElement.dataset.theme !== "light";
|
||||||
|
const isLight = (rank + fileIdx) % 2 === 1;
|
||||||
|
|
||||||
|
if (!showBoard) {
|
||||||
|
const neutral = isDark ? [26, 26, 26] : [245, 245, 245];
|
||||||
|
if (val === 0) return `rgb(${neutral[0]},${neutral[1]},${neutral[2]})`;
|
||||||
|
const intensity = Math.min(Math.abs(val) / maxAbs, 1);
|
||||||
|
const alpha = 0.3 + intensity * 0.7;
|
||||||
|
const tint = val > 0 ? [50, 130, 220] : [220, 50, 50];
|
||||||
|
const r = Math.round(neutral[0] * (1 - alpha) + tint[0] * alpha);
|
||||||
|
const g = Math.round(neutral[1] * (1 - alpha) + tint[1] * alpha);
|
||||||
|
const b = Math.round(neutral[2] * (1 - alpha) + tint[2] * alpha);
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
} else {
|
||||||
|
const base = isLight ? [192, 192, 192] : [120, 120, 120];
|
||||||
|
if (val === 0) return `rgb(${base[0]},${base[1]},${base[2]})`;
|
||||||
|
const intensity = Math.min(Math.abs(val) / maxAbs, 1);
|
||||||
|
const alpha = 0.2 + intensity * 0.6;
|
||||||
|
const tint = val > 0 ? [50, 130, 220] : [220, 50, 50];
|
||||||
|
const r = Math.round(base[0] * (1 - alpha) + tint[0] * alpha);
|
||||||
|
const g = Math.round(base[1] * (1 - alpha) + tint[1] * alpha);
|
||||||
|
const b = Math.round(base[2] * (1 - alpha) + tint[2] * alpha);
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFrame(ctx, frame) {
|
||||||
|
const key = pressureMode === "weighted" ? "pressure_weighted" : "pressure";
|
||||||
|
const pressure = frame[key];
|
||||||
|
const maxAbs = PRESSURE_SCALE[pressureMode];
|
||||||
|
const fen = frame.board.fen.split(" ")[0];
|
||||||
|
|
||||||
|
// Draw squares
|
||||||
|
for (let row = 0; row < 8; row++) {
|
||||||
|
for (let col = 0; col < 8; col++) {
|
||||||
|
const rank = 8 - row;
|
||||||
|
const pyIdx = (rank - 1) * 8 + col;
|
||||||
|
const color = computeSquareColor(pressure[pyIdx], maxAbs, rank, col);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(col * SQ, row * SQ, SQ, SQ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw pieces if enabled
|
||||||
|
if (showPieces) {
|
||||||
|
const rows = fen.split("/");
|
||||||
|
for (let row = 0; row < 8; row++) {
|
||||||
|
let col = 0;
|
||||||
|
for (const ch of rows[row]) {
|
||||||
|
if (ch >= "1" && ch <= "8") {
|
||||||
|
col += parseInt(ch);
|
||||||
|
} else {
|
||||||
|
const color = ch === ch.toUpperCase() ? "w" : "b";
|
||||||
|
const pieceMap = { k:"K", q:"Q", r:"R", b:"B", n:"N", p:"P" };
|
||||||
|
const key = color + pieceMap[ch.toLowerCase()];
|
||||||
|
const img = pieceImages[key];
|
||||||
|
if (img) {
|
||||||
|
ctx.drawImage(img, col * SQ, row * SQ, SQ, SQ);
|
||||||
|
}
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportGif() {
|
||||||
|
if (!gameData || gameData.frames.length === 0) return;
|
||||||
|
|
||||||
|
await loadPieceImages();
|
||||||
|
|
||||||
|
const speed = parseInt(document.getElementById("export-speed").value);
|
||||||
|
const progressEl = document.getElementById("export-progress");
|
||||||
|
const barEl = document.getElementById("export-bar");
|
||||||
|
const pctEl = document.getElementById("export-pct");
|
||||||
|
const goBtn = document.getElementById("export-go");
|
||||||
|
|
||||||
|
progressEl.style.display = "flex";
|
||||||
|
goBtn.disabled = true;
|
||||||
|
goBtn.textContent = "Exporting...";
|
||||||
|
|
||||||
|
const gif = new GIF({
|
||||||
|
workers: 2,
|
||||||
|
quality: 10,
|
||||||
|
width: EXPORT_SIZE,
|
||||||
|
height: EXPORT_SIZE,
|
||||||
|
workerScript: "/static/gif.worker.js",
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = EXPORT_SIZE;
|
||||||
|
canvas.height = EXPORT_SIZE;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
const total = gameData.frames.length;
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
drawFrame(ctx, gameData.frames[i]);
|
||||||
|
gif.addFrame(ctx, { copy: true, delay: speed });
|
||||||
|
const pct = Math.round(((i + 1) / total) * 50);
|
||||||
|
barEl.value = pct;
|
||||||
|
pctEl.textContent = pct + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
gif.on("progress", (p) => {
|
||||||
|
const pct = 50 + Math.round(p * 50);
|
||||||
|
barEl.value = pct;
|
||||||
|
pctEl.textContent = pct + "%";
|
||||||
|
});
|
||||||
|
|
||||||
|
gif.on("finished", (blob) => {
|
||||||
|
barEl.value = 100;
|
||||||
|
pctEl.textContent = "100%";
|
||||||
|
goBtn.disabled = false;
|
||||||
|
goBtn.textContent = "Export";
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "chess-pressure.gif";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
setTimeout(() => { progressEl.style.display = "none"; }, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
gif.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
})();
|
||||||
3
static/gif.js
Normal file
3
static/gif.worker.js
Normal file
BIN
static/img/chesspieces/wikipedia/bB.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/img/chesspieces/wikipedia/bK.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
static/img/chesspieces/wikipedia/bN.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
static/img/chesspieces/wikipedia/bP.png
Normal file
|
After Width: | Height: | Size: 777 B |
BIN
static/img/chesspieces/wikipedia/bQ.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
static/img/chesspieces/wikipedia/bR.png
Normal file
|
After Width: | Height: | Size: 748 B |
BIN
static/img/chesspieces/wikipedia/wB.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
static/img/chesspieces/wikipedia/wK.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
static/img/chesspieces/wikipedia/wN.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
static/img/chesspieces/wikipedia/wP.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/img/chesspieces/wikipedia/wQ.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
static/img/chesspieces/wikipedia/wR.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
111
static/index.html
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chess Pressure</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css?v=21">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Chess Pressure</h1>
|
||||||
|
<div class="header-controls">
|
||||||
|
<button id="theme-toggle" title="Toggle dark/light mode">
|
||||||
|
<span class="icon-sun">☼</span>
|
||||||
|
<span class="icon-moon">☾</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="board-panel">
|
||||||
|
<div class="board-wrap">
|
||||||
|
<div id="board"></div>
|
||||||
|
</div>
|
||||||
|
<div class="playback">
|
||||||
|
<input type="range" id="slider" min="0" max="0" value="0">
|
||||||
|
<div class="playback-buttons">
|
||||||
|
<button id="btn-start" title="Start">⏮</button>
|
||||||
|
<button id="btn-prev" title="Back">◀</button>
|
||||||
|
<button id="btn-play" title="Play/Pause">▶</button>
|
||||||
|
<button id="btn-next" title="Forward">▶</button>
|
||||||
|
<button id="btn-end" title="End">⏭</button>
|
||||||
|
</div>
|
||||||
|
<div class="pressure-mode">
|
||||||
|
<label><input type="radio" name="pmode" value="equal" checked> Equal weight</label>
|
||||||
|
<label><input type="radio" name="pmode" value="weighted"> Piece value</label>
|
||||||
|
<label><input type="checkbox" id="toggle-pieces" checked> Pieces</label>
|
||||||
|
<label><input type="checkbox" id="toggle-board"> Board</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-panel">
|
||||||
|
<div class="game-selector">
|
||||||
|
<select id="game-select">
|
||||||
|
<option value="__new">New game</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-upload" title="Upload PGN">PGN</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-info" id="game-info"></div>
|
||||||
|
|
||||||
|
<div class="move-list" id="move-list"></div>
|
||||||
|
|
||||||
|
<div class="fork-controls" id="fork-controls" style="display:none">
|
||||||
|
<span class="fork-badge">Forked at move <span id="fork-point"></span></span>
|
||||||
|
<button id="btn-reset-fork">Reset to original</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="export-row">
|
||||||
|
<button id="btn-export">Export GIF</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-bar" id="status-bar"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog id="export-dialog">
|
||||||
|
<form method="dialog">
|
||||||
|
<h2>Export GIF</h2>
|
||||||
|
<label>Speed per move:
|
||||||
|
<select id="export-speed">
|
||||||
|
<option value="250">0.25s (fast)</option>
|
||||||
|
<option value="500" selected>0.5s</option>
|
||||||
|
<option value="1000">1s</option>
|
||||||
|
<option value="2000">2s (slow)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="export-info">480 × 480px — respects current view settings</p>
|
||||||
|
<div class="export-progress" id="export-progress" style="display:none">
|
||||||
|
<progress id="export-bar" max="100" value="0"></progress>
|
||||||
|
<span id="export-pct">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button type="button" id="export-go">Export</button>
|
||||||
|
<button type="button" id="export-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="pgn-dialog">
|
||||||
|
<form method="dialog">
|
||||||
|
<h2>Upload PGN</h2>
|
||||||
|
<textarea id="pgn-input" rows="12" placeholder="Paste PGN here..."></textarea>
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button type="button" id="pgn-file-btn">Choose file</button>
|
||||||
|
<input type="file" id="pgn-file" accept=".pgn,.txt" style="display:none">
|
||||||
|
<button type="submit" id="pgn-submit">Load</button>
|
||||||
|
<button type="button" id="pgn-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.10.3/chess.min.js"></script>
|
||||||
|
<script src="/static/gif.js"></script>
|
||||||
|
<script src="/static/app.js?v=21"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
339
static/style.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||