f9420e5848
Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com> Reviewed-on: #11
245 lines
8.0 KiB
TypeScript
245 lines
8.0 KiB
TypeScript
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;
|
|
},
|
|
});
|
|
}
|
|
}
|