Move History - RumenDamyanov/js-chess GitHub Wiki
Comprehensive guide to implementing and displaying chess move history across all framework implementations.
Move history is a crucial component of any chess application, providing:
- Complete game record in algebraic notation
- Navigation through moves (forward/backward)
- Move analysis and annotations
- Export capabilities (PGN format)
- Visual move highlighting
- Statistical analysis
// shared/models/Move.js
export class ChessMove {
constructor(data) {
this.from = data.from; // e.g., 'e2'
this.to = data.to; // e.g., 'e4'
this.piece = data.piece; // e.g., 'P' (white pawn)
this.captured = data.captured; // e.g., 'p' (black pawn) or null
this.promotion = data.promotion; // e.g., 'Q' or null
this.castling = data.castling; // 'kingside', 'queenside', or null
this.enPassant = data.enPassant || false;
this.check = data.check || false;
this.checkmate = data.checkmate || false;
this.stalemate = data.stalemate || false;
this.timestamp = data.timestamp || new Date();
this.moveNumber = data.moveNumber;
this.annotation = data.annotation || '';
this.evaluation = data.evaluation || null; // For engine analysis
}
// Convert to algebraic notation
toAlgebraic() {
if (this.castling) {
return this.castling === 'kingside' ? 'O-O' : 'O-O-O';
}
let notation = '';
// Piece notation (except pawns)
if (this.piece.toUpperCase() !== 'P') {
notation += this.piece.toUpperCase();
}
// Capture notation
if (this.captured) {
if (this.piece.toUpperCase() === 'P') {
notation += this.from[0]; // File of capturing pawn
}
notation += 'x';
}
// Destination square
notation += this.to;
// Promotion
if (this.promotion) {
notation += '=' + this.promotion.toUpperCase();
}
// En passant
if (this.enPassant) {
notation += ' e.p.';
}
// Check/checkmate
if (this.checkmate) {
notation += '#';
} else if (this.check) {
notation += '+';
}
return notation;
}
// Convert to long algebraic notation
toLongAlgebraic() {
if (this.castling) {
return this.castling === 'kingside' ? 'O-O' : 'O-O-O';
}
let notation = '';
// Piece
if (this.piece.toUpperCase() !== 'P') {
notation += this.piece.toUpperCase();
}
// Origin square
notation += this.from;
// Capture or move
notation += this.captured ? 'x' : '-';
// Destination
notation += this.to;
// Promotion
if (this.promotion) {
notation += '=' + this.promotion.toUpperCase();
}
// Check/checkmate
if (this.checkmate) {
notation += '#';
} else if (this.check) {
notation += '+';
}
return notation;
}
// Clone move
clone() {
return new ChessMove(this);
}
}
// shared/services/MoveHistoryManager.js
export class MoveHistoryManager {
constructor() {
this.moves = [];
this.currentMoveIndex = -1;
this.variations = new Map(); // For move variations/branches
this.annotations = new Map(); // Move annotations
}
addMove(move) {
// If we're not at the end of history, truncate future moves
if (this.currentMoveIndex < this.moves.length - 1) {
this.moves = this.moves.slice(0, this.currentMoveIndex + 1);
}
// Add the new move
const moveNumber = Math.floor(this.moves.length / 2) + 1;
const chessMove = new ChessMove({
...move,
moveNumber,
timestamp: new Date()
});
this.moves.push(chessMove);
this.currentMoveIndex = this.moves.length - 1;
return chessMove;
}
goToMove(index) {
if (index >= -1 && index < this.moves.length) {
this.currentMoveIndex = index;
return true;
}
return false;
}
goToStart() {
return this.goToMove(-1);
}
goToEnd() {
return this.goToMove(this.moves.length - 1);
}
goBack() {
return this.goToMove(this.currentMoveIndex - 1);
}
goForward() {
return this.goToMove(this.currentMoveIndex + 1);
}
getCurrentMove() {
if (this.currentMoveIndex >= 0 && this.currentMoveIndex < this.moves.length) {
return this.moves[this.currentMoveIndex];
}
return null;
}
getAllMoves() {
return [...this.moves];
}
getMovesUpToCurrent() {
return this.moves.slice(0, this.currentMoveIndex + 1);
}
canGoBack() {
return this.currentMoveIndex > -1;
}
canGoForward() {
return this.currentMoveIndex < this.moves.length - 1;
}
clear() {
this.moves = [];
this.currentMoveIndex = -1;
this.variations.clear();
this.annotations.clear();
}
// Get formatted move list
getFormattedMoves(format = 'algebraic') {
const formatted = [];
for (let i = 0; i < this.moves.length; i += 2) {
const whiteMove = this.moves[i];
const blackMove = this.moves[i + 1];
const moveNumber = whiteMove.moveNumber;
let moveText = `${moveNumber}. `;
if (format === 'algebraic') {
moveText += whiteMove.toAlgebraic();
if (blackMove) {
moveText += ` ${blackMove.toAlgebraic()}`;
}
} else if (format === 'long') {
moveText += whiteMove.toLongAlgebraic();
if (blackMove) {
moveText += ` ${blackMove.toLongAlgebraic()}`;
}
}
formatted.push({
moveNumber,
whiteMove,
blackMove,
text: moveText,
isCurrentWhite: i === this.currentMoveIndex,
isCurrentBlack: blackMove && (i + 1) === this.currentMoveIndex
});
}
return formatted;
}
// Export to PGN format
toPGN(gameInfo = {}) {
const headers = {
Event: gameInfo.event || '?',
Site: gameInfo.site || '?',
Date: gameInfo.date || new Date().toISOString().split('T')[0],
Round: gameInfo.round || '?',
White: gameInfo.white || '?',
Black: gameInfo.black || '?',
Result: gameInfo.result || '*'
};
let pgn = '';
// Add headers
Object.entries(headers).forEach(([key, value]) => {
pgn += `[${key} "${value}"]\n`;
});
pgn += '\n';
// Add moves
const formattedMoves = this.getFormattedMoves('algebraic');
formattedMoves.forEach(move => {
pgn += move.text + ' ';
});
// Add result
pgn += headers.Result;
return pgn.trim();
}
// Import from PGN
fromPGN(pgnText) {
this.clear();
// Basic PGN parsing (simplified)
const lines = pgnText.split('\n');
let moveText = '';
// Skip headers and find move text
let inMoves = false;
for (const line of lines) {
if (line.trim() === '') {
inMoves = true;
continue;
}
if (inMoves && !line.startsWith('[')) {
moveText += line + ' ';
}
}
// Parse moves (basic implementation)
const movePattern = /\d+\.\s*([^\s]+)(?:\s+([^\s]+))?/g;
let match;
while ((match = movePattern.exec(moveText)) !== null) {
const whiteMove = this.parseAlgebraicMove(match[1]);
if (whiteMove) {
this.addMove(whiteMove);
}
if (match[2] && !match[2].includes('*') && !match[2].includes('-')) {
const blackMove = this.parseAlgebraicMove(match[2]);
if (blackMove) {
this.addMove(blackMove);
}
}
}
this.goToEnd();
}
parseAlgebraicMove(notation) {
// Simplified algebraic notation parsing
// This would need to be more comprehensive for production use
// Remove check/checkmate indicators
const clean = notation.replace(/[+#]$/, '');
// Handle castling
if (clean === 'O-O') {
return { castling: 'kingside' };
}
if (clean === 'O-O-O') {
return { castling: 'queenside' };
}
// Basic move parsing (needs expansion)
const moveMatch = clean.match(/^([NBRQK])?([a-h]?[1-8]?)(x)?([a-h][1-8])(=([NBRQ]))?$/);
if (moveMatch) {
return {
piece: moveMatch[1] || 'P',
to: moveMatch[4],
captured: !!moveMatch[3],
promotion: moveMatch[6] || null
};
}
return null;
}
// Add variation/branch
addVariation(moveIndex, moves) {
if (!this.variations.has(moveIndex)) {
this.variations.set(moveIndex, []);
}
this.variations.get(moveIndex).push(moves);
}
// Add annotation to move
addAnnotation(moveIndex, annotation) {
this.annotations.set(moveIndex, annotation);
}
getAnnotation(moveIndex) {
return this.annotations.get(moveIndex) || '';
}
}
// angular-chess/src/app/components/move-history/move-history.component.ts
import { Component, Input, Output, EventEmitter, OnChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MoveHistoryManager, ChessMove } from '../../../shared/services/MoveHistoryManager';
@Component({
selector: 'app-move-history',
standalone: true,
imports: [CommonModule],
template: `
<div class="move-history">
<div class="move-history-header">
<h3>Move History</h3>
<div class="move-controls">
<button
(click)="goToStart()"
[disabled]="!canGoBack()"
class="control-btn">
⏮
</button>
<button
(click)="goBack()"
[disabled]="!canGoBack()"
class="control-btn">
◀
</button>
<button
(click)="goForward()"
[disabled]="!canGoForward()"
class="control-btn">
▶
</button>
<button
(click)="goToEnd()"
[disabled]="!canGoForward()"
class="control-btn">
⏭
</button>
</div>
</div>
<div class="move-list" #moveList>
<div
*ngFor="let move of formattedMoves; index as i"
class="move-pair"
[class.has-current]="move.isCurrentWhite || move.isCurrentBlack">
<span class="move-number">{{ move.moveNumber }}.</span>
<button
class="move-button white-move"
[class.current]="move.isCurrentWhite"
(click)="goToMoveIndex(i * 2)">
{{ move.whiteMove.toAlgebraic() }}
</button>
<button
*ngIf="move.blackMove"
class="move-button black-move"
[class.current]="move.isCurrentBlack"
(click)="goToMoveIndex(i * 2 + 1)">
{{ move.blackMove.toAlgebraic() }}
</button>
<!-- Move annotations -->
<span
*ngIf="getAnnotation(i * 2)"
class="move-annotation">
{{ getAnnotation(i * 2) }}
</span>
</div>
</div>
<div class="move-history-footer">
<div class="move-count">
Moves: {{ moveCount }}
</div>
<div class="export-controls">
<button (click)="exportPGN()" class="export-btn">
Export PGN
</button>
<button (click)="copyMoves()" class="export-btn">
Copy Moves
</button>
</div>
</div>
</div>
`,
styleUrls: ['./move-history.component.css']
})
export class MoveHistoryComponent implements OnChanges {
@Input() historyManager!: MoveHistoryManager;
@Input() currentMoveIndex: number = -1;
@Output() moveSelected = new EventEmitter<number>();
@Output() navigationRequest = new EventEmitter<'start' | 'back' | 'forward' | 'end'>();
formattedMoves: any[] = [];
moveCount = 0;
ngOnChanges() {
if (this.historyManager) {
this.updateDisplay();
}
}
updateDisplay() {
this.formattedMoves = this.historyManager.getFormattedMoves();
this.moveCount = this.historyManager.getAllMoves().length;
}
goToMoveIndex(index: number) {
this.moveSelected.emit(index);
}
goToStart() {
this.navigationRequest.emit('start');
}
goBack() {
this.navigationRequest.emit('back');
}
goForward() {
this.navigationRequest.emit('forward');
}
goToEnd() {
this.navigationRequest.emit('end');
}
canGoBack(): boolean {
return this.historyManager?.canGoBack() || false;
}
canGoForward(): boolean {
return this.historyManager?.canGoForward() || false;
}
getAnnotation(moveIndex: number): string {
return this.historyManager?.getAnnotation(moveIndex) || '';
}
exportPGN() {
const pgn = this.historyManager.toPGN({
event: 'Chess Game',
site: 'Web Browser',
white: 'Player 1',
black: 'Player 2'
});
this.downloadFile(pgn, 'game.pgn', 'text/plain');
}
copyMoves() {
const moves = this.formattedMoves.map(m => m.text).join(' ');
navigator.clipboard.writeText(moves).then(() => {
console.log('Moves copied to clipboard');
});
}
private downloadFile(content: string, filename: string, contentType: string) {
const blob = new Blob([content], { type: contentType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
}
// react-chess/src/components/MoveHistory.jsx
import React, { useMemo, useCallback } from 'react';
import { MoveHistoryManager } from '../../shared/services/MoveHistoryManager';
import './MoveHistory.css';
const MoveHistory = ({
historyManager,
currentMoveIndex,
onMoveSelected,
onNavigationRequest
}) => {
const formattedMoves = useMemo(() => {
return historyManager ? historyManager.getFormattedMoves() : [];
}, [historyManager, currentMoveIndex]);
const moveCount = useMemo(() => {
return historyManager ? historyManager.getAllMoves().length : 0;
}, [historyManager, currentMoveIndex]);
const canGoBack = useMemo(() => {
return historyManager ? historyManager.canGoBack() : false;
}, [historyManager, currentMoveIndex]);
const canGoForward = useMemo(() => {
return historyManager ? historyManager.canGoForward() : false;
}, [historyManager, currentMoveIndex]);
const handleMoveClick = useCallback((index) => {
onMoveSelected?.(index);
}, [onMoveSelected]);
const handleNavigation = useCallback((direction) => {
onNavigationRequest?.(direction);
}, [onNavigationRequest]);
const exportPGN = useCallback(() => {
if (!historyManager) return;
const pgn = historyManager.toPGN({
event: 'Chess Game',
site: 'Web Browser',
white: 'Player 1',
black: 'Player 2'
});
downloadFile(pgn, 'game.pgn', 'text/plain');
}, [historyManager]);
const copyMoves = useCallback(async () => {
if (!historyManager) return;
const moves = formattedMoves.map(m => m.text).join(' ');
try {
await navigator.clipboard.writeText(moves);
console.log('Moves copied to clipboard');
} catch (err) {
console.error('Failed to copy moves:', err);
}
}, [formattedMoves]);
const downloadFile = (content, filename, contentType) => {
const blob = new Blob([content], { type: contentType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
};
return (
<div className="move-history">
<div className="move-history-header">
<h3>Move History</h3>
<div className="move-controls">
<button
onClick={() => handleNavigation('start')}
disabled={!canGoBack}
className="control-btn">
⏮
</button>
<button
onClick={() => handleNavigation('back')}
disabled={!canGoBack}
className="control-btn">
◀
</button>
<button
onClick={() => handleNavigation('forward')}
disabled={!canGoForward}
className="control-btn">
▶
</button>
<button
onClick={() => handleNavigation('end')}
disabled={!canGoForward}
className="control-btn">
⏭
</button>
</div>
</div>
<div className="move-list">
{formattedMoves.map((move, i) => (
<div
key={i}
className={`move-pair ${move.isCurrentWhite || move.isCurrentBlack ? 'has-current' : ''}`}>
<span className="move-number">{move.moveNumber}.</span>
<button
className={`move-button white-move ${move.isCurrentWhite ? 'current' : ''}`}
onClick={() => handleMoveClick(i * 2)}>
{move.whiteMove.toAlgebraic()}
</button>
{move.blackMove && (
<button
className={`move-button black-move ${move.isCurrentBlack ? 'current' : ''}`}
onClick={() => handleMoveClick(i * 2 + 1)}>
{move.blackMove.toAlgebraic()}
</button>
)}
{historyManager?.getAnnotation(i * 2) && (
<span className="move-annotation">
{historyManager.getAnnotation(i * 2)}
</span>
)}
</div>
))}
</div>
<div className="move-history-footer">
<div className="move-count">
Moves: {moveCount}
</div>
<div className="export-controls">
<button onClick={exportPGN} className="export-btn">
Export PGN
</button>
<button onClick={copyMoves} className="export-btn">
Copy Moves
</button>
</div>
</div>
</div>
);
};
export default MoveHistory;
<!-- vue-chess/src/components/MoveHistory.vue -->
<template>
<div class="move-history">
<div class="move-history-header">
<h3>Move History</h3>
<div class="move-controls">
<button
@click="handleNavigation('start')"
:disabled="!canGoBack"
class="control-btn">
⏮
</button>
<button
@click="handleNavigation('back')"
:disabled="!canGoBack"
class="control-btn">
◀
</button>
<button
@click="handleNavigation('forward')"
:disabled="!canGoForward"
class="control-btn">
▶
</button>
<button
@click="handleNavigation('end')"
:disabled="!canGoForward"
class="control-btn">
⏭
</button>
</div>
</div>
<div class="move-list">
<div
v-for="(move, i) in formattedMoves"
:key="i"
class="move-pair"
:class="{ 'has-current': move.isCurrentWhite || move.isCurrentBlack }">
<span class="move-number">{{ move.moveNumber }}.</span>
<button
class="move-button white-move"
:class="{ current: move.isCurrentWhite }"
@click="handleMoveClick(i * 2)">
{{ move.whiteMove.toAlgebraic() }}
</button>
<button
v-if="move.blackMove"
class="move-button black-move"
:class="{ current: move.isCurrentBlack }"
@click="handleMoveClick(i * 2 + 1)">
{{ move.blackMove.toAlgebraic() }}
</button>
<span
v-if="getAnnotation(i * 2)"
class="move-annotation">
{{ getAnnotation(i * 2) }}
</span>
</div>
</div>
<div class="move-history-footer">
<div class="move-count">
Moves: {{ moveCount }}
</div>
<div class="export-controls">
<button @click="exportPGN" class="export-btn">
Export PGN
</button>
<button @click="copyMoves" class="export-btn">
Copy Moves
</button>
</div>
</div>
</div>
</template>
<script>
import { computed } from 'vue';
export default {
name: 'MoveHistory',
props: {
historyManager: Object,
currentMoveIndex: {
type: Number,
default: -1
}
},
emits: ['move-selected', 'navigation-request'],
setup(props, { emit }) {
const formattedMoves = computed(() => {
return props.historyManager ? props.historyManager.getFormattedMoves() : [];
});
const moveCount = computed(() => {
return props.historyManager ? props.historyManager.getAllMoves().length : 0;
});
const canGoBack = computed(() => {
return props.historyManager ? props.historyManager.canGoBack() : false;
});
const canGoForward = computed(() => {
return props.historyManager ? props.historyManager.canGoForward() : false;
});
const handleMoveClick = (index) => {
emit('move-selected', index);
};
const handleNavigation = (direction) => {
emit('navigation-request', direction);
};
const getAnnotation = (moveIndex) => {
return props.historyManager?.getAnnotation(moveIndex) || '';
};
const exportPGN = () => {
if (!props.historyManager) return;
const pgn = props.historyManager.toPGN({
event: 'Chess Game',
site: 'Web Browser',
white: 'Player 1',
black: 'Player 2'
});
downloadFile(pgn, 'game.pgn', 'text/plain');
};
const copyMoves = async () => {
if (!props.historyManager) return;
const moves = formattedMoves.value.map(m => m.text).join(' ');
try {
await navigator.clipboard.writeText(moves);
console.log('Moves copied to clipboard');
} catch (err) {
console.error('Failed to copy moves:', err);
}
};
const downloadFile = (content, filename, contentType) => {
const blob = new Blob([content], { type: contentType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
};
return {
formattedMoves,
moveCount,
canGoBack,
canGoForward,
handleMoveClick,
handleNavigation,
getAnnotation,
exportPGN,
copyMoves
};
}
};
</script>
<style scoped src="./MoveHistory.css"></style>
/* shared/styles/move-history.css */
.move-history {
width: 100%;
max-width: 400px;
height: 100%;
display: flex;
flex-direction: column;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.move-history-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--color-surface-variant);
border-bottom: 1px solid var(--color-border);
}
.move-history-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-on-surface);
}
.move-controls {
display: flex;
gap: 0.25rem;
}
.control-btn {
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
transition: all var(--transition-fast);
}
.control-btn:hover:not(:disabled) {
background: var(--color-primary);
color: var(--color-on-primary);
}
.control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.move-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.move-pair {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: background-color var(--transition-fast);
}
.move-pair:hover {
background: var(--color-surface-variant);
}
.move-pair.has-current {
background: var(--color-primary-container);
}
.move-number {
min-width: 2rem;
font-weight: 600;
color: var(--color-on-surface-variant);
font-size: 0.9rem;
}
.move-button {
min-width: 3rem;
height: 24px;
padding: 0 0.5rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: 4px;
cursor: pointer;
font-family: monospace;
font-size: 0.85rem;
transition: all var(--transition-fast);
}
.move-button:hover {
background: var(--color-surface-variant);
}
.move-button.current {
background: var(--color-primary);
color: var(--color-on-primary);
border-color: var(--color-primary);
font-weight: 600;
}
.white-move {
color: var(--color-on-surface);
}
.black-move {
color: var(--color-on-surface-variant);
}
.move-annotation {
font-size: 0.75rem;
color: var(--color-primary);
font-style: italic;
margin-left: 0.5rem;
}
.move-history-footer {
padding: 1rem;
background: var(--color-surface-variant);
border-top: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.move-count {
font-size: 0.9rem;
color: var(--color-on-surface-variant);
}
.export-controls {
display: flex;
gap: 0.5rem;
}
.export-btn {
padding: 0.25rem 0.75rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
transition: all var(--transition-fast);
}
.export-btn:hover {
background: var(--color-primary);
color: var(--color-on-primary);
}
/* Responsive design */
@media (max-width: 768px) {
.move-history {
max-width: 100%;
}
.move-history-header {
padding: 0.75rem;
}
.move-controls {
gap: 0.125rem;
}
.control-btn {
width: 28px;
height: 28px;
font-size: 0.8rem;
}
.move-pair {
padding: 0.25rem;
gap: 0.25rem;
}
.move-button {
min-width: 2.5rem;
height: 20px;
font-size: 0.75rem;
}
.move-history-footer {
padding: 0.75rem;
flex-direction: column;
gap: 0.5rem;
}
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.move-history {
background: var(--color-surface-dark);
border-color: var(--color-border-dark);
}
.move-history-header,
.move-history-footer {
background: var(--color-surface-variant-dark);
border-color: var(--color-border-dark);
}
.control-btn,
.move-button,
.export-btn {
background: var(--color-surface-dark);
border-color: var(--color-border-dark);
color: var(--color-on-surface-dark);
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.move-button.current {
border-width: 2px;
font-weight: 700;
}
.move-pair.has-current {
border: 2px solid var(--color-primary);
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.move-button,
.control-btn,
.export-btn,
.move-pair {
transition: none;
}
}
// shared/services/MoveAnalysis.js
export class MoveAnalysis {
constructor(historyManager, chessEngine) {
this.historyManager = historyManager;
this.chessEngine = chessEngine;
this.analysisCache = new Map();
}
async analyzeMove(moveIndex) {
const cacheKey = `${moveIndex}`;
if (this.analysisCache.has(cacheKey)) {
return this.analysisCache.get(cacheKey);
}
const moves = this.historyManager.getMovesUpToCurrent();
const position = this.chessEngine.getPositionAfterMoves(moves.slice(0, moveIndex));
try {
const analysis = await this.chessEngine.analyzePosition(position, {
depth: 15,
multiPV: 3
});
this.analysisCache.set(cacheKey, analysis);
return analysis;
} catch (error) {
console.error('Move analysis failed:', error);
return null;
}
}
async analyzeGame() {
const moves = this.historyManager.getAllMoves();
const analysis = {
accuracy: { white: 0, black: 0 },
blunders: [],
mistakes: [],
inaccuracies: [],
bestMoves: [],
openingName: '',
endgameClassification: ''
};
for (let i = 0; i < moves.length; i++) {
const moveAnalysis = await this.analyzeMove(i);
if (moveAnalysis) {
this.categorizeMove(i, moveAnalysis, analysis);
}
}
return analysis;
}
categorizeMove(moveIndex, analysis, gameAnalysis) {
const evaluationDrop = this.calculateEvaluationDrop(analysis);
if (evaluationDrop > 300) {
gameAnalysis.blunders.push({
moveIndex,
evaluationDrop,
bestMove: analysis.bestMove
});
} else if (evaluationDrop > 100) {
gameAnalysis.mistakes.push({
moveIndex,
evaluationDrop,
bestMove: analysis.bestMove
});
} else if (evaluationDrop > 50) {
gameAnalysis.inaccuracies.push({
moveIndex,
evaluationDrop,
bestMove: analysis.bestMove
});
}
}
calculateEvaluationDrop(analysis) {
// Calculate how much the evaluation dropped after the move
return Math.abs(analysis.evaluationBefore - analysis.evaluationAfter);
}
getOpeningName(moves) {
// Look up opening name based on move sequence
// This would use an opening database
return 'Sicilian Defense';
}
}
// shared/components/MoveTree.js
export class MoveTree {
constructor(historyManager) {
this.historyManager = historyManager;
this.tree = this.buildTree();
}
buildTree() {
const root = { move: null, children: [], parent: null };
let current = root;
const moves = this.historyManager.getAllMoves();
moves.forEach(move => {
const node = {
move,
children: [],
parent: current,
evaluation: move.evaluation,
annotation: move.annotation
};
current.children.push(node);
current = node;
});
return root;
}
addVariation(afterMoveIndex, variationMoves) {
// Add alternative line from a specific point
const targetNode = this.findNode(afterMoveIndex);
if (targetNode) {
let current = targetNode;
variationMoves.forEach(move => {
const node = {
move,
children: [],
parent: current,
isVariation: true
};
current.children.push(node);
current = node;
});
}
}
findNode(moveIndex) {
// Find node at specific move index
let current = this.tree;
let index = 0;
while (current.children.length > 0 && index <= moveIndex) {
current = current.children[0]; // Follow main line
if (index === moveIndex) {
return current;
}
index++;
}
return null;
}
renderTree() {
// Return tree structure for visualization
return this.renderNode(this.tree, 0);
}
renderNode(node, depth) {
if (!node.move) {
// Root node
return {
type: 'root',
children: node.children.map(child => this.renderNode(child, depth + 1))
};
}
return {
type: 'move',
move: node.move.toAlgebraic(),
evaluation: node.evaluation,
annotation: node.annotation,
depth,
isVariation: node.isVariation || false,
children: node.children.map(child => this.renderNode(child, depth + 1))
};
}
}
// shared/__tests__/MoveHistoryManager.test.js
import { MoveHistoryManager, ChessMove } from '../services/MoveHistoryManager';
describe('MoveHistoryManager', () => {
let manager;
beforeEach(() => {
manager = new MoveHistoryManager();
});
test('should add moves and track current position', () => {
const move1 = { from: 'e2', to: 'e4', piece: 'P' };
const move2 = { from: 'e7', to: 'e5', piece: 'p' };
manager.addMove(move1);
manager.addMove(move2);
expect(manager.getAllMoves()).toHaveLength(2);
expect(manager.currentMoveIndex).toBe(1);
});
test('should navigate through move history', () => {
const moves = [
{ from: 'e2', to: 'e4', piece: 'P' },
{ from: 'e7', to: 'e5', piece: 'p' },
{ from: 'g1', to: 'f3', piece: 'N' }
];
moves.forEach(move => manager.addMove(move));
expect(manager.canGoBack()).toBe(true);
expect(manager.canGoForward()).toBe(false);
manager.goBack();
expect(manager.currentMoveIndex).toBe(1);
expect(manager.canGoForward()).toBe(true);
manager.goToStart();
expect(manager.currentMoveIndex).toBe(-1);
expect(manager.canGoBack()).toBe(false);
});
test('should truncate history when adding move mid-game', () => {
const moves = [
{ from: 'e2', to: 'e4', piece: 'P' },
{ from: 'e7', to: 'e5', piece: 'p' },
{ from: 'g1', to: 'f3', piece: 'N' }
];
moves.forEach(move => manager.addMove(move));
// Go back to first move
manager.goToMove(0);
// Add different move
const newMove = { from: 'd2', to: 'd4', piece: 'P' };
manager.addMove(newMove);
expect(manager.getAllMoves()).toHaveLength(2);
expect(manager.getAllMoves()[1].to).toBe('d4');
});
test('should format moves correctly', () => {
const moves = [
{ from: 'e2', to: 'e4', piece: 'P' },
{ from: 'e7', to: 'e5', piece: 'p' },
{ from: 'g1', to: 'f3', piece: 'N' },
{ from: 'b8', to: 'c6', piece: 'n' }
];
moves.forEach(move => manager.addMove(move));
const formatted = manager.getFormattedMoves();
expect(formatted).toHaveLength(2);
expect(formatted[0].text).toContain('1. e4 e5');
expect(formatted[1].text).toContain('2. Nf3 Nc6');
});
test('should export to PGN format', () => {
const moves = [
{ from: 'e2', to: 'e4', piece: 'P' },
{ from: 'e7', to: 'e5', piece: 'p' }
];
moves.forEach(move => manager.addMove(move));
const pgn = manager.toPGN({
white: 'Player 1',
black: 'Player 2',
result: '1-0'
});
expect(pgn).toContain('[White "Player 1"]');
expect(pgn).toContain('[Black "Player 2"]');
expect(pgn).toContain('1. e4 e5');
expect(pgn).toContain('1-0');
});
});
// shared/__tests__/move-history-integration.test.js
import { MoveHistoryManager } from '../services/MoveHistoryManager';
import { ChessGameEngine } from '../chess-engine/GameEngine';
describe('Move History Integration', () => {
let gameEngine;
let historyManager;
beforeEach(() => {
gameEngine = new ChessGameEngine();
historyManager = new MoveHistoryManager();
});
test('should integrate with game engine', () => {
const moves = ['e2-e4', 'e7-e5', 'g1-f3', 'b8-c6'];
moves.forEach(moveStr => {
const [from, to] = moveStr.split('-');
const result = gameEngine.makeMove(from, to);
expect(result.success).toBe(true);
historyManager.addMove(result.move);
});
expect(historyManager.getAllMoves()).toHaveLength(4);
expect(gameEngine.currentPlayer).toBe('white');
});
test('should replay moves correctly', () => {
// Play a sequence of moves
const moves = ['e2-e4', 'e7-e5', 'f1-c4', 'd7-d6'];
moves.forEach(moveStr => {
const [from, to] = moveStr.split('-');
const result = gameEngine.makeMove(from, to);
historyManager.addMove(result.move);
});
// Reset game and replay from history
gameEngine.reset();
const historyMoves = historyManager.getAllMoves();
historyMoves.forEach(move => {
const result = gameEngine.makeMove(move.from, move.to);
expect(result.success).toBe(true);
});
expect(gameEngine.currentPlayer).toBe('white');
});
});
// shared/components/VirtualMoveList.js
export class VirtualMoveList {
constructor(container, itemHeight = 30) {
this.container = container;
this.itemHeight = itemHeight;
this.visibleStart = 0;
this.visibleEnd = 0;
this.scrollTop = 0;
this.moves = [];
this.setupScrolling();
}
setupScrolling() {
this.container.addEventListener('scroll', () => {
this.scrollTop = this.container.scrollTop;
this.updateVisibleRange();
this.renderVisibleItems();
});
}
updateMoves(moves) {
this.moves = moves;
this.updateVisibleRange();
this.renderVisibleItems();
}
updateVisibleRange() {
const containerHeight = this.container.clientHeight;
const totalHeight = this.moves.length * this.itemHeight;
this.visibleStart = Math.floor(this.scrollTop / this.itemHeight);
this.visibleEnd = Math.min(
this.moves.length,
this.visibleStart + Math.ceil(containerHeight / this.itemHeight) + 1
);
// Update container height
this.container.style.height = `${totalHeight}px`;
}
renderVisibleItems() {
// Clear existing items
this.container.innerHTML = '';
// Create spacer for items before visible range
if (this.visibleStart > 0) {
const spacer = document.createElement('div');
spacer.style.height = `${this.visibleStart * this.itemHeight}px`;
this.container.appendChild(spacer);
}
// Render visible items
for (let i = this.visibleStart; i < this.visibleEnd; i++) {
const item = this.createMoveItem(this.moves[i], i);
this.container.appendChild(item);
}
}
createMoveItem(move, index) {
const item = document.createElement('div');
item.className = 'move-item';
item.style.height = `${this.itemHeight}px`;
item.textContent = move.toAlgebraic();
item.dataset.index = index;
return item;
}
}
- Responsive Design - Mobile-friendly move history
- Performance - Optimization strategies
- Accessibility - Screen reader support
- API Integration - Server-side move storage