import { Chess } from "./../../../libs/chess.js"; import { Classification } from "../../classification/MoveClassifier.js"; export class MoveTree { constructor() { this.mainline = [{ id: 'root', moveNumber: 1, san: null, fen: null, children: [], isMainline: true }]; this.nodeMap = new Map().set('root', this.mainline[0]); this.currentNode = this.mainline[0]; this.final = null; this.idCounter = 0; this.currentIndex = 0; this.clockData = new Map(); // Store clock data for each position } /** * Extracts clock annotations from PGN string * @param {string} pgn - The PGN string * @returns {Array} Array of clock data objects */ extractClockAnnotations(pgn) { const clockData = []; // Remove header from PGN to get only the moves const headerRegex = /^\[[\s\S]*?\]\s*$/gm; const moveText = pgn.replace(headerRegex, '').trim(); // Find all clock annotations in the format {[%clk 0:03:00.9]} const clockRegex = /\{[^}]*\[%clk\s+([^\]]+)\][^}]*\}/g; const clockMatches = [...moveText.matchAll(clockRegex)]; if (clockMatches.length === 0) { return clockData; } // Extract moves with their positions const moveRegex = /\d+\.\s*([^\s{]+)(?:\s+\{[^}]*\})?\s*([^\s{]+)?(?:\s+\{[^}]*\})?/g; let moveIndex = 0; let clockIndex = 0; let match; while ((match = moveRegex.exec(moveText)) !== null) { const whiteMove = match[1]; const blackMove = match[2]; if (whiteMove) { // Check if there's a clock annotation after this white move const whiteMovePos = match.index + match[0].indexOf(whiteMove); const nextClockMatch = clockMatches[clockIndex]; if (nextClockMatch && nextClockMatch.index > whiteMovePos) { // Find the next clock annotation that comes after this move let foundClock = false; for (let i = clockIndex; i < clockMatches.length; i++) { if (clockMatches[i].index > whiteMovePos) { clockData[moveIndex] = { moveIndex: moveIndex, color: 'white', clock: clockMatches[i][1].trim() }; clockIndex = i + 1; foundClock = true; break; } } } moveIndex++; } if (blackMove) { // Check if there's a clock annotation after this black move const blackMovePos = match.index + match[0].indexOf(blackMove); const nextClockMatch = clockMatches[clockIndex]; if (nextClockMatch && nextClockMatch.index > blackMovePos) { // Find the next clock annotation that comes after this move let foundClock = false; for (let i = clockIndex; i < clockMatches.length; i++) { if (clockMatches[i].index > blackMovePos) { clockData[moveIndex] = { moveIndex: moveIndex, color: 'black', clock: clockMatches[i][1].trim() }; clockIndex = i + 1; foundClock = true; break; } } } moveIndex++; } } return clockData; } buildFromPGN(pgn, chess) { // Use the provided chess instance or create a new one const chessInstance = chess || new Chess(); // Extract clock annotations before PGN parsing const clockData = this.extractClockAnnotations(pgn); chessInstance.loadPgn(pgn); const history = chessInstance.history({ verbose: true }); chessInstance.reset(); this.mainline = [{ id: 'root', moveNumber: null, san: null, fen: chessInstance.fen(), children: [], isMainline: true }]; this.nodeMap = new Map().set('root', this.mainline[0]); this.currentNode = this.mainline[0]; this.currentIndex = 0; let moveNumber = 1, isWhiteTurn = true; for (const [index, move] of history.entries()) { const nodeId = `move_${moveNumber}_${isWhiteTurn ? 'w' : 'b'}_${move.san.replace(/[+#]/g, m => m === '+' ? 'check' : 'mate')}`; const node = { id: nodeId, moveNumber: isWhiteTurn ? moveNumber : moveNumber + 0.5, san: move.san, move: move, fen: null, children: [], isMainline: true, parentIndex: this.mainline.length - 1 }; // Add clock data if available const clockInfo = clockData.find(c => c.moveIndex === index); if (clockInfo) { node.clock = clockInfo.clock; } chessInstance.move(move.lan); node.fen = chessInstance.fen(); this.mainline.push(node); this.nodeMap.set(node.id, node); if (!isWhiteTurn) moveNumber++; isWhiteTurn = !isWhiteTurn; this.final = node; } this.currentNode = this.mainline[0]; return history; } updateClassification(nodeId, move) { const node = this.nodeMap.get(nodeId); if (!node) return; node.classification = move.classification.type; node.evaluatedMove = move; if (move.lines?.length > 0) { const topLine = move.lines.find(line => line.id === 1); if (topLine) { node.evalScore = topLine.score; node.evalType = topLine.type || 'cp'; } } } findExistingMove(parentNode, move) { if (parentNode.isMainline) { const parentIndex = this.getNodeIndex(parentNode); if (parentIndex !== -1 && parentIndex + 1 < this.mainline.length) { const nextMove = this.mainline[parentIndex + 1]; if (nextMove.move && nextMove.move.from === move.from && nextMove.move.to === move.to && nextMove.move.promotion === move.promotion) { return nextMove; } } } return parentNode.children.find(child => child.move?.from === move.from && child.move?.to === move.to && child.move?.promotion === move.promotion ); } getNodeIndex(node) { return this.mainline.findIndex(n => n.id === node.id); } addMove(move, parentId) { const chess = new Chess(); const parent = this.nodeMap.get(parentId); if (!parent) return null; const existingNode = this.findExistingMove(parent, move); if (existingNode) return existingNode; const originalFen = chess.fen(); chess.load(parent.fen); const isWhiteTurn = chess.turn() === 'w'; const moveNumber = chess.moveNumber(); const parentIndex = this.getNodeIndex(parent); const isMainline = parent.isMainline && parentIndex === this.mainline.length - 1; this.idCounter++; let nodeId = `${isMainline ? 'move' : 'var'}_${moveNumber}_${isWhiteTurn ? 'w' : 'b'}_${move.san.replace(/[+#]/g, m => m === '+' ? 'check' : 'mate')}${isMainline ? '' : '_' + this.idCounter}`; if (isMainline && this.nodeMap.has(nodeId)) { nodeId = `${nodeId}_${this.idCounter}`; } const node = { id: nodeId, moveNumber: isWhiteTurn ? moveNumber : moveNumber + 0.5, san: move.san, move: move, fen: null, children: [], isMainline: isMainline, parentId: parentId, }; chess.move(move); node.fen = chess.fen(); if (isMainline) { node.parentIndex = parentIndex; this.mainline.push(node); this.final = node; } else { parent.children.push(node); } this.nodeMap.set(node.id, node); chess.load(originalFen); return node; } navigateTo(nodeId) { const node = this.nodeMap.get(nodeId); if (node) { this.currentNode = node; const nodeIndex = this.getNodeIndex(node); if (nodeIndex !== -1) this.currentIndex = nodeIndex; return node; } return null; } getNextMove() { if (!this.currentNode) return null; const currentIndex = this.getNodeIndex(this.currentNode); if (currentIndex !== -1 && currentIndex < this.mainline.length - 1) { return this.mainline[currentIndex + 1]; } return this.currentNode.children[0] || null; } getPreviousMove() { if (this.currentNode === this.mainline[0]) return null; const currentIndex = this.getNodeIndex(this.currentNode); if (currentIndex > 0) return this.mainline[currentIndex - 1]; return this.currentNode.parentId ? this.nodeMap.get(this.currentNode.parentId) : null; } getPathToNode(nodeId) { const node = this.nodeMap.get(nodeId); if (!node) return []; const nodeIndex = this.getNodeIndex(node); if (nodeIndex !== -1) { return this.mainline.slice(1, nodeIndex + 1); } const path = []; let current = node; while (current && current !== this.mainline[0]) { path.unshift(current); current = current.parentId ? this.nodeMap.get(current.parentId) : null; } return path; } getFinalMove() { return this.final; } /** * Gets an array of moves (with san notation) from root to the specified node * @param {string} nodeId - The target node ID * @returns {Array} Array of move objects with san property */ getMovesToNode(nodeId) { const path = this.getPathToNode(nodeId); return path .filter(node => node.move && node.san) .map(node => ({ san: node.san, move: node.move })); } render(containerId, clickHandler) { const $container = $(`#${containerId}`); if (!$container.length) return; $container.empty(); const $mainLine = $('