From 8758441f6533994fc1e5c6a144952ecb4b925766 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 27 May 2026 22:55:49 +0200 Subject: [PATCH] Split app.js into modular components (state, evaluation, ui, board, data) --- README.md | 7 +- index.html | 5 + js/app.js | 697 +---------------------------------------------- js/board.js | 97 +++++++ js/data.js | 169 ++++++++++++ js/evaluation.js | 99 +++++++ js/pgn-parser.js | 11 +- js/state.js | 22 ++ js/ui.js | 255 +++++++++++++++++ 9 files changed, 667 insertions(+), 695 deletions(-) create mode 100644 js/board.js create mode 100644 js/data.js create mode 100644 js/evaluation.js create mode 100644 js/state.js create mode 100644 js/ui.js diff --git a/README.md b/README.md index 9c0f539..c2017dd 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,12 @@ Live-Überwachung von Lara Kiesewetters Partien bei der **ODJM (Offene Deutsche ├── index.html # HTML-Grundgerüst ├── style.css # Dark-Theme-Styling (responsive) ├── js/ -│ ├── app.js # Hauptlogik (Brett, UI, Auto-Refresh, Eval-Bar) +│ ├── 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 diff --git a/index.html b/index.html index 7f46b34..6265159 100644 --- a/index.html +++ b/index.html @@ -122,6 +122,11 @@ + + + + + diff --git a/js/app.js b/js/app.js index 5c910b1..669e97b 100644 --- a/js/app.js +++ b/js/app.js @@ -1,691 +1,15 @@ /** * Lara Kiesewetter – Live Schachturnier - * Haupt-Application + * Event listeners and initialization */ -/* global $, parsePGN, filterLaraGames, getLiveGame, getLatestGame, Chess, Chessboard */ +/* global $, 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; -let previousMoveCount = -1; - -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; - } - - // Nur aktuelle/live Runden neu laden, historische bleiben gecached - 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; - } - - // 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, - // es sei denn der Nutzer war schon am Ende und neue Züge sind dazugekommen - if (currentMoveIndex >= nonResultMoves.length || currentMoveIndex < -1) { - currentMoveIndex = nonResultMoves.length - 1; - } else if (previousMoveCount >= 0 && currentMoveIndex === previousMoveCount - 1 && nonResultMoves.length > previousMoveCount) { - // Nutzer war am letzten Zug, neue Züge sind da → vorrücken - 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) { - // chess.js lehnt unnötige Disambiguierung ab (z.B. Nge7 wenn Nc6 gepinnt) - 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 - }); - - // 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++) { - const san = nonResultMoves[i].san; - let result = chess.move(san); - if (!result) { - // chess.js lehnt unnötige Disambiguierung ab (z.B. Nge7 wenn Nc6 gepinnt) - 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'); - } -} - -/** - * 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 = '● Laufen'; - } 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 = oppClock || '--:--:--'; - document.getElementById('white-clock').textContent = 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'; - - 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 = ` -
${resultIcon} Runde ${game.round}
-
Lara ${color} vs ${opponent}
-
${game.isLive ? '● Laufen' : game.result}
- `; - - entry.addEventListener('click', () => { - userSelectedGame = true; - currentGame = game; - previousMoveCount = -1; - currentMoveIndex = Number.MAX_SAFE_INTEGER; - 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>/gs); - for (const row of rows) { - if (!row[1].includes('Lara Kiesewetter')) continue; - - const cells = row[1].matchAll(/]*>(.*?)<\/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 = ` -
${data.rank}.
-
Tabellenplatz
-
${data.round_info || 'nach Runde 1'}
-
- Punkte - ${data.points} -
-
- Siege - ${data.wins} -
-
- Unentschieden - ${data.draws} -
-
- Niederlagen - ${data.losses} -
- `; - return; - } - } - document.getElementById('standings-content').innerHTML = '
Daten nicht verfügbar
'; - } catch { - document.getElementById('standings-content').innerHTML = '
Daten nicht verfügbar
'; - } -} - -/** - * Update timestamp - */ -function updateTimestamp() { - const time = new Date(); - document.getElementById('last-update').textContent = - `Letztes Update: ${time.toLocaleTimeString('de-DE')}`; -} - -/** - * Startet Auto-Refresh alle 15 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(); - }, 15000); - - updateTimer = setInterval(() => { - if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); 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); -} - -/** - * 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"]'); @@ -706,12 +30,9 @@ document.querySelectorAll('.copy-pgn-btn').forEach(btn => { }); }); -/** - * Pfeiltasten-Navigation - */ document.addEventListener('keydown', (e) => { if (!currentGame) return; - + if (e.key === 'ArrowLeft') { e.preventDefault(); goToMove(currentMoveIndex - 1); @@ -721,9 +42,6 @@ document.addEventListener('keydown', (e) => { } }); -/** - * Board-Resize bei Fenster- und Container-Änderungen - */ window.addEventListener('resize', () => { if (board) board.resize(); }); @@ -735,9 +53,6 @@ if (window.ResizeObserver) { ro.observe(document.getElementById('board')); } -/** - * Page Visibility API – Timer pausieren wenn Tab unsichtbar - */ document.addEventListener('visibilitychange', () => { if (document.hidden) { clearInterval(pollInterval); @@ -747,12 +62,8 @@ document.addEventListener('visibilitychange', () => { } }); -/** - * Init - */ loadPGN(); startAutoRefresh(); -// Eval-Bar initialisieren chess = new Chess(); -updateEvaluation(); +updateEvaluation(); \ No newline at end of file diff --git a/js/board.js b/js/board.js new file mode 100644 index 0000000..266c347 --- /dev/null +++ b/js/board.js @@ -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(); +} \ No newline at end of file diff --git a/js/data.js b/js/data.js new file mode 100644 index 0000000..300fc0c --- /dev/null +++ b/js/data.js @@ -0,0 +1,169 @@ +/** + * 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)); + } + 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>/gs); + for (const row of rows) { + if (!row[1].includes('Lara Kiesewetter')) continue; + + const cells = row[1].matchAll(/]*>(.*?)<\/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 = ` +
${data.rank}.
+
Tabellenplatz
+
${data.round_info || 'nach Runde 1'}
+
+ Punkte + ${data.points} +
+
+ Siege + ${data.wins} +
+
+ Unentschieden + ${data.draws} +
+
+ Niederlagen + ${data.losses} +
+ `; + return; + } + } + document.getElementById('standings-content').innerHTML = '
Daten nicht verfügbar
'; + } catch { + document.getElementById('standings-content').innerHTML = '
Daten nicht verfügbar
'; + } +} + +function updateTimestamp() { + const time = new Date(); + document.getElementById('last-update').textContent = + `Letztes Update: ${time.toLocaleTimeString('de-DE')}`; +} + +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'; + + loadPGN(true); + updateStandings(); + + 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); +} \ No newline at end of file diff --git a/js/evaluation.js b/js/evaluation.js new file mode 100644 index 0000000..f4e9890 --- /dev/null +++ b/js/evaluation.js @@ -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) + '%'; +} \ No newline at end of file diff --git a/js/pgn-parser.js b/js/pgn-parser.js index 8641993..9def39f 100644 --- a/js/pgn-parser.js +++ b/js/pgn-parser.js @@ -120,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; @@ -134,4 +142,5 @@ function getLatestGame(laraGames) { window.parsePGN = parsePGN; window.filterLaraGames = filterLaraGames; window.getLiveGame = getLiveGame; +window.getTodaysGames = getTodaysGames; window.getLatestGame = getLatestGame; diff --git a/js/state.js b/js/state.js new file mode 100644 index 0000000..7d12a69 --- /dev/null +++ b/js/state.js @@ -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; \ No newline at end of file diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..95bf2eb --- /dev/null +++ b/js/ui.js @@ -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 = '● Laufen'; + } 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 = ` +
${resultIcon} Runde ${game.round}
+
Lara ${color} vs ${opponent}
+
${game.isLive ? '● Laufen' : game.result}
+ `; + + entry.addEventListener('click', () => { + userSelectedGame = true; + currentGame = game; + previousMoveCount = -1; + currentMoveIndex = Number.MAX_SAFE_INTEGER; + updateBoard(); + updatePlayerInfo(); + updateMovesList(); + updateAllGamesList(); + updatePGNDisplay(); + }); + + list.appendChild(entry); + } +} \ No newline at end of file