feat : add move classification icons

This commit is contained in:
GuillaumeSD
2024-03-07 00:28:32 +01:00
parent c93983fa1f
commit 99a90def9c
23 changed files with 170 additions and 122 deletions

View File

@@ -1,19 +1,19 @@
import {
boardAtom,
currentMoveAtom,
currentPositionAtom,
engineDepthAtom,
engineMultiPvAtom,
gameAtom,
gameEvalAtom,
} from "@/sections/analysis/states";
import { CurrentMove, MoveEval } from "@/types/eval";
import { CurrentPosition, PositionEval } from "@/types/eval";
import { useAtom, useAtomValue } from "jotai";
import { useEffect } from "react";
import { useEngine } from "./useEngine";
import { EngineName } from "@/types/enums";
export const useCurrentMove = (engineName?: EngineName) => {
const [currentMove, setCurrentMove] = useAtom(currentMoveAtom);
export const useCurrentPosition = (engineName?: EngineName) => {
const [currentPosition, setCurrentPosition] = useAtom(currentPositionAtom);
const engine = useEngine(engineName);
const gameEval = useAtomValue(gameEvalAtom);
const game = useAtomValue(gameAtom);
@@ -22,8 +22,8 @@ export const useCurrentMove = (engineName?: EngineName) => {
const multiPv = useAtomValue(engineMultiPvAtom);
useEffect(() => {
const move: CurrentMove = {
...board.history({ verbose: true }).at(-1),
const position: CurrentPosition = {
lastMove: board.history({ verbose: true }).at(-1),
};
if (gameEval) {
@@ -36,15 +36,15 @@ export const useCurrentMove = (engineName?: EngineName) => {
) {
const evalIndex = board.history().length;
move.eval = gameEval.moves[evalIndex];
move.lastEval =
evalIndex > 0 ? gameEval.moves[evalIndex - 1] : undefined;
position.eval = gameEval.positions[evalIndex];
position.lastEval =
evalIndex > 0 ? gameEval.positions[evalIndex - 1] : undefined;
}
}
if (!move.eval && engine?.isReady()) {
const setPartialEval = (moveEval: MoveEval) => {
setCurrentMove({ ...move, eval: moveEval });
if (!position.eval && engine?.isReady()) {
const setPartialEval = (positionEval: PositionEval) => {
setCurrentPosition({ ...position, eval: positionEval });
};
engine.evaluatePositionWithUpdate({
@@ -55,8 +55,8 @@ export const useCurrentMove = (engineName?: EngineName) => {
});
}
setCurrentMove(move);
}, [gameEval, board, game, engine, depth, multiPv, setCurrentMove]);
setCurrentPosition(position);
}, [gameEval, board, game, engine, depth, multiPv, setCurrentPosition]);
return currentMove;
return currentPosition;
};

View File

@@ -0,0 +1,73 @@
import { showPlayerMoveIconAtom } from "@/sections/analysis/states";
import { MoveClassification } from "@/types/enums";
import { CurrentPosition } from "@/types/eval";
import { useAtomValue } from "jotai";
import Image from "next/image";
import { CSSProperties, forwardRef, useMemo } from "react";
import { CustomSquareProps } from "react-chessboard/dist/chessboard/types";
export const useSquareRenderer = (position: CurrentPosition) => {
const showPlayerMoveIcon = useAtomValue(showPlayerMoveIconAtom);
const CustomSquareRenderer = useMemo(() => {
const fromSquare = position.lastMove?.from;
const toSquare = position.lastMove?.to;
const moveClassification = position?.eval?.moveClassification;
if (!showPlayerMoveIcon || !moveClassification || !fromSquare || !toSquare)
return undefined;
const squareRenderer = forwardRef<HTMLDivElement, CustomSquareProps>(
(props, ref) => {
const { children, square, style } = props;
const customSquareStyle: CSSProperties | undefined =
fromSquare === square || toSquare === square
? {
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: moveClassificationColors[moveClassification],
opacity: 0.5,
}
: undefined;
return (
<div ref={ref} style={{ ...style, position: "relative" }}>
{children}
{customSquareStyle && <div style={customSquareStyle} />}
{square === toSquare && (
<Image
src={`/icons/${moveClassification}.png`}
alt="move-icon"
width={40}
height={40}
style={{
position: "absolute",
top: -12,
right: -12,
}}
/>
)}
</div>
);
}
);
squareRenderer.displayName = "CustomSquareRenderer";
return squareRenderer;
}, [showPlayerMoveIcon, position]);
return CustomSquareRenderer;
};
export const moveClassificationColors: Record<MoveClassification, string> = {
[MoveClassification.Best]: "#3aab18",
[MoveClassification.Book]: "#d5a47d",
[MoveClassification.Excellent]: "#3aab18",
[MoveClassification.Good]: "#81b64c",
[MoveClassification.Inaccuracy]: "#f7c631",
[MoveClassification.Mistake]: "#ffa459",
[MoveClassification.Blunder]: "#fa412d",
};

View File

@@ -4,15 +4,15 @@ import {
getStandardDeviation,
getWeightedMean,
} from "@/lib/helpers";
import { Accuracy, MoveEval } from "@/types/eval";
import { Accuracy, PositionEval } from "@/types/eval";
import { getPositionWinPercentage } from "./winPercentage";
export const computeAccuracy = (moves: MoveEval[]): Accuracy => {
const movesWinPercentage = moves.map(getPositionWinPercentage);
export const computeAccuracy = (positions: PositionEval[]): Accuracy => {
const positionsWinPercentage = positions.map(getPositionWinPercentage);
const weights = getAccuracyWeights(movesWinPercentage);
const weights = getAccuracyWeights(positionsWinPercentage);
const movesAccuracy = getMovesAccuracy(movesWinPercentage);
const movesAccuracy = getMovesAccuracy(positionsWinPercentage);
const whiteAccuracy = getPlayerAccuracy(movesAccuracy, weights, "white");
const blackAccuracy = getPlayerAccuracy(movesAccuracy, weights, "black");

View File

@@ -1,13 +1,13 @@
import { MoveEval } from "@/types/eval";
import { PositionEval } from "@/types/eval";
import { getPositionWinPercentage } from "./winPercentage";
import { MoveClassification } from "@/types/enums";
import { openings } from "@/data/openings";
export const getMovesClassification = (
rawMoves: MoveEval[],
rawMoves: PositionEval[],
uciMoves: string[],
fens: string[]
): MoveEval[] => {
): PositionEval[] => {
const positionsWinPercentage = rawMoves.map(getPositionWinPercentage);
let currentOpening: string | undefined = undefined;

View File

@@ -1,10 +1,10 @@
import { LineEval, MoveEval } from "@/types/eval";
import { LineEval, PositionEval } from "@/types/eval";
export const parseEvaluationResults = (
results: string[],
whiteToPlay: boolean
): MoveEval => {
const parsedResults: MoveEval = {
): PositionEval => {
const parsedResults: PositionEval = {
lines: [],
};
const tempResults: Record<string, LineEval> = {};

View File

@@ -1,13 +1,13 @@
import { ceilsNumber } from "@/lib/helpers";
import { MoveEval } from "@/types/eval";
import { PositionEval } from "@/types/eval";
export const getPositionWinPercentage = (move: MoveEval): number => {
if (move.lines[0].cp !== undefined) {
return getWinPercentageFromCp(move.lines[0].cp);
export const getPositionWinPercentage = (position: PositionEval): number => {
if (position.lines[0].cp !== undefined) {
return getWinPercentageFromCp(position.lines[0].cp);
}
if (move.lines[0].mate !== undefined) {
return getWinPercentageFromMate(move.lines[0].mate);
if (position.lines[0].mate !== undefined) {
return getWinPercentageFromMate(position.lines[0].mate);
}
throw new Error("No cp or mate in move");

View File

@@ -3,7 +3,7 @@ import {
EvaluateGameParams,
EvaluatePositionWithUpdateParams,
GameEval,
MoveEval,
PositionEval,
} from "@/types/eval";
import { parseEvaluationResults } from "./helpers/parseResults";
import { computeAccuracy } from "./helpers/accuracy";
@@ -108,11 +108,11 @@ export abstract class UciEngine {
await this.sendCommands(["ucinewgame", "isready"], "readyok");
this.worker.postMessage("position startpos");
const moves: MoveEval[] = [];
const positions: PositionEval[] = [];
for (const fen of fens) {
const whoIsCheckmated = getWhoIsCheckmated(fen);
if (whoIsCheckmated) {
moves.push({
positions.push({
lines: [
{
pv: [],
@@ -125,19 +125,19 @@ export abstract class UciEngine {
continue;
}
const result = await this.evaluatePosition(fen, depth);
moves.push(result);
positions.push(result);
}
const movesWithClassification = getMovesClassification(
moves,
const positionsWithClassification = getMovesClassification(
positions,
uciMoves,
fens
);
const accuracy = computeAccuracy(moves);
const accuracy = computeAccuracy(positions);
this.ready = true;
return {
moves: movesWithClassification,
positions: positionsWithClassification,
accuracy,
settings: {
engine: this.engineName,
@@ -148,7 +148,10 @@ export abstract class UciEngine {
};
}
private async evaluatePosition(fen: string, depth = 16): Promise<MoveEval> {
private async evaluatePosition(
fen: string,
depth = 16
): Promise<PositionEval> {
console.log(`Evaluating position: ${fen}`);
const lichessEval = await getLichessEval(fen, this.multiPv);

View File

@@ -1,4 +1,4 @@
import { LineEval, MoveEval } from "@/types/eval";
import { LineEval, PositionEval } from "@/types/eval";
import { sortLines } from "./engine/helpers/parseResults";
import {
LichessError,
@@ -9,7 +9,7 @@ import {
export const getLichessEval = async (
fen: string,
multiPv = 1
): Promise<MoveEval> => {
): Promise<PositionEval> => {
try {
const res = await fetch(
`https://lichess.org/api/cloud-eval?fen=${fen}&multiPv=${multiPv}`

View File

@@ -1,7 +1,11 @@
import { Box, Grid, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { boardAtom, boardOrientationAtom, currentMoveAtom } from "../states";
import {
boardAtom,
boardOrientationAtom,
currentPositionAtom,
} from "../states";
import { getEvaluationBarValue } from "@/lib/chess";
interface Props {
@@ -15,17 +19,17 @@ export default function EvaluationBar({ height }: Props) {
});
const board = useAtomValue(boardAtom);
const boardOrientation = useAtomValue(boardOrientationAtom);
const currentMove = useAtomValue(currentMoveAtom);
const position = useAtomValue(currentPositionAtom);
const isWhiteToPlay = board.turn() === "w";
useEffect(() => {
const bestLine = currentMove?.eval?.lines[0];
const bestLine = position?.eval?.lines[0];
if (!bestLine || bestLine.depth < 6) return;
const evalBar = getEvaluationBarValue(bestLine, isWhiteToPlay);
setEvalBar(evalBar);
}, [currentMove, isWhiteToPlay]);
}, [position, isWhiteToPlay]);
return (
<Grid

View File

@@ -4,9 +4,8 @@ import { useAtomValue } from "jotai";
import {
boardAtom,
boardOrientationAtom,
currentMoveAtom,
currentPositionAtom,
showBestMoveArrowAtom,
showPlayerMoveArrowAtom,
} from "../states";
import { Arrow, Square } from "react-chessboard/dist/chessboard/types";
import { useChessActions } from "@/hooks/useChessActions";
@@ -15,6 +14,10 @@ import PlayerInfo from "./playerInfo";
import EvaluationBar from "./evaluationBar";
import { useScreenSize } from "@/hooks/useScreenSize";
import { MoveClassification } from "@/types/enums";
import {
moveClassificationColors,
useSquareRenderer,
} from "@/hooks/useSquareRenderer";
export default function Board() {
const boardRef = useRef<HTMLDivElement>(null);
@@ -22,9 +25,9 @@ export default function Board() {
const board = useAtomValue(boardAtom);
const boardOrientation = useAtomValue(boardOrientationAtom);
const showBestMoveArrow = useAtomValue(showBestMoveArrowAtom);
const showPlayerMoveArrow = useAtomValue(showPlayerMoveArrowAtom);
const { makeMove: makeBoardMove } = useChessActions(boardAtom);
const currentMove = useAtomValue(currentMoveAtom);
const position = useAtomValue(currentPositionAtom);
const squareRenderer = useSquareRenderer(position);
const onPieceDrop = (
source: Square,
@@ -45,9 +48,8 @@ export default function Board() {
};
const customArrows: Arrow[] = useMemo(() => {
const arrows: Arrow[] = [];
const bestMove = currentMove?.lastEval?.bestMove;
const moveClassification = currentMove?.eval?.moveClassification;
const bestMove = position?.lastEval?.bestMove;
const moveClassification = position?.eval?.moveClassification;
if (
bestMove &&
@@ -60,36 +62,11 @@ export default function Board() {
moveClassificationColors[MoveClassification.Best],
] as Arrow;
arrows.push(bestMoveArrow);
return [bestMoveArrow];
}
if (
currentMove.from &&
currentMove.to &&
showPlayerMoveArrow &&
moveClassification !== MoveClassification.Best
) {
const arrowColor = moveClassification
? moveClassificationColors[moveClassification]
: "#ffaa00";
const playerMoveArrow: Arrow = [
currentMove.from,
currentMove.to,
arrowColor,
];
if (
arrows.every(
(arrow) =>
arrow[0] !== playerMoveArrow[0] || arrow[1] !== playerMoveArrow[1]
)
) {
arrows.push(playerMoveArrow);
}
}
return arrows;
}, [currentMove, showBestMoveArrow, showPlayerMoveArrow]);
return [];
}, [position, showBestMoveArrow]);
return (
<Grid
@@ -131,6 +108,7 @@ export default function Board() {
borderRadius: "5px",
boxShadow: "0 2px 10px rgba(0, 0, 0, 0.5)",
}}
customSquare={squareRenderer}
/>
</Grid>
@@ -139,13 +117,3 @@ export default function Board() {
</Grid>
);
}
const moveClassificationColors: Record<MoveClassification, string> = {
[MoveClassification.Best]: "#26c2a3",
[MoveClassification.Book]: "#d5a47d",
[MoveClassification.Excellent]: "#3aab18",
[MoveClassification.Good]: "#81b64c",
[MoveClassification.Inaccuracy]: "#f7c631",
[MoveClassification.Mistake]: "#ffa459",
[MoveClassification.Blunder]: "#fa412d",
};

View File

@@ -3,7 +3,7 @@ import { Grid, List, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { boardAtom, engineMultiPvAtom, gameAtom } from "../states";
import LineEvaluation from "./lineEvaluation";
import { useCurrentMove } from "@/hooks/useCurrentMove";
import { useCurrentPosition } from "@/hooks/useCurrentPosition";
import { LineEval } from "@/types/eval";
import { EngineName } from "@/types/enums";
import EngineSettingsButton from "@/sections/engineSettings/engineSettingsButton";
@@ -13,7 +13,7 @@ import Opening from "./opening";
export default function ReviewPanelBody() {
const linesNumber = useAtomValue(engineMultiPvAtom);
const move = useCurrentMove(EngineName.Stockfish16);
const position = useCurrentPosition(EngineName.Stockfish16);
const game = useAtomValue(gameAtom);
const board = useAtomValue(boardAtom);
@@ -30,8 +30,8 @@ export default function ReviewPanelBody() {
(_, i) => ({ pv: [`${i}`], depth: 0, multiPv: i + 1 })
);
const engineLines = move?.eval?.lines?.length
? move.eval.lines
const engineLines = position?.eval?.lines?.length
? position.eval.lines
: linesSkeleton;
return (

View File

@@ -1,16 +1,15 @@
import { useCurrentMove } from "@/hooks/useCurrentMove";
import { Grid, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { boardAtom } from "../states";
import { boardAtom, currentPositionAtom } from "../states";
import { useMemo } from "react";
import { moveLineUciToSan } from "@/lib/chess";
import { MoveClassification } from "@/types/enums";
export default function MoveInfo() {
const move = useCurrentMove();
const position = useAtomValue(currentPositionAtom);
const board = useAtomValue(boardAtom);
const bestMove = move?.lastEval?.bestMove;
const bestMove = position?.lastEval?.bestMove;
const bestMoveSan = useMemo(() => {
if (!bestMove) return undefined;
@@ -23,9 +22,9 @@ export default function MoveInfo() {
if (!bestMoveSan) return null;
const moveClassification = move.eval?.moveClassification;
const moveClassification = position.eval?.moveClassification;
const moveLabel = moveClassification
? `${move.san} is ${moveClassificationLabels[moveClassification]}`
? `${position.lastMove?.san} is ${moveClassificationLabels[moveClassification]}`
: null;
const bestMoveLabel =

View File

@@ -1,10 +1,10 @@
import { useCurrentMove } from "@/hooks/useCurrentMove";
import { useCurrentPosition } from "@/hooks/useCurrentPosition";
import { Grid, Typography } from "@mui/material";
export default function Opening() {
const move = useCurrentMove();
const position = useCurrentPosition();
const opening = move?.eval?.opening;
const opening = position?.eval?.opening;
if (!opening) return null;
return (

View File

@@ -1,15 +1,15 @@
import { CurrentMove, GameEval } from "@/types/eval";
import { CurrentPosition, GameEval } from "@/types/eval";
import { Chess } from "chess.js";
import { atom } from "jotai";
export const gameEvalAtom = atom<GameEval | undefined>(undefined);
export const gameAtom = atom(new Chess());
export const boardAtom = atom(new Chess());
export const currentMoveAtom = atom<CurrentMove>({});
export const currentPositionAtom = atom<CurrentPosition>({});
export const boardOrientationAtom = atom(true);
export const showBestMoveArrowAtom = atom(true);
export const showPlayerMoveArrowAtom = atom(true);
export const showPlayerMoveIconAtom = atom(true);
export const engineDepthAtom = atom(16);
export const engineMultiPvAtom = atom(3);

View File

@@ -1,7 +1,7 @@
import { Checkbox, FormControlLabel, Grid } from "@mui/material";
import {
showBestMoveArrowAtom,
showPlayerMoveArrowAtom,
showPlayerMoveIconAtom,
} from "../analysis/states";
import { useAtomLocalStorage } from "@/hooks/useAtomLocalStorage";
@@ -10,9 +10,9 @@ export default function ArrowOptions() {
"show-arrow-best-move",
showBestMoveArrowAtom
);
const [showPlayerMove, setShowPlayerMove] = useAtomLocalStorage(
"show-arrow-player-move",
showPlayerMoveArrowAtom
const [showPlayerMoveIcon, setShowPlayerMoveIcon] = useAtomLocalStorage(
"show-icon-player-move",
showPlayerMoveIconAtom
);
return (
@@ -37,11 +37,11 @@ export default function ArrowOptions() {
<FormControlLabel
control={
<Checkbox
checked={showPlayerMove}
onChange={(_, checked) => setShowPlayerMove(checked)}
checked={showPlayerMoveIcon}
onChange={(_, checked) => setShowPlayerMoveIcon(checked)}
/>
}
label="Show played move arrow"
label="Show played move icon"
sx={{ marginX: 0 }}
/>
</Grid>

View File

@@ -1,7 +1,7 @@
import { Move } from "chess.js";
import { EngineName, MoveClassification } from "./enums";
export interface MoveEval {
export interface PositionEval {
bestMove?: string;
moveClassification?: MoveClassification;
opening?: string;
@@ -29,7 +29,7 @@ export interface EngineSettings {
}
export interface GameEval {
moves: MoveEval[];
positions: PositionEval[];
accuracy: Accuracy;
settings: EngineSettings;
}
@@ -38,13 +38,14 @@ export interface EvaluatePositionWithUpdateParams {
fen: string;
depth?: number;
multiPv?: number;
setPartialEval: (moveEval: MoveEval) => void;
setPartialEval: (positionEval: PositionEval) => void;
}
export type CurrentMove = Partial<Move> & {
eval?: MoveEval;
lastEval?: MoveEval;
};
export interface CurrentPosition {
lastMove?: Move;
eval?: PositionEval;
lastEval?: PositionEval;
}
export interface EvaluateGameParams {
fens: string[];