Files
lara-schach-live/js/app.js

745 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Lara Kiesewetter Live Schachturnier
* Haupt-Application
*/
/* global $, parsePGN, filterLaraGames, getLiveGame, getLatestGame, Chess, Chessboard */
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;
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();
// DSJ liefert ISO-8859-1; versuche UTF-8, fallback auf Latin-1
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 {
await updateStandings();
if (currentRound === 0) {
if (showOverlay) showLoading(false);
return;
}
// Alle Runden immer frisch von der DSJ laden (mit Cache-Busting)
const maxRound = currentRound + 1;
for (let r = 1; r <= maxRound; r++) {
const text = await fetchRoundPGN(r);
if (currentPollId !== pollId) return;
if (text !== null) roundPgns[r] = text;
}
// Alle PGNs kombinieren
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);
currentGame = liveGame || getLatestGame(allLaraGames);
}
updateBoard();
updatePlayerInfo();
updateMovesList();
updateAllGamesList();
updatePGNDisplay();
updateTimestamp();
showLoading(false);
} catch (error) {
if (currentPollId !== pollId) return;
console.error('Fehler beim Laden:', error);
showLoading(false);
}
}
/**
* Aktualisiert das Schachbrett
*/
function updateBoard() {
if (!currentGame) return;
// 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
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
// Aktuellen Move beibehalten — nie automatisch zum letzten Zug springen
if (currentMoveIndex >= nonResultMoves.length || currentMoveIndex < -1) {
currentMoveIndex = nonResultMoves.length - 1;
}
chess = new Chess();
if (currentMoveIndex >= 0) {
for (let i = 0; i <= currentMoveIndex && i < nonResultMoves.length; i++) {
try {
chess.move(nonResultMoves[i].san);
} catch {
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
});
// Click auf Züge
document.getElementById('moves-list').addEventListener('click', handleMoveClick);
}
// Highlight active player
highlightActivePlayer();
syncEvalBarHeight();
updateEvaluation();
}
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';
}
}
/**
* Sendet FEN an Stockfish und aktualisiert die Eval-Bar
*/
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) + '%';
}
/**
* Gehe zu einem bestimmten Zug (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++) {
try {
chess.move(nonResultMoves[i].san);
} catch {
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');
}
}
/**
* Aktualisiert die Spielerinformationen
*/
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 ? '⬛' : '⬜';
// Top-Panel (player-black) = Gegner
document.getElementById('black-name').textContent = oppPlayer;
document.getElementById('black-elo').textContent = oppElo ? `(ELO: ${oppElo})` : '';
document.querySelector('#player-black .player-avatar').textContent = oppEmoji;
// Bottom-Panel (player-white) = Lara
document.getElementById('white-name').textContent = laraPlayer;
document.getElementById('white-elo').textContent = laraElo ? `(ELO: ${laraElo})` : '';
document.querySelector('#player-white .player-avatar').textContent = laraEmoji;
updateClocks(currentMoveIndex);
// Round info
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;
// 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}`;
}
}
/**
* Aktualisiert die Uhrenanzeige basierend auf dem aktuellen Zugindex
*/
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 = formatClock(oppClock);
document.getElementById('white-clock').textContent = formatClock(laraClock);
}
/**
* Highlight den Spieler, der gerade am Zug ist
*/
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');
}
}
/**
* Highlight den letzten Zug auf dem Brett
*/
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');
}
}
}
/**
* Generiert PGN-Text aus einem Spiel-Objekt
*/
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);
}
/**
* Aktualisiert die Zugliste
*/
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) {
// Lara (Weiß) zuerst
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 {
// Lara (Schwarz) Gegner zuerst, dann Lara
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);
}
}
}
// Mark current move
if (currentMoveIndex >= 0) {
const curMove = movesList.querySelector(`[data-index="${currentMoveIndex}"]`);
if (curMove) curMove.classList.add('current');
}
}
/**
* Klick auf einen Zug in der Liste
*/
function handleMoveClick(e) {
if (!e.target.classList.contains('move') || !currentGame) return;
goToMove(parseInt(e.target.dataset.index));
}
/**
* 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', () => {
userSelectedGame = true;
currentGame = game;
updateBoard();
updatePlayerInfo();
updateMovesList();
updateAllGamesList();
updatePGNDisplay();
});
list.appendChild(entry);
}
}
/**
* Lädt die Turniertabelle vom DSJ und zeigt Laras Platzierung an
*/
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');
if (!data || data.error) {
container.innerHTML = '<div class="standings-loading">Daten nicht verfügbar</div>';
return;
}
container.innerHTML = `
<div class="standings-rank">${data.rank}.</div>
<div class="standings-rank-label">Tabellenplatz</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>';
}
}
/**
* Format clock string
*/
function formatClock(clockStr) {
if (!clockStr) return '--:--:--';
// Format is HH:MM:SS
return clockStr;
}
/**
* Update timestamp
*/
function updateTimestamp() {
const time = new Date();
document.getElementById('last-update').textContent =
`Letztes Update: ${time.toLocaleTimeString('de-DE')}`;
}
/**
* Startet Auto-Refresh alle 30 Sekunden
*/
function startAutoRefresh() {
clearInterval(pollInterval);
clearInterval(updateTimer);
const myId = ++pollId;
let lastUpdate = Date.now();
document.getElementById('refresh-timer').textContent = '0s';
document.getElementById('refresh-timer').style.color = '#4ade80';
loadPGN(true);
pollInterval = setInterval(() => {
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); return; }
loadPGN(false);
lastUpdate = Date.now();
}, 30000);
updateTimer = setInterval(() => {
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); return; }
const elapsed = Date.now() - lastUpdate;
const remaining = Math.max(0, 30000 - elapsed);
const s = Math.floor(remaining / 1000);
document.getElementById('refresh-timer').textContent = `${s}s`;
}, 1000);
}
/**
* UI Helpers
*/
function showLoading(show) {
document.getElementById('loading-overlay').style.display = show ? 'flex' : 'none';
}
function hideError() {
document.getElementById('error-overlay').style.display = 'none';
}
/**
* Manual refresh button startet neuen Long-Poll-Zyklus
*/
document.getElementById('refresh-btn').addEventListener('click', () => {
pollId++;
startAutoRefresh();
});
/**
* PGN in Zwischenablage kopieren
*/
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);
}
});
});
/**
* Pfeiltasten-Navigation
*/
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);
}
});
/**
* Board-Resize bei Fenster- und Container-Änderungen
*/
window.addEventListener('resize', () => {
if (board) board.resize();
});
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => {
if (board) board.resize();
});
ro.observe(document.getElementById('board'));
}
/**
* Page Visibility API Timer pausieren wenn Tab unsichtbar
*/
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearInterval(pollInterval);
clearInterval(updateTimer);
} else {
startAutoRefresh();
}
});
/**
* Init
*/
loadPGN();
startAutoRefresh();
// Eval-Bar initialisieren
chess = new Chess();
updateEvaluation();