Compare commits
30 Commits
84973c002c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f320e2f91 | |||
| c7633f5c7f | |||
| 8758441f65 | |||
| 8d971dbef9 | |||
| a42fe45812 | |||
| 6ba57c3927 | |||
| 352ed480a8 | |||
| b3d43e37b8 | |||
| 79ab97da6e | |||
| fbb15ed61a | |||
| 0be816a4ec | |||
| ec1ebaa525 | |||
| 3b1a4ce4e4 | |||
| f5d6d59cdd | |||
| 47e7924a49 | |||
| 6608d771a2 | |||
| df77f52a53 | |||
| b337c92d77 | |||
| 20a59166a8 | |||
| 3944c7d5bb | |||
| 812cd3b24f | |||
| 638ef0360f | |||
| 12a2b5c7db | |||
| 08ec14bd63 | |||
| 82e37fb9bf | |||
| ab4486cc98 | |||
| 7efa38c91a | |||
| 2ad3dab7f8 | |||
| 0d94cac60c | |||
| 15b6f31f62 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1 +1,7 @@
|
||||
cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
lara-chess/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
stockfish.exe
|
||||
|
||||
8
AGENTS.md
Normal file
8
AGENTS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Agenten-Regeln
|
||||
|
||||
- Nach jeder Änderung am Proxy (server.py) oder an den Requests (app.js) muss der Proxy neu gestartet werden: erst alten Prozess killen (`Get-Process -Name python | Stop-Process -Force`), dann unsichtbar starten.
|
||||
- Commits und Push erfolgen nur durch expliziten Befehl des Nutzers.
|
||||
- Der Server (server.py) wird immer unsichtbar gestartet: `Start-Process -NoNewWindow -FilePath "python" -ArgumentList "server.py"`
|
||||
- Bei Updates (Polling/Code-Änderungen) niemals den aktuell angezeigten Zug wechseln. Der Benutzer bleibt auf dem von ihm ausgewählten Zug.
|
||||
- Nach jeder Code-Änderung muss die Doku (README.md) auf Richtigkeit und Vollständigkeit geprüft und bei Bedarf aktualisiert werden (Features, Projektstruktur, Konfiguration, Umgebungsvariablen, Technik-Tabelle).
|
||||
|
||||
127
README.md
127
README.md
@@ -1,27 +1,37 @@
|
||||
# 🏆 Lara Kiesewetter – Live Schachturnier
|
||||
# Lara Kiesewetter – Live Schachturnier
|
||||
|
||||
Live-Überwachung von Lara Kiesewetters Partien bei der **ODJM (Offene Deutsche Jugendmeisterschaft)** 2026. Die App lädt automatisch PGN-Daten von der Deutschen Schachjugend, zeigt das aktuelle Spielbrett an und listet alle Partien von Lara.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Live-Brett** – Visuelle Darstellung der aktuellen Partie mit chessboard.js
|
||||
- **Stockfish-Analyse** – Bewertungsbalken (Eval-Bar) mit Live-Score in Bauerneinheiten, powered by Stockfish-Engine
|
||||
- **Spielerinfo** – Anzeige von Namen, ELO und Schachuhr beider Spieler
|
||||
- **Zugliste** – Alle Züge der aktuellen Partie mit Klick-Navigation
|
||||
- **Zugliste** – Alle Züge der aktuellen Partie mit Klick-Navigation und Pfeiltasten-Steuerung
|
||||
- **Alle Partien** – Übersicht aller Runden mit Lara; Klick zum Wechseln
|
||||
- **Live-Erkennung** – Automatische Erkennung laufender (unterminierter) Partien
|
||||
- **Auto-Refresh** – Aktualisiert die Daten alle 60 Sekunden
|
||||
- **Proxy-Server** – Lokaler Python-Server mit PGN-Caching und CORS-Unterstützung
|
||||
- **[Turniertabelle](https://www.deutsche-schachjugend.de/2026/odjm-d/tabelle/)** – Tabellenplatz, Punkte, Siege/Unentschieden/Niederlagen von Lara
|
||||
- **Auto-Refresh** – Aktualisiert die Daten alle 15 Sekunden (nur aktuelle Runden)
|
||||
- **Proxy-Server** – Lokaler Python-Server mit Stockfish-Engine-Wrapper, PGN-Proxy und statischem File-Serving
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
├── app.js # Hauptlogik (Brett, UI, Auto-Refresh)
|
||||
```text
|
||||
├── index.html # HTML-Grundgerüst
|
||||
├── style.css # Dark-Theme-Styling
|
||||
├── pgn-parser.js # PGN-Parser (Header + Züge extrahieren)
|
||||
├── server.py # Lokaler Proxy-Server (Python 3)
|
||||
├── cache/ # PGN-Cache (wird automatisch angelegt)
|
||||
└── lara-chess/ # (optional – reserviert für Erweiterungen)
|
||||
├── style.css # Dark-Theme-Styling (responsive)
|
||||
├── js/
|
||||
│ ├── app.js # Event-Listener und Initialisierung
|
||||
│ ├── state.js # Globaler Zustand (Variablen)
|
||||
│ ├── board.js # Schachbrett-Rendering und Zug-Navigation
|
||||
│ ├── data.js # PGN-Laden, Turniertabelle, Auto-Refresh
|
||||
│ ├── evaluation.js # Stockfish-Analyse und Eval-Bar
|
||||
│ ├── ui.js # UI-Rendering (Spielerinfo, Züge, PGN)
|
||||
│ └── pgn-parser.js # PGN-Parser (Header + Züge extrahieren)
|
||||
├── server.py # Lokaler Proxy-Server + Stockfish-Engine (Python 3)
|
||||
├── cache/ # PGN- und Standings-Cache
|
||||
└── stockfish.exe # Stockfish-Schachengine (Windows)
|
||||
```
|
||||
|
||||
## Setup
|
||||
@@ -29,6 +39,7 @@ Live-Überwachung von Lara Kiesewetters Partien bei der **ODJM (Offene Deutsche
|
||||
### Voraussetzungen
|
||||
|
||||
- **Python 3.6+** für den Proxy-Server
|
||||
- **Stockfish** (optional, für Analyse) – auf Windows: `stockfish.exe` im Projektverzeichnis
|
||||
- **Browser** (Chrome, Firefox, Edge)
|
||||
|
||||
### Installation & Start
|
||||
@@ -40,37 +51,89 @@ Live-Überwachung von Lara Kiesewetters Partien bei der **ODJM (Offene Deutsche
|
||||
Der Server läuft auf `http://localhost:8111`.
|
||||
|
||||
2. **Öffne die App im Browser:**
|
||||
```
|
||||
```text
|
||||
http://localhost:8111
|
||||
```
|
||||
(Der Server serviert auch die statischen Dateien.)
|
||||
|
||||
### Ohne Proxy (direkter Dateizugriff)
|
||||
|
||||
Öffne `index.html` direkt im Browser. Hinweis: Der CORS-Proxy (`server.py`) muss trotzdem laufen, da der PGN-Endpunkt keine CORS-Header setzt.
|
||||
|
||||
## Technik
|
||||
|
||||
| Komponente | Technologie |
|
||||
| ------------------ | --------------------------------------------------- |
|
||||
| Schachbrett | [chessboard.js](https://github.com/oakmac/chessboardjs) |
|
||||
| Schachlogik | [chess.js](https://github.com/jhlywa/chess.js) |
|
||||
| UI | HTML, CSS, Vanilla JS |
|
||||
| Proxy-Server | Python http.server (ohne externe Abhängigkeiten) |
|
||||
| PGN-Quelle | deutsche-schachjugend.de (ODJM 2026) |
|
||||
| Komponente | Technologie |
|
||||
| ------------------ | ------------------------------------------------------------ |
|
||||
| Schachbrett | [chessboard.js](https://github.com/oakmac/chessboardjs) |
|
||||
| Schachlogik | [chess.js](https://github.com/jhlywa/chess.js) |
|
||||
| Stockfish-Analyse | Stockfish Engine (lokal, via POST `/evaluate`) |
|
||||
| UI | HTML, CSS, Vanilla JS, jQuery |
|
||||
| Proxy-Server | Python http.server (subprocess, threading, NDJSON-Streaming) |
|
||||
| PGN-Quelle | deutsche-schachjugend.de (ODJM 2026) |
|
||||
|
||||
## Konfiguration
|
||||
## Deployment
|
||||
|
||||
Die wichtigsten Konstanten in `app.js`:
|
||||
Die Anwendung kann als eigenständiger Server betrieben werden.
|
||||
|
||||
```js
|
||||
const PGN_URL = 'https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn';
|
||||
const REFRESH_INTERVAL = 60000; // Auto-Refresh alle 60s
|
||||
const PLAYER_NAME = 'Kiesewetter, Lara';
|
||||
### Produktivbetrieb (systemd – Linux)
|
||||
|
||||
```bash
|
||||
# Service-Datei erstellen
|
||||
sudo tee /etc/systemd/system/lara-chess.service <<EOF
|
||||
[Unit]
|
||||
Description=Lara Kiesewetter Live Schachturnier
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/lara-chess
|
||||
ExecStart=/usr/bin/python3 /opt/lara-chess/server.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Aktivieren und starten
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lara-chess
|
||||
```
|
||||
|
||||
Im `server.py` lässt sich der Cache-TTL anpassen:
|
||||
Die App läuft dann auf `http://SERVER_IP:8111`.
|
||||
|
||||
```python
|
||||
CACHE_TTL = 30 # Sekunden
|
||||
### Docker
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM python:3-slim
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
EXPOSE 8111
|
||||
CMD ["python", "server.py"]
|
||||
```
|
||||
|
||||
```bash
|
||||
docker build -t lara-chess .
|
||||
docker run -d --name lara-chess -p 8111:8111 --restart unless-stopped lara-chess
|
||||
```
|
||||
|
||||
### Reverse Proxy (nginx)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name chess.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8111;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Umgebungsvariablen (optional)
|
||||
|
||||
| Variable | Default | Beschreibung |
|
||||
| ------------------- | ------------------------------ | ------------------------------- |
|
||||
| `PORT` | `8111` | Server-Port |
|
||||
| `STOCKFISH_PATH` | `stockfish.exe` / `stockfish` | Pfad zur Stockfish-Engine |
|
||||
| `STOCKFISH_DEPTH` | `25` | Stockfish-Suchtiefe |
|
||||
|
||||
344
app.js
344
app.js
@@ -1,344 +0,0 @@
|
||||
/**
|
||||
* Lara Kiesewetter – Live Schachturnier
|
||||
* Haupt-Application
|
||||
*/
|
||||
|
||||
const PGN_URL = 'https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn';
|
||||
const REFRESH_INTERVAL = 60000; // 60 Sekunden
|
||||
const PLAYER_NAME = 'Kiesewetter, Lara';
|
||||
|
||||
let board = null;
|
||||
let chess = null;
|
||||
let currentGame = null;
|
||||
let allLaraGames = [];
|
||||
let refreshTimer = null;
|
||||
let countdown = 0;
|
||||
let serverLastFetch = null;
|
||||
|
||||
/**
|
||||
* Lädt die PGN-Datei und aktualisiert die Anzeige
|
||||
*/
|
||||
async function loadPGN() {
|
||||
showLoading(true);
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const [pgnResponse, statusResponse] = await Promise.all([
|
||||
fetch('http://localhost:8111/pgn'),
|
||||
fetch('http://localhost:8111/status').catch(() => null)
|
||||
]);
|
||||
|
||||
if (!pgnResponse.ok) throw new Error(`HTTP ${pgnResponse.status}`);
|
||||
|
||||
if (statusResponse && statusResponse.ok) {
|
||||
const status = await statusResponse.json();
|
||||
serverLastFetch = status.last_fetch ? status.last_fetch * 1000 : null;
|
||||
}
|
||||
|
||||
const pgnText = await pgnResponse.text();
|
||||
const allGames = parsePGN(pgnText);
|
||||
allLaraGames = filterLaraGames(allGames);
|
||||
|
||||
if (allLaraGames.length === 0) {
|
||||
showError('Keine Partien von Lara gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Finde die aktuelle/live Partie
|
||||
const liveGame = getLiveGame(allLaraGames);
|
||||
const targetGame = liveGame || getLatestGame(allLaraGames);
|
||||
|
||||
currentGame = targetGame;
|
||||
updateBoard();
|
||||
updatePlayerInfo();
|
||||
updateMovesList();
|
||||
updateAllGamesList();
|
||||
updateTimestamp();
|
||||
|
||||
showLoading(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden:', error);
|
||||
showError(`Fehler: ${error.message}`);
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert das Schachbrett
|
||||
*/
|
||||
function updateBoard() {
|
||||
if (!currentGame) return;
|
||||
|
||||
chess = new Chess();
|
||||
|
||||
// Spiegele das Brett, wenn Lara Schwarz hat
|
||||
const laraIsBlack = currentGame.black.toLowerCase().includes('kiesewetter');
|
||||
const orientation = laraIsBlack ? 'black' : 'white';
|
||||
|
||||
// Führe alle Züge aus
|
||||
for (const move of currentGame.moves) {
|
||||
if (move.isResult) break;
|
||||
try {
|
||||
chess.move(move.san);
|
||||
} catch (e) {
|
||||
// Ignoriere ungültige Züge
|
||||
}
|
||||
}
|
||||
|
||||
if (board) board.position(chess.fen(), true);
|
||||
else {
|
||||
board = Chessboard('board', {
|
||||
position: chess.fen(),
|
||||
orientation: orientation,
|
||||
pieceTheme: 'https://chessboardjs.com/img/chesspieces/wikipedia/{piece}.png',
|
||||
draggable: false,
|
||||
spawnMoveError: false
|
||||
});
|
||||
|
||||
// Click auf Züge
|
||||
document.getElementById('moves-list').addEventListener('click', handleMoveClick);
|
||||
}
|
||||
|
||||
// Highlight active player
|
||||
highlightActivePlayer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Spielerinformationen
|
||||
*/
|
||||
function updatePlayerInfo() {
|
||||
if (!currentGame) return;
|
||||
|
||||
const laraIsWhite = currentGame.white.toLowerCase().includes('kiesewetter');
|
||||
|
||||
if (laraIsWhite) {
|
||||
document.getElementById('white-name').textContent = currentGame.white;
|
||||
document.getElementById('white-elo').textContent = currentGame.whiteElo ? `(ELO: ${currentGame.whiteElo})` : '';
|
||||
document.getElementById('white-clock').textContent = formatClock(currentGame.whiteClock);
|
||||
|
||||
document.getElementById('black-name').textContent = currentGame.black;
|
||||
document.getElementById('black-elo').textContent = currentGame.blackElo ? `(ELO: ${currentGame.blackElo})` : '';
|
||||
document.getElementById('black-clock').textContent = formatClock(currentGame.blackClock);
|
||||
} else {
|
||||
document.getElementById('white-name').textContent = currentGame.white;
|
||||
document.getElementById('white-elo').textContent = currentGame.whiteElo ? `(ELO: ${currentGame.whiteElo})` : '';
|
||||
document.getElementById('white-clock').textContent = formatClock(currentGame.whiteClock);
|
||||
|
||||
document.getElementById('black-name').textContent = currentGame.black;
|
||||
document.getElementById('black-elo').textContent = currentGame.blackElo ? `(ELO: ${currentGame.blackElo})` : '';
|
||||
document.getElementById('black-clock').textContent = formatClock(currentGame.blackClock);
|
||||
}
|
||||
|
||||
// Round info
|
||||
const roundInfo = `Runde ${currentGame.round} – ${currentGame.event || 'Turnier'}`;
|
||||
document.getElementById('round-info').textContent = roundInfo;
|
||||
|
||||
// Result info
|
||||
const resultEl = document.getElementById('result-info');
|
||||
if (currentGame.isLive) {
|
||||
resultEl.innerHTML = '<span style="color: #4ade80;">● Laufen</span>';
|
||||
} else {
|
||||
resultEl.textContent = `Ergebnis: ${currentGame.result}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight den Spieler, der gerade am Zug ist
|
||||
*/
|
||||
function highlightActivePlayer() {
|
||||
if (!chess) return;
|
||||
|
||||
const whiteEl = document.getElementById('player-white');
|
||||
const blackEl = document.getElementById('player-black');
|
||||
|
||||
whiteEl.classList.remove('active');
|
||||
blackEl.classList.remove('active');
|
||||
|
||||
if (chess.turn() === 'w') {
|
||||
whiteEl.classList.add('active');
|
||||
} else {
|
||||
blackEl.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Zugliste
|
||||
*/
|
||||
function updateMovesList() {
|
||||
if (!currentGame) return;
|
||||
|
||||
const movesList = document.getElementById('moves-list');
|
||||
movesList.innerHTML = '';
|
||||
|
||||
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
|
||||
|
||||
for (let i = 0; i < nonResultMoves.length; i += 2) {
|
||||
const moveNumber = Math.floor(i / 2) + 1;
|
||||
|
||||
// Move number
|
||||
const numSpan = document.createElement('span');
|
||||
numSpan.className = 'move-number';
|
||||
numSpan.textContent = `${moveNumber}.`;
|
||||
movesList.appendChild(numSpan);
|
||||
|
||||
// White move
|
||||
const whiteMove = document.createElement('span');
|
||||
whiteMove.className = 'move';
|
||||
whiteMove.textContent = nonResultMoves[i].san;
|
||||
whiteMove.dataset.index = i;
|
||||
movesList.appendChild(whiteMove);
|
||||
|
||||
// Black move
|
||||
if (i + 1 < nonResultMoves.length) {
|
||||
const blackMove = document.createElement('span');
|
||||
blackMove.className = 'move';
|
||||
blackMove.textContent = nonResultMoves[i + 1].san;
|
||||
blackMove.dataset.index = i + 1;
|
||||
movesList.appendChild(blackMove);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark last move
|
||||
if (nonResultMoves.length > 0) {
|
||||
const lastMove = movesList.querySelector(`[data-index="${nonResultMoves.length - 1}"]`);
|
||||
if (lastMove) lastMove.classList.add('current');
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
movesList.scrollTop = movesList.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Klick auf einen Zug in der Liste
|
||||
*/
|
||||
function handleMoveClick(e) {
|
||||
if (!e.target.classList.contains('move') || !currentGame) return;
|
||||
|
||||
const index = parseInt(e.target.dataset.index);
|
||||
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
|
||||
|
||||
chess = new Chess();
|
||||
for (let i = 0; i <= index; i++) {
|
||||
try {
|
||||
chess.move(nonResultMoves[i].san);
|
||||
} catch (err) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
board.position(chess.fen(), true);
|
||||
|
||||
// Update current highlight
|
||||
document.querySelectorAll('#moves-list .move').forEach(el => el.classList.remove('current'));
|
||||
e.target.classList.add('current');
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Liste aller Partien
|
||||
*/
|
||||
function updateAllGamesList() {
|
||||
const list = document.getElementById('all-games-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
// Sort by round
|
||||
const sorted = [...allLaraGames].sort((a, b) => {
|
||||
return (parseInt(a.round) || 0) - (parseInt(b.round) || 0);
|
||||
});
|
||||
|
||||
for (const game of sorted) {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'game-entry';
|
||||
if (currentGame && game === currentGame) entry.classList.add('active');
|
||||
|
||||
const laraIsWhite = game.white.toLowerCase().includes('kiesewetter');
|
||||
const opponent = laraIsWhite ? game.black : game.white;
|
||||
const color = laraIsWhite ? '⬜ Weiß' : '⬛ Schwarz';
|
||||
|
||||
entry.innerHTML = `
|
||||
<div class="game-round">Runde ${game.round}</div>
|
||||
<div class="game-players">Lara ${color} vs ${opponent}</div>
|
||||
<div class="game-result">${game.isLive ? '● Laufen' : game.result}</div>
|
||||
`;
|
||||
|
||||
entry.addEventListener('click', () => {
|
||||
currentGame = game;
|
||||
updateBoard();
|
||||
updatePlayerInfo();
|
||||
updateMovesList();
|
||||
updateAllGamesList();
|
||||
});
|
||||
|
||||
list.appendChild(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format clock string
|
||||
*/
|
||||
function formatClock(clockStr) {
|
||||
if (!clockStr) return '--:--:--';
|
||||
// Format is HH:MM:SS
|
||||
return clockStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update timestamp
|
||||
*/
|
||||
function updateTimestamp() {
|
||||
const time = serverLastFetch ? new Date(serverLastFetch) : new Date();
|
||||
document.getElementById('last-update').textContent =
|
||||
`Letztes Update: ${time.toLocaleTimeString('de-DE')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-refresh
|
||||
*/
|
||||
function startAutoRefresh() {
|
||||
countdown = REFRESH_INTERVAL / 1000;
|
||||
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
|
||||
refreshTimer = setInterval(() => {
|
||||
countdown--;
|
||||
const mins = Math.floor(countdown / 60);
|
||||
const secs = countdown % 60;
|
||||
document.getElementById('refresh-timer').textContent =
|
||||
`Nächstes Update in: ${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
|
||||
if (countdown <= 0) {
|
||||
countdown = REFRESH_INTERVAL / 1000;
|
||||
loadPGN();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Helpers
|
||||
*/
|
||||
function showLoading(show) {
|
||||
document.getElementById('loading-overlay').style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('error-message').textContent = msg;
|
||||
document.getElementById('error-overlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('error-overlay').style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual refresh button
|
||||
*/
|
||||
document.getElementById('refresh-btn').addEventListener('click', () => {
|
||||
countdown = REFRESH_INTERVAL / 1000;
|
||||
loadPGN();
|
||||
});
|
||||
|
||||
/**
|
||||
* Init
|
||||
*/
|
||||
loadPGN();
|
||||
startAutoRefresh();
|
||||
13
eslint.config.mjs
Normal file
13
eslint.config.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import json from "@eslint/json";
|
||||
import markdown from "@eslint/markdown";
|
||||
import css from "@eslint/css";
|
||||
import { defineConfig } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
|
||||
{ files: ["**/*.json"], ignores: ["package-lock.json"], plugins: { json }, language: "json/json", extends: ["json/recommended"] },
|
||||
{ files: ["**/*.md"], plugins: { markdown }, language: "markdown/commonmark", extends: ["markdown/recommended"] },
|
||||
{ files: ["**/*.css"], plugins: { css }, language: "css/css", extends: ["css/recommended"], rules: { "css/use-baseline": "off" } },
|
||||
]);
|
||||
60
index.html
60
index.html
@@ -20,6 +20,7 @@
|
||||
|
||||
<div id="main-content">
|
||||
<!-- Linke Spalte: Schachbrett -->
|
||||
<h2 id="round-info-mobile" class="round-info-mobile"></h2>
|
||||
<div id="board-section">
|
||||
<div id="player-black" class="player-info">
|
||||
<div class="player-avatar">⬛</div>
|
||||
@@ -29,7 +30,35 @@
|
||||
</div>
|
||||
<div class="player-clock" id="black-clock">--:--:--</div>
|
||||
</div>
|
||||
<div id="board"></div>
|
||||
<div id="board-row">
|
||||
<div id="board"></div>
|
||||
<div id="eval-bar-container">
|
||||
<div id="eval-bar">
|
||||
<div id="eval-bar-lines">
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line eval-line-center"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
<span class="eval-line"></span>
|
||||
</div>
|
||||
<div id="eval-bar-fill"></div>
|
||||
<div id="eval-bar-marker"></div>
|
||||
</div>
|
||||
<div id="eval-info">
|
||||
<span id="eval-score">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="player-white" class="player-info">
|
||||
<div class="player-avatar">⬜</div>
|
||||
<div class="player-details">
|
||||
@@ -38,6 +67,13 @@
|
||||
</div>
|
||||
<div class="player-clock" id="white-clock">--:--:--</div>
|
||||
</div>
|
||||
<div id="pgn-panel" class="pgn-panel--desktop">
|
||||
<div class="pgn-header">
|
||||
<span>PGN</span>
|
||||
<button class="copy-pgn-btn" title="In Zwischenablage kopieren">📋</button>
|
||||
</div>
|
||||
<pre class="pgn-text"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte: Info & Züge -->
|
||||
@@ -54,6 +90,19 @@
|
||||
<h3>Alle Partien von Lara</h3>
|
||||
<div id="all-games-list"></div>
|
||||
</div>
|
||||
<div id="pgn-panel-mobile" class="pgn-panel--mobile">
|
||||
<div class="pgn-header">
|
||||
<span>PGN</span>
|
||||
<button class="copy-pgn-btn" title="In Zwischenablage kopieren">📋</button>
|
||||
</div>
|
||||
<pre class="pgn-text"></pre>
|
||||
</div>
|
||||
<div id="standings-panel">
|
||||
<h3>Turniertabelle ODJM D 2026</h3>
|
||||
<div id="standings-content">
|
||||
<div class="standings-loading">Lade Tabellenstand...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +121,12 @@
|
||||
<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="pgn-parser.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script src="js/pgn-parser.js"></script>
|
||||
<script src="js/state.js"></script>
|
||||
<script src="js/evaluation.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/board.js"></script>
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
69
js/app.js
Normal file
69
js/app.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Lara Kiesewetter – Live Schachturnier
|
||||
* Event listeners and initialization
|
||||
*/
|
||||
|
||||
/* global $, Chess, Chessboard */
|
||||
|
||||
document.getElementById('refresh-btn').addEventListener('click', () => {
|
||||
pollId++;
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.copy-pgn-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const panel = btn.closest('[class*="pgn-panel"]');
|
||||
const text = panel.querySelector('.pgn-text').textContent;
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
btn.textContent = '✅';
|
||||
setTimeout(() => { btn.textContent = '📋'; }, 1500);
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!currentGame) return;
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
goToMove(currentMoveIndex - 1);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
goToMove(currentMoveIndex + 1);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (board) board.resize();
|
||||
});
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (board) board.resize();
|
||||
});
|
||||
ro.observe(document.getElementById('board'));
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(updateTimer);
|
||||
} else {
|
||||
startAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
loadPGN();
|
||||
startAutoRefresh();
|
||||
|
||||
chess = new Chess();
|
||||
updateEvaluation();
|
||||
97
js/board.js
Normal file
97
js/board.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Lara Kiesewetter – Live Schachturnier
|
||||
* Board rendering and move navigation
|
||||
*/
|
||||
|
||||
/* global $, Chess, Chessboard */
|
||||
|
||||
function handleMoveClick(e) {
|
||||
if (!e.target.classList.contains('move') || !currentGame) return;
|
||||
goToMove(parseInt(e.target.dataset.index));
|
||||
}
|
||||
|
||||
function goToMove(index) {
|
||||
if (!currentGame) return;
|
||||
|
||||
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
|
||||
|
||||
if (index < -1) index = -1;
|
||||
if (index >= nonResultMoves.length) index = nonResultMoves.length - 1;
|
||||
|
||||
currentMoveIndex = index;
|
||||
|
||||
chess = new Chess();
|
||||
for (let i = 0; i <= index; i++) {
|
||||
const san = nonResultMoves[i].san;
|
||||
let result = chess.move(san);
|
||||
if (!result) {
|
||||
const cleanSan = san.replace(/^([NBRQK])([a-h])?([1-8])?([a-h][1-8].*)$/, '$1$4');
|
||||
result = chess.move(cleanSan);
|
||||
}
|
||||
if (!result) break;
|
||||
}
|
||||
|
||||
board.position(chess.fen(), true);
|
||||
highlightActivePlayer();
|
||||
highlightLastMove();
|
||||
updateClocks(index);
|
||||
syncEvalBarHeight();
|
||||
updateEvaluation();
|
||||
|
||||
document.querySelectorAll('#moves-list .move').forEach(el => el.classList.remove('current'));
|
||||
if (index >= 0) {
|
||||
const moveEl = document.querySelector(`#moves-list [data-index="${index}"]`);
|
||||
if (moveEl) moveEl.classList.add('current');
|
||||
}
|
||||
}
|
||||
|
||||
function updateBoard() {
|
||||
if (!currentGame) return;
|
||||
|
||||
const laraIsBlack = currentGame.black.toLowerCase().includes('kiesewetter');
|
||||
const orientation = laraIsBlack ? 'black' : 'white';
|
||||
|
||||
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
|
||||
|
||||
if (currentMoveIndex >= nonResultMoves.length || currentMoveIndex <= -1) {
|
||||
currentMoveIndex = nonResultMoves.length - 1;
|
||||
} else if (previousMoveCount >= 0 && currentMoveIndex === previousMoveCount - 1 && nonResultMoves.length > previousMoveCount) {
|
||||
currentMoveIndex = nonResultMoves.length - 1;
|
||||
}
|
||||
previousMoveCount = nonResultMoves.length;
|
||||
|
||||
chess = new Chess();
|
||||
if (currentMoveIndex >= 0) {
|
||||
for (let i = 0; i <= currentMoveIndex && i < nonResultMoves.length; i++) {
|
||||
const san = nonResultMoves[i].san;
|
||||
let result = chess.move(san);
|
||||
if (!result) {
|
||||
const cleanSan = san.replace(/^([NBRQK])([a-h])?([1-8])?([a-h][1-8].*)$/, '$1$4');
|
||||
result = chess.move(cleanSan);
|
||||
}
|
||||
if (!result) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (board) {
|
||||
board.position(chess.fen(), true);
|
||||
board.orientation(orientation);
|
||||
board.resize();
|
||||
highlightLastMove();
|
||||
} else {
|
||||
board = Chessboard('board', {
|
||||
position: chess.fen(),
|
||||
orientation: orientation,
|
||||
pieceTheme: 'https://chessboardjs.com/img/chesspieces/wikipedia/{piece}.png',
|
||||
draggable: false,
|
||||
spawnMoveError: false
|
||||
});
|
||||
|
||||
document.getElementById('moves-list').addEventListener('click', handleMoveClick);
|
||||
}
|
||||
|
||||
highlightActivePlayer();
|
||||
|
||||
syncEvalBarHeight();
|
||||
updateEvaluation();
|
||||
}
|
||||
176
js/data.js
Normal file
176
js/data.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Lara Kiesewetter – Live Schachturnier
|
||||
* Data fetching, PGN loading, standings, auto-refresh
|
||||
*/
|
||||
|
||||
/* global parsePGN, filterLaraGames, getLiveGame, getTodaysGames, getLatestGame */
|
||||
|
||||
async function fetchRoundPGN(round) {
|
||||
const res = await fetch(`https://www.deutsche-schachjugend.de/2026/odjm-d/partien/${round}.pgn?t=${Date.now()}`);
|
||||
if (!res.ok) return null;
|
||||
const buf = await res.arrayBuffer();
|
||||
let text = new TextDecoder('utf-8').decode(buf);
|
||||
if (text.includes('\uFFFD')) {
|
||||
text = new TextDecoder('iso-8859-1').decode(buf);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function loadPGN(showOverlay = true) {
|
||||
const currentPollId = pollId;
|
||||
if (showOverlay) showLoading(true);
|
||||
hideError();
|
||||
|
||||
try {
|
||||
if (currentRound === 0) {
|
||||
if (showOverlay) showLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxRound = currentRound + 1;
|
||||
const isFirstLoad = Object.keys(roundPgns).length === 0;
|
||||
const startRound = isFirstLoad ? 1 : currentRound;
|
||||
for (let r = startRound; r <= maxRound; r++) {
|
||||
const text = await fetchRoundPGN(r);
|
||||
if (currentPollId !== pollId) return;
|
||||
if (text !== null) roundPgns[r] = text;
|
||||
}
|
||||
|
||||
const combinedPgn = Object.values(roundPgns).join('\n\n');
|
||||
const allGames = parsePGN(combinedPgn);
|
||||
allLaraGames = filterLaraGames(allGames);
|
||||
|
||||
if (allLaraGames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userSelectedGame) {
|
||||
const liveGame = getLiveGame(allLaraGames);
|
||||
const todaysGames = getTodaysGames(allLaraGames);
|
||||
currentGame = liveGame || (todaysGames.length > 0 ? getLatestGame(todaysGames) : getLatestGame(allLaraGames));
|
||||
} else if (currentGame) {
|
||||
const updatedGame = allLaraGames.find(g =>
|
||||
g.white === currentGame.white &&
|
||||
g.black === currentGame.black &&
|
||||
g.round === currentGame.round
|
||||
);
|
||||
if (updatedGame) currentGame = updatedGame;
|
||||
}
|
||||
updateBoard();
|
||||
updatePlayerInfo();
|
||||
updateMovesList();
|
||||
updateAllGamesList();
|
||||
updatePGNDisplay();
|
||||
updateTimestamp();
|
||||
|
||||
showLoading(false);
|
||||
|
||||
} catch (error) {
|
||||
if (currentPollId !== pollId) return;
|
||||
console.error('Fehler beim Laden:', error);
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStandings() {
|
||||
try {
|
||||
const res = await fetch(`https://www.deutsche-schachjugend.de/2026/odjm-d/tabelle/?t=${Date.now()}`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden');
|
||||
const html = await res.text();
|
||||
|
||||
const roundMatch = html.match(/Tabellenstand\s+nach\s+der\s+(\d+)\.\s*Runde/);
|
||||
if (roundMatch) {
|
||||
currentRound = parseInt(roundMatch[1]);
|
||||
}
|
||||
|
||||
const rows = html.matchAll(/<tr[^>]*>(.*?)<\/tr>/gs);
|
||||
for (const row of rows) {
|
||||
if (!row[1].includes('Lara Kiesewetter')) continue;
|
||||
|
||||
const cells = row[1].matchAll(/<td[^>]*>(.*?)<\/td>/gs);
|
||||
const clean = [];
|
||||
for (const cell of cells) {
|
||||
clean.push(cell[1].replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
|
||||
if (clean.length >= 9) {
|
||||
const data = {
|
||||
rank: clean[0],
|
||||
player: 'Lara Kiesewetter',
|
||||
wins: clean[5],
|
||||
draws: clean[6],
|
||||
losses: clean[7],
|
||||
points: clean[8],
|
||||
round_info: roundMatch ? `nach der ${roundMatch[1]}. Runde` : '',
|
||||
round: currentRound,
|
||||
};
|
||||
const container = document.getElementById('standings-content');
|
||||
container.innerHTML = `
|
||||
<div class="standings-rank">${data.rank}.</div>
|
||||
<div class="standings-rank-label"><a href="https://www.deutsche-schachjugend.de/2026/odjm-d/tabelle/" target="_blank" rel="noopener">Tabellenplatz</a></div>
|
||||
<div class="standings-header">${data.round_info || 'nach Runde 1'}</div>
|
||||
<div class="standings-row">
|
||||
<span class="standings-label">Punkte</span>
|
||||
<span class="standings-value">${data.points}</span>
|
||||
</div>
|
||||
<div class="standings-row">
|
||||
<span class="standings-label">Siege</span>
|
||||
<span class="standings-value">${data.wins}</span>
|
||||
</div>
|
||||
<div class="standings-row">
|
||||
<span class="standings-label">Unentschieden</span>
|
||||
<span class="standings-value">${data.draws}</span>
|
||||
</div>
|
||||
<div class="standings-row">
|
||||
<span class="standings-label">Niederlagen</span>
|
||||
<span class="standings-value">${data.losses}</span>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.getElementById('standings-content').innerHTML = '<div class="standings-loading">Daten nicht verfügbar</div>';
|
||||
} catch {
|
||||
document.getElementById('standings-content').innerHTML = '<div class="standings-loading">Daten nicht verfügbar</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimestamp() {
|
||||
const time = new Date();
|
||||
document.getElementById('last-update').textContent =
|
||||
`Letztes Update: ${time.toLocaleTimeString('de-DE')}`;
|
||||
}
|
||||
|
||||
async function startAutoRefresh() {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(updateTimer);
|
||||
clearInterval(standingsInterval);
|
||||
|
||||
const myId = ++pollId;
|
||||
let lastUpdate = Date.now();
|
||||
|
||||
document.getElementById('refresh-timer').textContent = '0s';
|
||||
document.getElementById('refresh-timer').style.color = '#4ade80';
|
||||
|
||||
await updateStandings();
|
||||
loadPGN(true);
|
||||
|
||||
pollInterval = setInterval(() => {
|
||||
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); clearInterval(standingsInterval); return; }
|
||||
loadPGN(false);
|
||||
lastUpdate = Date.now();
|
||||
}, 15000);
|
||||
|
||||
updateTimer = setInterval(() => {
|
||||
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); clearInterval(standingsInterval); return; }
|
||||
const elapsed = Date.now() - lastUpdate;
|
||||
const remaining = Math.max(0, 15000 - elapsed);
|
||||
const s = Math.floor(remaining / 1000);
|
||||
document.getElementById('refresh-timer').textContent = `${s}s`;
|
||||
}, 1000);
|
||||
|
||||
standingsInterval = setInterval(() => {
|
||||
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); clearInterval(standingsInterval); return; }
|
||||
updateStandings();
|
||||
}, 1800000);
|
||||
}
|
||||
99
js/evaluation.js
Normal file
99
js/evaluation.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Lara Kiesewetter – Live Schachturnier
|
||||
* Stockfish evaluation
|
||||
*/
|
||||
|
||||
/* global Chess */
|
||||
|
||||
function syncEvalBarHeight() {
|
||||
const boardEl = document.getElementById('board');
|
||||
const evalContainer = document.getElementById('eval-bar-container');
|
||||
if (boardEl && evalContainer) {
|
||||
const h = boardEl.offsetHeight;
|
||||
if (h > 100) evalContainer.style.minHeight = h + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEvaluation() {
|
||||
if (!chess) return;
|
||||
const fen = chess.fen();
|
||||
|
||||
if (lastEvalFen === fen && evalAbortController) return;
|
||||
lastEvalFen = fen;
|
||||
|
||||
if (evalAbortController) {
|
||||
evalAbortController.abort();
|
||||
}
|
||||
evalAbortController = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch('/evaluate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fen }),
|
||||
signal: evalAbortController.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error('Eval fehlgeschlagen');
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
displayEval(JSON.parse(line));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try { displayEval(JSON.parse(buffer)); } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') return;
|
||||
const el = document.getElementById('eval-score');
|
||||
if (el) el.textContent = '?';
|
||||
}
|
||||
}
|
||||
|
||||
function displayEval(data) {
|
||||
const evalScore = document.getElementById('eval-score');
|
||||
const barFill = document.getElementById('eval-bar-fill');
|
||||
const barMarker = document.getElementById('eval-bar-marker');
|
||||
|
||||
let scoreText = '0.00';
|
||||
let fillPercent = 50;
|
||||
|
||||
if (data.scoreMate !== null && data.scoreMate !== undefined && data.scoreMate !== 0) {
|
||||
const mate = parseInt(data.scoreMate);
|
||||
const whiteMate = chess && chess.turn() === 'w' ? mate : -mate;
|
||||
if (whiteMate > 0) {
|
||||
scoreText = '#' + whiteMate;
|
||||
fillPercent = 100;
|
||||
} else {
|
||||
scoreText = '#' + Math.abs(whiteMate);
|
||||
fillPercent = 0;
|
||||
}
|
||||
} else if (data.scoreCp !== null && data.scoreCp !== undefined) {
|
||||
const cp = parseInt(data.scoreCp);
|
||||
const whiteCp = chess && chess.turn() === 'w' ? cp : -cp;
|
||||
const pawns = (whiteCp / 100).toFixed(2);
|
||||
scoreText = (whiteCp > 0 ? '+' : '') + pawns;
|
||||
fillPercent = 50 + whiteCp / 14;
|
||||
fillPercent = Math.max(0, Math.min(100, fillPercent));
|
||||
}
|
||||
|
||||
evalScore.textContent = scoreText;
|
||||
|
||||
if (barFill) barFill.style.height = fillPercent + '%';
|
||||
if (barMarker) barMarker.style.top = (100 - fillPercent) + '%';
|
||||
}
|
||||
@@ -66,21 +66,43 @@ function parseGameBlock(block) {
|
||||
function parseMoves(movesText) {
|
||||
const moves = [];
|
||||
|
||||
// Remove comments in curly braces
|
||||
movesText = movesText.replace(/\{[^}]*\}/g, '');
|
||||
let clockWhite = null;
|
||||
let clockBlack = null;
|
||||
let color = 'w';
|
||||
|
||||
// Remove move numbers
|
||||
movesText = movesText.replace(/\d+\.\s*/g, '');
|
||||
const tokenRegex = /\d+\.\s*|\{[^}]*\}|\S+/g;
|
||||
const tokens = movesText.match(tokenRegex) || [];
|
||||
|
||||
// Split into individual moves
|
||||
const tokens = movesText.split(/\s+/).filter(t => t.trim());
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
let token = tokens[i].trim();
|
||||
|
||||
for (const token of tokens) {
|
||||
if (['1-0', '0-1', '1/2-1/2', '*'].includes(token)) {
|
||||
moves.push({ san: token, isResult: true });
|
||||
} else if (token.length > 0) {
|
||||
moves.push({ san: token, isResult: false });
|
||||
moves.push({ san: token, isResult: true, whiteClock: clockWhite, blackClock: clockBlack });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.$/.test(token)) continue;
|
||||
|
||||
if (token.startsWith('{')) {
|
||||
const clkMatch = token.match(/\[%clk\s+([\d:]+)\]/);
|
||||
if (clkMatch && moves.length > 0) {
|
||||
const lastMove = moves[moves.length - 1];
|
||||
if (!lastMove.isResult && lastMove.color) {
|
||||
if (lastMove.color === 'w') {
|
||||
clockWhite = clkMatch[1];
|
||||
} else {
|
||||
clockBlack = clkMatch[1];
|
||||
}
|
||||
lastMove.whiteClock = clockWhite;
|
||||
lastMove.blackClock = clockBlack;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const move = { san: token, isResult: false, whiteClock: clockWhite, blackClock: clockBlack, color };
|
||||
moves.push(move);
|
||||
color = color === 'w' ? 'b' : 'w';
|
||||
}
|
||||
|
||||
return moves;
|
||||
@@ -98,9 +120,17 @@ function getLiveGame(laraGames) {
|
||||
return laraGames.find(game => game.isLive) || null;
|
||||
}
|
||||
|
||||
function getTodaysGames(laraGames) {
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(today.getDate()).padStart(2, '0');
|
||||
const todayStr = `${yyyy}.${mm}.${dd}`;
|
||||
return laraGames.filter(game => game.date === todayStr);
|
||||
}
|
||||
|
||||
function getLatestGame(laraGames) {
|
||||
if (laraGames.length === 0) return null;
|
||||
// Sort by round number, return the highest round
|
||||
const sorted = [...laraGames].sort((a, b) => {
|
||||
const roundA = parseInt(a.round) || 0;
|
||||
const roundB = parseInt(b.round) || 0;
|
||||
@@ -108,3 +138,9 @@ function getLatestGame(laraGames) {
|
||||
});
|
||||
return sorted[0];
|
||||
}
|
||||
|
||||
window.parsePGN = parsePGN;
|
||||
window.filterLaraGames = filterLaraGames;
|
||||
window.getLiveGame = getLiveGame;
|
||||
window.getTodaysGames = getTodaysGames;
|
||||
window.getLatestGame = getLatestGame;
|
||||
22
js/state.js
Normal file
22
js/state.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Lara Kiesewetter – Live Schachturnier
|
||||
* Global state
|
||||
*/
|
||||
|
||||
/* global Chess */
|
||||
|
||||
let board = null;
|
||||
let chess = null;
|
||||
let currentGame = null;
|
||||
let allLaraGames = [];
|
||||
let currentMoveIndex = -1;
|
||||
let userSelectedGame = false;
|
||||
let evalAbortController = null;
|
||||
let lastEvalFen = null;
|
||||
let currentRound = 0;
|
||||
let roundPgns = {};
|
||||
let pollId = 0;
|
||||
let pollInterval = null;
|
||||
let updateTimer = null;
|
||||
let standingsInterval = null;
|
||||
let previousMoveCount = -1;
|
||||
255
js/ui.js
Normal file
255
js/ui.js
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Lara Kiesewetter – Live Schachturnier
|
||||
* UI rendering: player info, clocks, highlights, moves list, games list, PGN display
|
||||
*/
|
||||
|
||||
/* global $, Chess */
|
||||
|
||||
function showLoading(show) {
|
||||
document.getElementById('loading-overlay').style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('error-overlay').style.display = 'none';
|
||||
}
|
||||
|
||||
function updatePlayerInfo() {
|
||||
if (!currentGame) return;
|
||||
|
||||
const laraIsWhite = currentGame.white.toLowerCase().includes('kiesewetter');
|
||||
|
||||
const laraPlayer = laraIsWhite ? currentGame.white : currentGame.black;
|
||||
const laraElo = laraIsWhite ? currentGame.whiteElo : currentGame.blackElo;
|
||||
const laraEmoji = laraIsWhite ? '⬜' : '⬛';
|
||||
|
||||
const oppPlayer = laraIsWhite ? currentGame.black : currentGame.white;
|
||||
const oppElo = laraIsWhite ? currentGame.blackElo : currentGame.whiteElo;
|
||||
const oppEmoji = laraIsWhite ? '⬛' : '⬜';
|
||||
|
||||
document.getElementById('black-name').textContent = oppPlayer;
|
||||
document.getElementById('black-elo').textContent = oppElo ? `(ELO: ${oppElo})` : '';
|
||||
document.querySelector('#player-black .player-avatar').textContent = oppEmoji;
|
||||
|
||||
document.getElementById('white-name').textContent = laraPlayer;
|
||||
document.getElementById('white-elo').textContent = laraElo ? `(ELO: ${laraElo})` : '';
|
||||
document.querySelector('#player-white .player-avatar').textContent = laraEmoji;
|
||||
|
||||
updateClocks(currentMoveIndex);
|
||||
|
||||
const roundInfo = `Runde ${currentGame.round} – ${currentGame.event || 'Turnier'}`;
|
||||
document.getElementById('round-info').textContent = roundInfo;
|
||||
const mobileRound = document.getElementById('round-info-mobile');
|
||||
if (mobileRound) mobileRound.textContent = roundInfo;
|
||||
|
||||
const resultEl = document.getElementById('result-info');
|
||||
if (currentGame.isLive) {
|
||||
resultEl.innerHTML = '<span style="color: #4ade80;">● Laufen</span>';
|
||||
} else {
|
||||
resultEl.textContent = `Ergebnis: ${currentGame.result}`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateClocks(moveIndex) {
|
||||
if (!currentGame) return;
|
||||
|
||||
const laraIsWhite = currentGame.white.toLowerCase().includes('kiesewetter');
|
||||
|
||||
let whiteClock, blackClock;
|
||||
|
||||
if (moveIndex < 0) {
|
||||
whiteClock = currentGame.whiteClock;
|
||||
blackClock = currentGame.blackClock;
|
||||
} else {
|
||||
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
|
||||
if (moveIndex < nonResultMoves.length) {
|
||||
whiteClock = nonResultMoves[moveIndex].whiteClock;
|
||||
blackClock = nonResultMoves[moveIndex].blackClock;
|
||||
}
|
||||
}
|
||||
|
||||
const laraClock = laraIsWhite ? whiteClock : blackClock;
|
||||
const oppClock = laraIsWhite ? blackClock : whiteClock;
|
||||
|
||||
document.getElementById('black-clock').textContent = oppClock || '--:--:--';
|
||||
document.getElementById('white-clock').textContent = laraClock || '--:--:--';
|
||||
}
|
||||
|
||||
function highlightActivePlayer() {
|
||||
if (!chess || !currentGame) return;
|
||||
|
||||
const laraPanel = document.getElementById('player-white');
|
||||
const oppPanel = document.getElementById('player-black');
|
||||
|
||||
laraPanel.classList.remove('active');
|
||||
oppPanel.classList.remove('active');
|
||||
|
||||
const laraIsWhite = currentGame.white.toLowerCase().includes('kiesewetter');
|
||||
const isLaraTurn = (chess.turn() === 'w' && laraIsWhite) || (chess.turn() === 'b' && !laraIsWhite);
|
||||
|
||||
if (isLaraTurn) {
|
||||
laraPanel.classList.add('active');
|
||||
} else {
|
||||
oppPanel.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function highlightLastMove() {
|
||||
if (!board || !chess || !currentGame) return;
|
||||
|
||||
$('#board [data-square]').removeClass('last-move-highlight');
|
||||
|
||||
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
|
||||
|
||||
if (currentMoveIndex >= 0 && currentMoveIndex < nonResultMoves.length) {
|
||||
const moves = chess.history({ verbose: true });
|
||||
if (moves.length > 0) {
|
||||
const lastMoveData = moves[moves.length - 1];
|
||||
$(`#board [data-square="${lastMoveData.from}"]`).addClass('last-move-highlight');
|
||||
$(`#board [data-square="${lastMoveData.to}"]`).addClass('last-move-highlight');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generatePGN(game) {
|
||||
if (!game) return '';
|
||||
|
||||
let pgn = '';
|
||||
if (game.event) pgn += `[Event "${game.event}"]\n`;
|
||||
if (game.site) pgn += `[Site "${game.site}"]\n`;
|
||||
if (game.date) pgn += `[Date "${game.date}"]\n`;
|
||||
if (game.round) pgn += `[Round "${game.round}"]\n`;
|
||||
if (game.white) pgn += `[White "${game.white}"]\n`;
|
||||
if (game.black) pgn += `[Black "${game.black}"]\n`;
|
||||
if (game.result) pgn += `[Result "${game.result}"]\n`;
|
||||
if (game.whiteElo) pgn += `[WhiteElo "${game.whiteElo}"]\n`;
|
||||
if (game.blackElo) pgn += `[BlackElo "${game.blackElo}"]\n`;
|
||||
if (game.termination) pgn += `[Termination "${game.termination}"]\n`;
|
||||
|
||||
pgn += '\n';
|
||||
|
||||
const nonResultMoves = game.moves.filter(m => !m.isResult);
|
||||
for (let i = 0; i < nonResultMoves.length; i += 2) {
|
||||
const moveNumber = Math.floor(i / 2) + 1;
|
||||
pgn += `${moveNumber}. ${nonResultMoves[i].san}`;
|
||||
if (i + 1 < nonResultMoves.length) {
|
||||
pgn += ` ${nonResultMoves[i + 1].san} `;
|
||||
}
|
||||
}
|
||||
|
||||
const resultMove = game.moves.find(m => m.isResult);
|
||||
if (resultMove) {
|
||||
pgn += ` ${resultMove.san}`;
|
||||
}
|
||||
|
||||
return pgn.trim();
|
||||
}
|
||||
|
||||
function updatePGNDisplay() {
|
||||
if (!currentGame) return;
|
||||
const text = generatePGN(currentGame);
|
||||
document.querySelectorAll('.pgn-text').forEach(el => el.textContent = text);
|
||||
}
|
||||
|
||||
function updateMovesList() {
|
||||
if (!currentGame) return;
|
||||
|
||||
const movesList = document.getElementById('moves-list');
|
||||
movesList.innerHTML = '';
|
||||
|
||||
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
|
||||
const laraIsWhite = currentGame.white.toLowerCase().includes('kiesewetter');
|
||||
|
||||
for (let i = 0; i < nonResultMoves.length; i += 2) {
|
||||
const moveNumber = Math.floor(i / 2) + 1;
|
||||
|
||||
const numSpan = document.createElement('span');
|
||||
numSpan.className = 'move-number';
|
||||
numSpan.textContent = `${moveNumber}.`;
|
||||
movesList.appendChild(numSpan);
|
||||
|
||||
if (laraIsWhite) {
|
||||
const laraMove = document.createElement('span');
|
||||
laraMove.className = 'move lara-move';
|
||||
laraMove.textContent = nonResultMoves[i].san;
|
||||
laraMove.dataset.index = i;
|
||||
movesList.appendChild(laraMove);
|
||||
|
||||
if (i + 1 < nonResultMoves.length) {
|
||||
const oppMove = document.createElement('span');
|
||||
oppMove.className = 'move opp-move';
|
||||
oppMove.textContent = nonResultMoves[i + 1].san;
|
||||
oppMove.dataset.index = i + 1;
|
||||
movesList.appendChild(oppMove);
|
||||
}
|
||||
} else {
|
||||
const oppMove = document.createElement('span');
|
||||
oppMove.className = 'move opp-move';
|
||||
oppMove.textContent = nonResultMoves[i].san;
|
||||
oppMove.dataset.index = i;
|
||||
movesList.appendChild(oppMove);
|
||||
|
||||
if (i + 1 < nonResultMoves.length) {
|
||||
const laraMove = document.createElement('span');
|
||||
laraMove.className = 'move lara-move';
|
||||
laraMove.textContent = nonResultMoves[i + 1].san;
|
||||
laraMove.dataset.index = i + 1;
|
||||
movesList.appendChild(laraMove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentMoveIndex >= 0) {
|
||||
const curMove = movesList.querySelector(`[data-index="${currentMoveIndex}"]`);
|
||||
if (curMove) curMove.classList.add('current');
|
||||
}
|
||||
}
|
||||
|
||||
function updateAllGamesList() {
|
||||
const list = document.getElementById('all-games-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
const sorted = [...allLaraGames].sort((a, b) => {
|
||||
return (parseInt(a.round) || 0) - (parseInt(b.round) || 0);
|
||||
});
|
||||
|
||||
for (const game of sorted) {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'game-entry';
|
||||
if (currentGame && game === currentGame) entry.classList.add('active');
|
||||
|
||||
const laraIsWhite = game.white.toLowerCase().includes('kiesewetter');
|
||||
const opponent = laraIsWhite ? game.black : game.white;
|
||||
const color = laraIsWhite ? '⬜ Weiß' : '⬛ Schwarz';
|
||||
|
||||
let resultIcon = '';
|
||||
if (game.isLive) {
|
||||
resultIcon = '⏳';
|
||||
} else if (game.result === '1/2-1/2') {
|
||||
resultIcon = '🤝';
|
||||
} else if ((laraIsWhite && game.result === '1-0') || (!laraIsWhite && game.result === '0-1')) {
|
||||
resultIcon = '✅';
|
||||
} else {
|
||||
resultIcon = '❌';
|
||||
}
|
||||
|
||||
entry.innerHTML = `
|
||||
<div class="game-round">${resultIcon} Runde ${game.round}</div>
|
||||
<div class="game-players">Lara ${color} vs ${opponent}</div>
|
||||
<div class="game-result">${game.isLive ? '● Laufen' : game.result}</div>
|
||||
`;
|
||||
|
||||
entry.addEventListener('click', () => {
|
||||
userSelectedGame = true;
|
||||
currentGame = game;
|
||||
previousMoveCount = -1;
|
||||
currentMoveIndex = Number.MAX_SAFE_INTEGER;
|
||||
updateBoard();
|
||||
updatePlayerInfo();
|
||||
updateMovesList();
|
||||
updateAllGamesList();
|
||||
updatePGNDisplay();
|
||||
});
|
||||
|
||||
list.appendChild(entry);
|
||||
}
|
||||
}
|
||||
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"scripts": {},
|
||||
"devDependencies": {
|
||||
"@eslint/css": "^1.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@eslint/json": "^1.2.0",
|
||||
"@eslint/markdown": "^8.0.2",
|
||||
"eslint": "^10.4.0",
|
||||
"globals": "^17.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chess.js": "^0.10.3"
|
||||
}
|
||||
}
|
||||
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
430
server.py
430
server.py
@@ -1,180 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Lokal Proxy-Server für Lara's Schachturnier
|
||||
Lädt die PGN-Datei und stellt sie mit CORS-Headern bereit.
|
||||
Lokaler Server für Lara Schachturnier
|
||||
Serviert statische Dateien und bietet Stockfish-Analyse.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import socketserver
|
||||
import urllib.request
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import subprocess
|
||||
import json
|
||||
from datetime import datetime
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import socket
|
||||
import queue
|
||||
|
||||
PGN_URL = "https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn"
|
||||
PORT = 8111
|
||||
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache")
|
||||
CACHE_FILE = os.path.join(CACHE_DIR, "gesamt-utf8.pgn")
|
||||
CACHE_TTL = 30 # Sekunden
|
||||
PORT = int(os.environ.get("PORT", 8111))
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
STOCKFISH_PATH = os.environ.get("STOCKFISH_PATH")
|
||||
if not STOCKFISH_PATH:
|
||||
win_path = os.path.join(BASE_DIR, "stockfish.exe")
|
||||
if os.path.exists(win_path):
|
||||
STOCKFISH_PATH = win_path
|
||||
else:
|
||||
STOCKFISH_PATH = "stockfish"
|
||||
|
||||
last_fetch_time = None
|
||||
STOCKFISH_DEPTH = int(os.environ.get("STOCKFISH_DEPTH", 25))
|
||||
|
||||
_stockfish_lock = threading.Lock()
|
||||
|
||||
|
||||
def fetch_pgn():
|
||||
"""Lädt die PGN-Datei von der URL als Bytes."""
|
||||
global last_fetch_time
|
||||
try:
|
||||
req = urllib.request.Request(PGN_URL, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
data = response.read()
|
||||
last_fetch_time = time.time()
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Fehler beim Laden: {e}")
|
||||
return None
|
||||
class StockfishEngine:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.proc = None
|
||||
self._output_queue = queue.Queue()
|
||||
self._reader_thread = None
|
||||
self._reader_alive = threading.Event()
|
||||
self._cmd_lock = threading.Lock()
|
||||
self._searching = False
|
||||
|
||||
def start(self):
|
||||
if self.proc:
|
||||
return
|
||||
flags = 0
|
||||
if sys.platform == "win32":
|
||||
flags = subprocess.CREATE_NO_WINDOW
|
||||
|
||||
self.proc = subprocess.Popen(
|
||||
[self.path],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
creationflags=flags,
|
||||
)
|
||||
|
||||
self._reader_alive.set()
|
||||
self._reader_thread = threading.Thread(target=self._read_output, daemon=True)
|
||||
self._reader_thread.start()
|
||||
|
||||
self._send("uci")
|
||||
self._read_until("uciok")
|
||||
self._send("isready")
|
||||
self._read_until("readyok")
|
||||
|
||||
print(f"[STOCKFISH] Engine gestartet: {self.path}")
|
||||
|
||||
def _read_output(self):
|
||||
while self._reader_alive.is_set():
|
||||
try:
|
||||
line = self.proc.stdout.readline()
|
||||
except Exception:
|
||||
break
|
||||
if not line:
|
||||
break
|
||||
self._output_queue.put(line)
|
||||
|
||||
def _send(self, cmd):
|
||||
with self._cmd_lock:
|
||||
self.proc.stdin.write(cmd + "\n")
|
||||
self.proc.stdin.flush()
|
||||
|
||||
def _read_until(self, marker):
|
||||
while True:
|
||||
try:
|
||||
line = self._output_queue.get(timeout=5.0)
|
||||
except queue.Empty:
|
||||
return
|
||||
if line.strip() == marker:
|
||||
return
|
||||
|
||||
def _drain_queue(self):
|
||||
"""Leert die Queue von allen pending Zeilen."""
|
||||
drained = 0
|
||||
while True:
|
||||
try:
|
||||
self._output_queue.get_nowait()
|
||||
drained += 1
|
||||
except queue.Empty:
|
||||
break
|
||||
if drained > 0:
|
||||
print(f"[STOCKFISH] {drained} alte Zeilen verworfen")
|
||||
|
||||
def _stop_and_wait(self):
|
||||
"""Stoppt laufende Suche und wartet auf bestmove."""
|
||||
if not self._searching:
|
||||
return
|
||||
self._send("stop")
|
||||
while True:
|
||||
try:
|
||||
line = self._output_queue.get(timeout=3.0).strip()
|
||||
except queue.Empty:
|
||||
# Stockfish antwortet nicht auf stop (z.B. weil Suche schon beendet)
|
||||
self._searching = False
|
||||
return
|
||||
if line.startswith("bestmove"):
|
||||
self._searching = False
|
||||
return
|
||||
|
||||
def evaluate(self, fen):
|
||||
# Alte Suche abbrechen und Queue leeren
|
||||
self._stop_and_wait()
|
||||
self._drain_queue()
|
||||
|
||||
self._send(f"position fen {fen}")
|
||||
self._send(f"go depth {STOCKFISH_DEPTH} movetime 15000")
|
||||
self._searching = True
|
||||
|
||||
score_cp = None
|
||||
score_mate = None
|
||||
bestmove = None
|
||||
pv = None
|
||||
last_depth = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = self._output_queue.get(timeout=1.0).strip()
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if "score cp" in line:
|
||||
m = re.search(r"score cp (-?\d+)", line)
|
||||
if m:
|
||||
score_cp = int(m.group(1))
|
||||
if "score mate" in line:
|
||||
m = re.search(r"score mate (-?\d+)", line)
|
||||
if m:
|
||||
score_mate = int(m.group(1))
|
||||
if " pv " in line:
|
||||
pv = line.split(" pv ", 1)[1]
|
||||
|
||||
depth_m = re.search(r"depth (\d+)", line)
|
||||
if depth_m and score_cp is not None:
|
||||
new_depth = int(depth_m.group(1))
|
||||
if new_depth != last_depth:
|
||||
last_depth = new_depth
|
||||
yield {
|
||||
"scoreCp": score_cp,
|
||||
"scoreMate": score_mate,
|
||||
"bestMove": None,
|
||||
"pv": pv,
|
||||
"depth": new_depth,
|
||||
}
|
||||
|
||||
if line.startswith("bestmove"):
|
||||
parts = line.split()
|
||||
bestmove = parts[1] if len(parts) > 1 else None
|
||||
self._searching = False
|
||||
break
|
||||
|
||||
yield {
|
||||
"scoreCp": score_cp,
|
||||
"scoreMate": score_mate,
|
||||
"bestMove": bestmove,
|
||||
"pv": pv,
|
||||
}
|
||||
|
||||
def stop(self):
|
||||
self._reader_alive.clear()
|
||||
if self.proc:
|
||||
self.proc.terminate()
|
||||
self.proc = None
|
||||
|
||||
|
||||
def get_pgn_content():
|
||||
"""Gibt PGN-Inhalt als Bytes zurück, nutzt Cache wenn möglich."""
|
||||
now = time.time()
|
||||
|
||||
# Prüfe Cache
|
||||
if os.path.exists(CACHE_FILE):
|
||||
age = now - os.path.getmtime(CACHE_FILE)
|
||||
if age < CACHE_TTL:
|
||||
with open(CACHE_FILE, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
# Cache verfallen oder nicht vorhanden -> neu laden
|
||||
content = fetch_pgn()
|
||||
if content:
|
||||
with open(CACHE_FILE, "wb") as f:
|
||||
f.write(content)
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] PGN aktualisiert ({len(content)} Bytes)")
|
||||
return content
|
||||
|
||||
# Fallback: alter Cache
|
||||
if os.path.exists(CACHE_FILE):
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Verwende alten Cache")
|
||||
with open(CACHE_FILE, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
return None
|
||||
_engine = StockfishEngine(STOCKFISH_PATH)
|
||||
|
||||
|
||||
class PGNHandler(http.server.BaseHTTPRequestHandler):
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/pgn":
|
||||
content = get_pgn_content()
|
||||
if content:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
else:
|
||||
self.send_response(502)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"error": "Konnte PGN nicht laden"}')
|
||||
if self.path == "/":
|
||||
self.path = "/index.html"
|
||||
|
||||
filepath = os.path.normpath(os.path.join(BASE_DIR, self.path.lstrip("/")))
|
||||
if not filepath.startswith(BASE_DIR):
|
||||
self.send_response(403)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if os.path.isfile(filepath):
|
||||
content_types = {
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
ext = os.path.splitext(filepath)[1]
|
||||
content_type = content_types.get(ext, "application/octet-stream")
|
||||
|
||||
with open(filepath, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
elif self.path == "/status":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
status = {
|
||||
"status": "ok",
|
||||
"last_fetch": last_fetch_time,
|
||||
"cache_ttl": CACHE_TTL,
|
||||
"server_time": time.time()
|
||||
}
|
||||
self.wfile.write(json.dumps(status).encode())
|
||||
|
||||
self.wfile.write(content)
|
||||
else:
|
||||
# Statische Dateien aus dem Verzeichnis
|
||||
if self.path == "/":
|
||||
self.path = "/index.html"
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
filepath = os.path.join(os.path.dirname(os.path.abspath(__file__)), self.path.lstrip("/"))
|
||||
if os.path.isfile(filepath):
|
||||
content_types = {
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
ext = os.path.splitext(filepath)[1]
|
||||
content_type = content_types.get(ext, "application/octet-stream")
|
||||
def do_POST(self):
|
||||
if self.path == "/evaluate":
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length) if length else b"{}"
|
||||
|
||||
with open(filepath, "rb") as f:
|
||||
content = f.read()
|
||||
try:
|
||||
data = json.loads(body)
|
||||
fen = data.get("fen", "")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
self._send_json({"error": "Invalid JSON"}, 400)
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
if not fen:
|
||||
self._send_json({"error": "Missing fen"}, 400)
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
self.wfile.flush()
|
||||
|
||||
# TCP_NODELAY für sofortiges Senden
|
||||
try:
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _write_chunk(chunk_data):
|
||||
size = len(chunk_data)
|
||||
self.wfile.write(f"{size:x}\r\n".encode())
|
||||
self.wfile.write(chunk_data)
|
||||
self.wfile.write(b"\r\n")
|
||||
self.wfile.flush()
|
||||
|
||||
try:
|
||||
with _stockfish_lock:
|
||||
try:
|
||||
_engine.start()
|
||||
except FileNotFoundError:
|
||||
_write_chunk(
|
||||
json.dumps({"error": "Stockfish nicht gefunden"}).encode("utf-8")
|
||||
)
|
||||
return
|
||||
|
||||
for result in _engine.evaluate(fen):
|
||||
try:
|
||||
data = json.dumps(result).encode("utf-8") + b"\n"
|
||||
_write_chunk(data)
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
|
||||
# Client hat Verbindung getrennt (neuer Zug angeklickt / Pfeiltasten)
|
||||
break
|
||||
# Abschluss-Chunk
|
||||
_write_chunk(b"")
|
||||
except Exception as e:
|
||||
print(f"[STOCKFISH] Fehler: {e}")
|
||||
try:
|
||||
_write_chunk(
|
||||
json.dumps({"error": str(e)}).encode("utf-8")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self._send_json({"error": "Not found"}, 404)
|
||||
|
||||
def _send_json(self, obj, status=200):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(obj).encode("utf-8"))
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}")
|
||||
|
||||
|
||||
def background_refresh():
|
||||
"""Aktualisiert den Cache im Hintergrund."""
|
||||
while True:
|
||||
time.sleep(CACHE_TTL)
|
||||
try:
|
||||
content = fetch_pgn()
|
||||
if content:
|
||||
with open(CACHE_FILE, "wb") as f:
|
||||
f.write(content)
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Hintergrund-Refresh Fehler: {e}")
|
||||
print(f"[{self.log_date_time_string()}] {args[0]}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" [TROPHY] Lara Kiesewetter – Live Schachturnier")
|
||||
print(" Lara Kiesewetter - Live Schachturnier")
|
||||
print("=" * 50)
|
||||
print(f" Server läuft auf: http://localhost:{PORT}")
|
||||
print(f" Drücke Ctrl+C zum Beenden")
|
||||
print("=" * 50)
|
||||
|
||||
# Initialer Ladeversuch
|
||||
content = fetch_pgn()
|
||||
if content:
|
||||
with open(CACHE_FILE, "wb") as f:
|
||||
f.write(content)
|
||||
print(f"[OK] PGN geladen ({len(content)} Bytes)")
|
||||
print(f" Server laeuft auf: http://localhost:{PORT}")
|
||||
if os.path.exists(STOCKFISH_PATH) or STOCKFISH_PATH == "stockfish":
|
||||
print(f" Stockfish-Analyse aktiv (depth={STOCKFISH_DEPTH})")
|
||||
else:
|
||||
print("[WARN] Initialer Ladeversuch fehlgeschlagen, wird wiederholt...")
|
||||
print(f" Stockfish nicht gefunden unter: {STOCKFISH_PATH}")
|
||||
print(f" Druecke Ctrl+C zum Beenden")
|
||||
print("=" * 50)
|
||||
|
||||
# Hintergrund-Refresh Thread starten
|
||||
refresh_thread = threading.Thread(target=background_refresh, daemon=True)
|
||||
refresh_thread.start()
|
||||
|
||||
# Server starten
|
||||
with socketserver.TCPServer(("", PORT), PGNHandler) as httpd:
|
||||
print(f"\n[SERVER] Server gestartet: http://localhost:{PORT}\n")
|
||||
socketserver.ThreadingTCPServer.allow_reuse_address = True
|
||||
with socketserver.ThreadingTCPServer(("", PORT), Handler) as httpd:
|
||||
print(f"\n[SERVER] Bereit für Anfragen\n")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[BYE] Server gestoppt.")
|
||||
_engine.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
709
style.css
709
style.css
@@ -1,333 +1,552 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
border-bottom: 2px solid #e94560;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
border-bottom: 2px solid #e94560;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 10px rgba(233, 69, 96, 0.5);
|
||||
font-size: 1.5rem;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 10px rgba(233, 69, 96, 0.5);
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
#last-update, #refresh-timer {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #4ade80;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(74, 222, 128, 0.2);
|
||||
#last-update,
|
||||
#refresh-timer {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #4ade80;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(74, 222, 128, 0.2);
|
||||
}
|
||||
|
||||
#refresh-btn {
|
||||
background: #e94560;
|
||||
border: none;
|
||||
color: white;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: transform 0.3s, background 0.3s;
|
||||
background: #e94560;
|
||||
border: none;
|
||||
color: white;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: transform 0.3s, background 0.3s;
|
||||
}
|
||||
|
||||
#refresh-btn:hover {
|
||||
background: #ff6b6b;
|
||||
transform: rotate(180deg);
|
||||
background: #ff6b6b;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
#main-content {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Board Section */
|
||||
#board-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 12px;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 12px;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.player-info.active {
|
||||
border-color: #e94560;
|
||||
box-shadow: 0 0 15px rgba(233, 69, 96, 0.3);
|
||||
border-color: #e94560;
|
||||
box-shadow: 0 0 15px rgba(233, 69, 96, 0.3);
|
||||
}
|
||||
|
||||
.player-avatar {
|
||||
font-size: 2rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
font-size: 2rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.player-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.player-elo {
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.player-clock {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
color: #4ade80;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
#board {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
#board [data-square].last-move-highlight {
|
||||
box-shadow: inset 0 0 3px 3px rgba(255, 200, 0, 0.7);
|
||||
}
|
||||
/* Board Row: Board + Eval-Bar nebeneinander */
|
||||
#board-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
}
|
||||
#eval-bar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
min-height: 350px;
|
||||
}
|
||||
#eval-bar {
|
||||
flex: 1;
|
||||
width: 16px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
#eval-bar-fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: linear-gradient(to top, #b0b0b0 0%, #909090 100%);
|
||||
transition: height 0.3s ease;
|
||||
border-radius: 0 0 7px 7px;
|
||||
height: 50%;
|
||||
}
|
||||
#eval-bar-marker {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
height: 2px;
|
||||
background: #000;
|
||||
transition: top 0.3s ease;
|
||||
z-index: 3;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
|
||||
top: 50%;
|
||||
}
|
||||
#eval-bar-lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.eval-line {
|
||||
height: 1px;
|
||||
background: #eee;
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
.eval-line-center {
|
||||
height: 4px;
|
||||
background: #fff;
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
#eval-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#eval-score {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#pgn-panel,
|
||||
#pgn-panel-mobile {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
#pgn-panel-mobile {
|
||||
display: none;
|
||||
}
|
||||
.pgn-panel--desktop,
|
||||
.pgn-panel--mobile {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.pgn-panel--desktop {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.pgn-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
color: #e94560;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.copy-pgn-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #ccc;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.copy-pgn-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.pgn-text {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: #aaa;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 6px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Info Section */
|
||||
#info-section {
|
||||
flex: 0 0 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
flex: 0 0 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#game-info h2 {
|
||||
font-size: 1.3rem;
|
||||
color: #e94560;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.3rem;
|
||||
color: #e94560;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#result-info {
|
||||
font-size: 1.1rem;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#moves-panel, #all-games-panel {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
#moves-panel,
|
||||
#all-games-panel,
|
||||
#standings-panel,
|
||||
#pgn-panel-mobile {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#moves-panel h3, #all-games-panel h3 {
|
||||
margin-bottom: 12px;
|
||||
color: #e94560;
|
||||
font-size: 1rem;
|
||||
#moves-panel h3,
|
||||
#all-games-panel h3,
|
||||
#standings-panel h3,
|
||||
#pgn-panel-mobile h3 {
|
||||
margin-bottom: 12px;
|
||||
color: #e94560;
|
||||
font-size: 1rem;
|
||||
}
|
||||
#standings-content {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
#standings-content .standings-loading {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
.standings-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.standings-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.standings-label {
|
||||
color: #aaa;
|
||||
}
|
||||
.standings-value {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.standings-rank {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #e94560;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.standings-rank-label {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
.standings-rank-label a {
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
}
|
||||
.standings-rank-label a:hover {
|
||||
color: #ffd700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.standings-header {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
color: #ffd700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
#moves-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.8;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.8;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#moves-list .move-number {
|
||||
color: #888;
|
||||
font-weight: bold;
|
||||
color: #888;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#moves-list .move {
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#moves-list .move:hover {
|
||||
background: rgba(233, 69, 96, 0.3);
|
||||
background: rgba(233, 69, 96, 0.3);
|
||||
}
|
||||
|
||||
#moves-list .move.current {
|
||||
background: #e94560;
|
||||
color: #fff;
|
||||
background: #e94560;
|
||||
color: #fff;
|
||||
}
|
||||
#moves-list .lara-move {
|
||||
color: #ffd700;
|
||||
font-weight: bold;
|
||||
}
|
||||
#moves-list .opp-move {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
#all-games-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-entry {
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-left: 3px solid transparent;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.game-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.game-entry.active {
|
||||
border-left-color: #e94560;
|
||||
background: rgba(233, 69, 96, 0.15);
|
||||
border-left-color: #e94560;
|
||||
background: rgba(233, 69, 96, 0.15);
|
||||
}
|
||||
|
||||
.game-entry .game-round {
|
||||
font-weight: bold;
|
||||
color: #e94560;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: #e94560;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.game-entry .game-players {
|
||||
font-size: 0.9rem;
|
||||
color: #ccc;
|
||||
margin-top: 2px;
|
||||
font-size: 0.9rem;
|
||||
color: #ccc;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.game-entry .game-result {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Overlays */
|
||||
#loading-overlay, #error-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
gap: 16px;
|
||||
#loading-overlay,
|
||||
#error-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#loading-overlay p, #error-overlay p {
|
||||
font-size: 1.2rem;
|
||||
#loading-overlay p,
|
||||
#error-overlay p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
#error-overlay button {
|
||||
padding: 10px 24px;
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-top: 8px;
|
||||
padding: 10px 24px;
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(233, 69, 96, 0.3);
|
||||
border-top-color: #e94560;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(233, 69, 96, 0.3);
|
||||
border-top-color: #e94560;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #e94560;
|
||||
border-radius: 3px;
|
||||
background: #e94560;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.round-info-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
#main-content {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#info-section {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
#main-content {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
#info-section {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
#board-section {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
#board,
|
||||
.player-info {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
#board {
|
||||
overflow: hidden;
|
||||
}
|
||||
#board > div {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.round-info-mobile {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
font-size: 1.3rem;
|
||||
color: #e94560;
|
||||
margin: 0 auto;
|
||||
}
|
||||
#round-info {
|
||||
display: none;
|
||||
}
|
||||
#result-info {
|
||||
display: none;
|
||||
}
|
||||
.pgn-panel--desktop {
|
||||
display: none;
|
||||
}
|
||||
#pgn-panel-mobile {
|
||||
display: block;
|
||||
}
|
||||
.player-info {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.player-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.player-name {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.player-clock {
|
||||
font-size: 1.1rem;
|
||||
min-width: 80px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user