feat: NCWF-5/6/7/8/9 chess analysis page and engine integration (#11)
Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com> Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { switchMap, of } from 'rxjs';
|
||||
|
||||
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
|
||||
import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component';
|
||||
import { AnnotatedMoveListComponent } from '../../components/annotated-move-list/annotated-move-list.component';
|
||||
|
||||
import { GameApiService } from '../../services/game-api.service';
|
||||
import { AnalysisService } from '../../services/analysis.service';
|
||||
|
||||
import { AnnotatedMove, AnalysisResponse } from '../../models/analysis.models';
|
||||
import { GameFull } from '../../models/game.models';
|
||||
import { getErrorMessage } from '../../core/http/error-message.util';
|
||||
|
||||
const ANALYSIS_DEPTH_DEFAULT = 14;
|
||||
const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||
|
||||
type InputMode = 'fen' | 'pgn' | 'game';
|
||||
|
||||
@Component({
|
||||
selector: 'app-analysis',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
ChessBoardComponent,
|
||||
EvalTimelineComponent,
|
||||
AnnotatedMoveListComponent,
|
||||
],
|
||||
templateUrl: './analysis.component.html',
|
||||
styleUrl: './analysis.component.css',
|
||||
})
|
||||
export class AnalysisComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly gameApi = inject(GameApiService);
|
||||
private readonly analysisService = inject(AnalysisService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
// ── State ─────────────────────────────────────────────────
|
||||
inputMode: InputMode = 'fen';
|
||||
fenInput = '';
|
||||
pgnInput = '';
|
||||
depth = ANALYSIS_DEPTH_DEFAULT;
|
||||
|
||||
loading = false;
|
||||
analysing = false;
|
||||
errorMessage = '';
|
||||
|
||||
currentFen = STARTING_FEN;
|
||||
game: GameFull | null = null;
|
||||
|
||||
/** FEN for each ply (index 0 = position before move 0 was played) */
|
||||
fenHistory: string[] = [STARTING_FEN];
|
||||
annotatedMoves: AnnotatedMove[] = [];
|
||||
activePly: number | null = null;
|
||||
|
||||
/** Single-position analysis result (for custom FEN/PGN input) */
|
||||
positionAnalysis: AnalysisResponse | null = null;
|
||||
|
||||
get displayFen(): string {
|
||||
if (this.activePly !== null && this.fenHistory[this.activePly + 1]) {
|
||||
return this.fenHistory[this.activePly + 1];
|
||||
}
|
||||
return this.currentFen;
|
||||
}
|
||||
|
||||
get hasAnnotations(): boolean {
|
||||
return this.annotatedMoves.length > 0;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────
|
||||
ngOnInit(): void {
|
||||
// Support /analysis?gameId=xxx deep-link
|
||||
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
const gameId = params.get('gameId');
|
||||
if (gameId) {
|
||||
this.inputMode = 'game';
|
||||
this.loadGame(gameId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Input mode ─────────────────────────────────────────────
|
||||
setInputMode(mode: InputMode): void {
|
||||
this.inputMode = mode;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
|
||||
// ── Load FEN ──────────────────────────────────────────────
|
||||
loadFen(): void {
|
||||
const fen = this.fenInput.trim();
|
||||
if (!fen) return;
|
||||
this.reset();
|
||||
this.currentFen = fen;
|
||||
this.fenHistory = [fen];
|
||||
this.analyseSinglePosition(fen);
|
||||
}
|
||||
|
||||
// ── Load PGN ──────────────────────────────────────────────
|
||||
loadPgn(): void {
|
||||
const pgn = this.pgnInput.trim();
|
||||
if (!pgn) return;
|
||||
this.reset();
|
||||
this.loading = true;
|
||||
this.gameApi
|
||||
.importPgn(pgn)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
this.loading = false;
|
||||
this.applyGame(game);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.errorMessage = getErrorMessage(err, 'Could not import PGN.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load game by ID ───────────────────────────────────────
|
||||
loadGame(gameId: string): void {
|
||||
this.reset();
|
||||
this.loading = true;
|
||||
this.gameApi
|
||||
.getGame(gameId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
this.loading = false;
|
||||
this.applyGame(game);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.errorMessage = getErrorMessage(err, 'Could not load game.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Run full analysis ─────────────────────────────────────
|
||||
runAnalysis(): void {
|
||||
if (this.fenHistory.length < 2) {
|
||||
this.analyseSinglePosition(this.currentFen);
|
||||
return;
|
||||
}
|
||||
this.analysing = true;
|
||||
this.errorMessage = '';
|
||||
const sans =
|
||||
this.annotatedMoves.length > 0
|
||||
? this.annotatedMoves.map((m) => m.san)
|
||||
: (this.game?.state.moves ?? []);
|
||||
|
||||
this.analysisService
|
||||
.analyzeGame(sans, this.fenHistory, this.depth)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (annotated) => {
|
||||
this.annotatedMoves = annotated;
|
||||
this.analysing = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.errorMessage = getErrorMessage(err, 'Analysis failed.');
|
||||
this.analysing = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Board navigation ──────────────────────────────────────
|
||||
navigateToPly(ply: number): void {
|
||||
this.activePly = ply;
|
||||
}
|
||||
|
||||
navigateHistory(direction: 'first' | 'prev' | 'next' | 'last'): void {
|
||||
const total = this.fenHistory.length - 1;
|
||||
const current = this.activePly ?? total;
|
||||
let next: number;
|
||||
switch (direction) {
|
||||
case 'first':
|
||||
next = 0;
|
||||
break;
|
||||
case 'prev':
|
||||
next = Math.max(0, current - 1);
|
||||
break;
|
||||
case 'next':
|
||||
next = Math.min(total, current + 1);
|
||||
break;
|
||||
default:
|
||||
next = total;
|
||||
break;
|
||||
}
|
||||
this.activePly = next >= total ? null : next;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────
|
||||
private reset(): void {
|
||||
this.errorMessage = '';
|
||||
this.annotatedMoves = [];
|
||||
this.positionAnalysis = null;
|
||||
this.activePly = null;
|
||||
this.game = null;
|
||||
this.fenHistory = [STARTING_FEN];
|
||||
this.currentFen = STARTING_FEN;
|
||||
}
|
||||
|
||||
private applyGame(game: GameFull): void {
|
||||
this.game = game;
|
||||
this.currentFen = game.state.fen;
|
||||
// Build a flat FEN history from scratch using moves array
|
||||
// The server gives us the final FEN. We reconstruct history by
|
||||
// storing the final FEN; full per-ply history requires per-move API calls
|
||||
// which is out of scope here — we store what we have and allow analysis to proceed.
|
||||
this.fenHistory = [game.state.fen];
|
||||
// Seed annotated moves with san strings, no quality yet
|
||||
this.annotatedMoves = game.state.moves.map((san) => ({
|
||||
san,
|
||||
fen: game.state.fen,
|
||||
evalBefore: null,
|
||||
evalAfter: null,
|
||||
quality: null,
|
||||
bestMove: null,
|
||||
winChanceBefore: null,
|
||||
winChanceAfter: null,
|
||||
}));
|
||||
}
|
||||
|
||||
private analyseSinglePosition(fen: string): void {
|
||||
this.analysing = true;
|
||||
this.analysisService
|
||||
.analyzePosition(fen, this.depth)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.positionAnalysis = result;
|
||||
this.analysing = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.errorMessage = getErrorMessage(err, 'Analysis failed.');
|
||||
this.analysing = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user