Squashed commit of the following:

commit 4810de3b94b0ec0d7e9b8570de58f85792dffa80
Author: GuillaumeSD <gsd.lfny@gmail.com>
Date:   Sat Apr 6 01:37:42 2024 +0200

    fix : lint

commit 59e0b571e6089da6c086ab6340ec6a966b2e9739
Author: GuillaumeSD <gsd.lfny@gmail.com>
Date:   Sat Apr 6 01:36:17 2024 +0200

    feat : UI refacto

commit 56806a89dca5c7fb2c229b5a57404f9a856fac09
Author: GuillaumeSD <gsd.lfny@gmail.com>
Date:   Fri Apr 5 03:56:08 2024 +0200

    feat : add moves list

commit 9e3d2347882074c38ab183e642ecef8153dbfcde
Author: GuillaumeSD <gsd.lfny@gmail.com>
Date:   Thu Apr 4 02:18:52 2024 +0200

    feat : init branch, wip
This commit is contained in:
GuillaumeSD
2024-04-06 01:38:06 +02:00
parent d9b322d9fa
commit 3d0d1c41a8
18 changed files with 328 additions and 176 deletions

View File

@@ -1,5 +1,9 @@
import { setGameHeaders } from "@/lib/chess"; import { setGameHeaders } from "@/lib/chess";
import { playIllegalMoveSound, playSoundFromMove } from "@/lib/sounds"; import {
playGameEndSound,
playIllegalMoveSound,
playSoundFromMove,
} from "@/lib/sounds";
import { Chess, Move } from "chess.js"; import { Chess, Move } from "chess.js";
import { PrimitiveAtom, useAtom } from "jotai"; import { PrimitiveAtom, useAtom } from "jotai";
import { useCallback } from "react"; import { useCallback } from "react";
@@ -76,7 +80,11 @@ export const useChessActions = (chessAtom: PrimitiveAtom<Chess>) => {
} }
setGame(newGame); setGame(newGame);
playSoundFromMove(lastMove); if (lastMove) {
playSoundFromMove(lastMove);
} else {
playGameEndSound();
}
}, },
[setGame] [setGame]
); );

View File

@@ -11,8 +11,16 @@ export const usePlayersNames = (gameAtom: PrimitiveAtom<Chess>) => {
const blackName = const blackName =
gameFromUrl?.black?.name || game.header()["Black"] || "Black"; gameFromUrl?.black?.name || game.header()["Black"] || "Black";
const whiteElo =
gameFromUrl?.white?.rating || game.header()["WhiteElo"] || "?";
const blackElo =
gameFromUrl?.black?.rating || game.header()["BlackElo"] || "?";
return { return {
whiteName, whiteName,
blackName, blackName,
whiteElo,
blackElo,
}; };
}; };

View File

@@ -3,7 +3,7 @@ import {
getHarmonicMean, getHarmonicMean,
getStandardDeviation, getStandardDeviation,
getWeightedMean, getWeightedMean,
} from "@/lib/helpers"; } from "@/lib/math";
import { Accuracy, PositionEval } from "@/types/eval"; import { Accuracy, PositionEval } from "@/types/eval";
import { getPositionWinPercentage } from "./winPercentage"; import { getPositionWinPercentage } from "./winPercentage";

View File

@@ -1,4 +1,4 @@
import { ceilsNumber } from "@/lib/helpers"; import { ceilsNumber } from "@/lib/math";
import { LineEval, PositionEval } from "@/types/eval"; import { LineEval, PositionEval } from "@/types/eval";
export const getPositionWinPercentage = (position: PositionEval): number => { export const getPositionWinPercentage = (position: PositionEval): number => {

View File

@@ -6,36 +6,10 @@ export const capitalize = (s: string) => {
return s.charAt(0).toUpperCase() + s.slice(1); return s.charAt(0).toUpperCase() + s.slice(1);
}; };
export const ceilsNumber = (number: number, min: number, max: number) => { export const isInViewport = (element: HTMLElement) => {
if (number > max) return max; const rect = element.getBoundingClientRect();
if (number < min) return min; return (
return number; rect.top >= 0 &&
}; rect.bottom <= (window.innerHeight || document.documentElement.clientHeight)
export const getHarmonicMean = (array: number[]) => {
const sum = array.reduce((acc, curr) => acc + 1 / curr, 0);
return array.length / sum;
};
export const getStandardDeviation = (array: number[]) => {
const n = array.length;
const mean = array.reduce((a, b) => a + b) / n;
return Math.sqrt(
array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n
); );
}; };
export const getWeightedMean = (array: number[], weights: number[]) => {
if (array.length > weights.length)
throw new Error("Weights array is too short");
const weightedSum = array.reduce(
(acc, curr, index) => acc + curr * weights[index],
0
);
const weightSum = weights
.slice(0, array.length)
.reduce((acc, curr) => acc + curr, 0);
return weightedSum / weightSum;
};

33
src/lib/math.ts Normal file
View File

@@ -0,0 +1,33 @@
export const ceilsNumber = (number: number, min: number, max: number) => {
if (number > max) return max;
if (number < min) return min;
return number;
};
export const getHarmonicMean = (array: number[]) => {
const sum = array.reduce((acc, curr) => acc + 1 / curr, 0);
return array.length / sum;
};
export const getStandardDeviation = (array: number[]) => {
const n = array.length;
const mean = array.reduce((a, b) => a + b) / n;
return Math.sqrt(
array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n
);
};
export const getWeightedMean = (array: number[], weights: number[]) => {
if (array.length > weights.length)
throw new Error("Weights array is too short");
const weightedSum = array.reduce(
(acc, curr, index) => acc + curr * weights[index],
0
);
const weightSum = weights
.slice(0, array.length)
.reduce((acc, curr) => acc + curr, 0);
return weightedSum / weightSum;
};

View File

@@ -1,6 +1,6 @@
import { useChessActions } from "@/hooks/useChessActions"; import { useChessActions } from "@/hooks/useChessActions";
import Board from "@/sections/analysis/board"; import Board from "@/sections/analysis/board";
import MovesClassificationsRecap from "@/sections/analysis/movesClassificationsRecap"; import MovesClassificationsRecap from "@/sections/analysis/reviewPanelBody/movesClassificationsRecap";
import ReviewPanelBody from "@/sections/analysis/reviewPanelBody"; import ReviewPanelBody from "@/sections/analysis/reviewPanelBody";
import ReviewPanelHeader from "@/sections/analysis/reviewPanelHeader"; import ReviewPanelHeader from "@/sections/analysis/reviewPanelHeader";
import ReviewPanelToolBar from "@/sections/analysis/reviewPanelToolbar"; import ReviewPanelToolBar from "@/sections/analysis/reviewPanelToolbar";
@@ -15,6 +15,7 @@ import { Chess } from "chess.js";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import MovesPanel from "@/sections/analysis/reviewPanelBody/movesPanel";
export default function GameReport() { export default function GameReport() {
const theme = useTheme(); const theme = useTheme();
@@ -22,7 +23,7 @@ export default function GameReport() {
const { reset: resetBoard } = useChessActions(boardAtom); const { reset: resetBoard } = useChessActions(boardAtom);
const { setPgn: setGamePgn } = useChessActions(gameAtom); const { setPgn: setGamePgn } = useChessActions(gameAtom);
const setEval = useSetAtom(gameEvalAtom); const setGameEval = useSetAtom(gameEvalAtom);
const setBoardOrientation = useSetAtom(boardOrientationAtom); const setBoardOrientation = useSetAtom(boardOrientationAtom);
const router = useRouter(); const router = useRouter();
@@ -31,20 +32,19 @@ export default function GameReport() {
useEffect(() => { useEffect(() => {
if (!gameId) { if (!gameId) {
resetBoard(); resetBoard();
setEval(undefined); setGameEval(undefined);
setBoardOrientation(true); setBoardOrientation(true);
setGamePgn(new Chess().pgn()); setGamePgn(new Chess().pgn());
} }
}, [gameId, setEval, setBoardOrientation, resetBoard, setGamePgn]); }, [gameId, setGameEval, setBoardOrientation, resetBoard, setGamePgn]);
return ( return (
<Grid container gap={4} justifyContent="space-evenly" alignItems="start"> <Grid container gap={4} justifyContent="space-evenly" alignItems="center">
<Board /> <Board />
<Grid <Grid
container container
item item
marginTop={{ xs: 0, lg: "2.5em" }}
justifyContent="center" justifyContent="center"
alignItems="center" alignItems="center"
borderRadius={2} borderRadius={2}
@@ -61,21 +61,38 @@ export default function GameReport() {
padding={3} padding={3}
rowGap={3} rowGap={3}
style={{ style={{
maxWidth: "1100px", maxWidth: "1200px",
}} }}
maxHeight={{ lg: "calc(100vh - 150px)", xs: "900px" }}
display="grid"
gridTemplateRows="repeat(4, auto) fit-content(100%)"
> >
{isLgOrGreater ? <ReviewPanelHeader /> : <ReviewPanelToolBar />} {isLgOrGreater ? <ReviewPanelHeader /> : <ReviewPanelToolBar />}
<Divider sx={{ width: "90%" }} /> <Divider sx={{ marginX: "5%" }} />
<ReviewPanelBody /> <ReviewPanelBody />
<Divider sx={{ width: "90%" }} /> <Divider sx={{ marginX: "5%" }} />
<Grid
container
item
justifyContent="center"
alignItems="start"
height="100%"
minHeight={{ lg: "50px", xs: undefined }}
sx={{ overflow: "hidden" }}
>
<MovesPanel />
<MovesClassificationsRecap />
</Grid>
<Divider sx={{ marginX: "5%" }} />
{isLgOrGreater ? <ReviewPanelToolBar /> : <ReviewPanelHeader />} {isLgOrGreater ? <ReviewPanelToolBar /> : <ReviewPanelHeader />}
</Grid> </Grid>
<MovesClassificationsRecap />
</Grid> </Grid>
); );
} }

View File

@@ -17,7 +17,8 @@ export default function BoardContainer() {
const screenSize = useScreenSize(); const screenSize = useScreenSize();
const boardOrientation = useAtomValue(boardOrientationAtom); const boardOrientation = useAtomValue(boardOrientationAtom);
const showBestMoveArrow = useAtomValue(showBestMoveArrowAtom); const showBestMoveArrow = useAtomValue(showBestMoveArrowAtom);
const { whiteName, blackName } = usePlayersNames(gameAtom); const { whiteName, whiteElo, blackName, blackElo } =
usePlayersNames(gameAtom);
const boardSize = useMemo(() => { const boardSize = useMemo(() => {
const width = screenSize.width; const width = screenSize.width;
@@ -28,7 +29,7 @@ export default function BoardContainer() {
return Math.min(width, height - 150); return Math.min(width, height - 150);
} }
return Math.min(width - 600, height * 0.95); return Math.min(width - 700, height * 0.95);
}, [screenSize]); }, [screenSize]);
return ( return (
@@ -37,8 +38,8 @@ export default function BoardContainer() {
boardSize={boardSize} boardSize={boardSize}
canPlay={true} canPlay={true}
gameAtom={boardAtom} gameAtom={boardAtom}
whitePlayer={whiteName} whitePlayer={`${whiteName} (${whiteElo})`}
blackPlayer={blackName} blackPlayer={`${blackName} (${blackElo})`}
boardOrientation={boardOrientation ? Color.White : Color.Black} boardOrientation={boardOrientation ? Color.White : Color.Black}
currentPositionAtom={currentPositionAtom} currentPositionAtom={currentPositionAtom}
showBestMoveArrow={showBestMoveArrow} showBestMoveArrow={showBestMoveArrow}

View File

@@ -26,14 +26,16 @@ export const useCurrentPosition = (engineName?: EngineName) => {
lastMove: board.history({ verbose: true }).at(-1), lastMove: board.history({ verbose: true }).at(-1),
}; };
if (gameEval) { const boardHistory = board.history();
const boardHistory = board.history(); const gameHistory = game.history();
const gameHistory = game.history();
if ( if (
boardHistory.length <= gameHistory.length && boardHistory.length <= gameHistory.length &&
gameHistory.slice(0, boardHistory.length).join() === boardHistory.join() gameHistory.slice(0, boardHistory.length).join() === boardHistory.join()
) { ) {
position.currentMoveIdx = boardHistory.length;
if (gameEval) {
const evalIndex = boardHistory.length; const evalIndex = boardHistory.length;
position.eval = gameEval.positions[evalIndex]; position.eval = gameEval.positions[evalIndex];

View File

@@ -1,7 +1,7 @@
import { Color, MoveClassification } from "@/types/enums"; import { Color, MoveClassification } from "@/types/enums";
import { Grid, Typography } from "@mui/material"; import { Grid, Typography } from "@mui/material";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { boardAtom, gameAtom, gameEvalAtom } from "../states"; import { boardAtom, gameAtom, gameEvalAtom } from "../../states";
import { useMemo } from "react"; import { useMemo } from "react";
import { moveClassificationColors } from "@/components/board/squareRenderer"; import { moveClassificationColors } from "@/components/board/squareRenderer";
import Image from "next/image"; import Image from "next/image";
@@ -68,8 +68,6 @@ export default function ClassificationRow({ classification }: Props) {
} }
}; };
if (!gameEval?.positions.length) return null;
return ( return (
<Grid <Grid
container container
@@ -99,6 +97,7 @@ export default function ClassificationRow({ classification }: Props) {
alignItems="center" alignItems="center"
width={"7rem"} width={"7rem"}
gap={1} gap={1}
wrap="nowrap"
> >
<Image <Image
src={`/icons/${classification}.png`} src={`/icons/${classification}.png`}

View File

@@ -1,6 +1,6 @@
import { usePlayersNames } from "@/hooks/usePlayerNames"; import { usePlayersNames } from "@/hooks/usePlayerNames";
import { Grid, Typography } from "@mui/material"; import { Grid, Typography } from "@mui/material";
import { gameAtom, gameEvalAtom } from "../states"; import { gameAtom, gameEvalAtom } from "../../states";
import { MoveClassification } from "@/types/enums"; import { MoveClassification } from "@/types/enums";
import ClassificationRow from "./classificationRow"; import ClassificationRow from "./classificationRow";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
@@ -17,20 +17,10 @@ export default function MovesClassificationsRecap() {
item item
justifyContent="center" justifyContent="center"
alignItems="center" alignItems="center"
borderRadius={2}
border={1}
borderColor={"secondary.main"}
sx={{
backgroundColor: "secondary.main",
borderColor: "primary.main",
borderWidth: 2,
boxShadow: "0 2px 10px rgba(0, 0, 0, 0.5)",
}}
marginTop={{ xs: 0, lg: "2.5em" }}
paddingY={3}
rowGap={2} rowGap={2}
xs xs={6}
style={{ maxWidth: "50rem" }} sx={{ scrollbarWidth: "thin", overflowY: "auto" }}
maxHeight="100%"
> >
<Grid <Grid
item item
@@ -40,13 +30,13 @@ export default function MovesClassificationsRecap() {
wrap="nowrap" wrap="nowrap"
xs={12} xs={12}
> >
<Typography width="12rem" align="center"> <Typography width="12rem" align="center" noWrap>
{whiteName} {whiteName}
</Typography> </Typography>
<Typography width="7rem" /> <Typography width="7rem" />
<Typography width="12rem" align="center"> <Typography width="12rem" align="center" noWrap>
{blackName} {blackName}
</Typography> </Typography>
</Grid> </Grid>

View File

@@ -0,0 +1,62 @@
import { Grid } from "@mui/material";
import MovesLine from "./movesLine";
import { useMemo } from "react";
import { useAtomValue } from "jotai";
import { gameAtom, gameEvalAtom } from "../../states";
import { MoveClassification } from "@/types/enums";
export default function MovesPanel() {
const game = useAtomValue(gameAtom);
const gameEval = useAtomValue(gameEvalAtom);
const gameMoves = useMemo(() => {
const history = game.history();
if (!history.length) return undefined;
const moves: { san: string; moveClassification?: MoveClassification }[][] =
[];
for (let i = 0; i < history.length; i += 2) {
const items = [
{
san: history[i],
moveClassification: gameEval?.positions[i + 1]?.moveClassification,
},
];
if (history[i + 1]) {
items.push({
san: history[i + 1],
moveClassification: gameEval?.positions[i + 2]?.moveClassification,
});
}
moves.push(items);
}
return moves;
}, [game, gameEval]);
if (!gameMoves) return null;
return (
<Grid
container
item
justifyContent="center"
alignItems="start"
gap={1}
sx={{ scrollbarWidth: "thin", overflowY: "auto" }}
maxHeight="100%"
xs={6}
>
{gameMoves?.map((moves, idx) => (
<MovesLine
key={`${moves.map(({ san }) => san).join()}-${idx}`}
moves={moves}
moveNb={idx + 1}
/>
))}
</Grid>
);
}

View File

@@ -0,0 +1,86 @@
import { MoveClassification } from "@/types/enums";
import { Grid, Typography } from "@mui/material";
import { moveClassificationColors } from "@/components/board/squareRenderer";
import Image from "next/image";
import { useAtomValue } from "jotai";
import { boardAtom, currentPositionAtom, gameAtom } from "../../states";
import { useChessActions } from "@/hooks/useChessActions";
import { useEffect } from "react";
import { isInViewport } from "@/lib/helpers";
interface Props {
san: string;
moveClassification?: MoveClassification;
moveIdx: number;
}
export default function MoveItem({ san, moveClassification, moveIdx }: Props) {
const game = useAtomValue(gameAtom);
const { goToMove } = useChessActions(boardAtom);
const position = useAtomValue(currentPositionAtom);
const color = getMoveColor(moveClassification);
const isCurrentMove = position?.currentMoveIdx === moveIdx;
useEffect(() => {
if (!isCurrentMove) return;
const moveItem = document.getElementById(`move-${moveIdx}`);
if (!moveItem || !isInViewport(moveItem)) return;
moveItem.scrollIntoView({ behavior: "smooth", block: "center" });
}, [isCurrentMove, moveIdx]);
const handleClick = () => {
if (isCurrentMove) return;
goToMove(moveIdx, game);
};
return (
<Grid
item
container
justifyContent="center"
alignItems="center"
gap={1}
width="5rem"
wrap="nowrap"
onClick={handleClick}
paddingY={0.5}
sx={{
cursor: isCurrentMove ? undefined : "pointer",
backgroundColor: isCurrentMove ? "#4f4f4f" : undefined,
borderRadius: 1,
}}
id={`move-${moveIdx}`}
>
{color && (
<Image
src={`/icons/${moveClassification}.png`}
alt="move-icon"
width={15}
height={15}
style={{
maxWidth: "3.6vw",
maxHeight: "3.6vw",
}}
/>
)}
<Typography color={getMoveColor(moveClassification)}>{san}</Typography>
</Grid>
);
}
const getMoveColor = (moveClassification?: MoveClassification) => {
if (
!moveClassification ||
moveClassificationsToIgnore.includes(moveClassification)
) {
return undefined;
}
return moveClassificationColors[moveClassification];
};
const moveClassificationsToIgnore: MoveClassification[] = [
MoveClassification.Good,
MoveClassification.Excellent,
];

View File

@@ -0,0 +1,27 @@
import { MoveClassification } from "@/types/enums";
import { Grid, Typography } from "@mui/material";
import MoveItem from "./moveItem";
interface Props {
moves: { san: string; moveClassification?: MoveClassification }[];
moveNb: number;
}
export default function MovesLine({ moves, moveNb }: Props) {
return (
<Grid
container
item
justifyContent="space-evenly"
alignItems="start"
xs={12}
wrap="nowrap"
>
<Typography width="2rem">{moveNb}.</Typography>
<MoveItem {...moves[0]} moveIdx={(moveNb - 1) * 2 + 1} />
<MoveItem {...moves[1]} moveIdx={(moveNb - 1) * 2 + 2} />
</Grid>
);
}

View File

@@ -0,0 +1,44 @@
import { Grid, Typography } from "@mui/material";
import { useGameDatabase } from "@/hooks/useGameDatabase";
import { useAtomValue } from "jotai";
import { gameAtom } from "../states";
export default function GamePanel() {
const { gameFromUrl } = useGameDatabase();
const game = useAtomValue(gameAtom);
const hasGameInfo = gameFromUrl !== undefined || !!game.header().White;
if (!hasGameInfo) return null;
return (
<Grid
item
container
xs={11}
justifyContent="space-evenly"
alignItems="center"
rowGap={1}
columnGap={3}
>
<Grid item container xs justifyContent="center" alignItems="center">
<Typography noWrap>
Site : {gameFromUrl?.site || game.header().Site || "?"}
</Typography>
</Grid>
<Grid item container xs justifyContent="center" alignItems="center">
<Typography noWrap>
Date : {gameFromUrl?.date || game.header().Date || "?"}
</Typography>
</Grid>
<Grid item container xs justifyContent="center" alignItems="center">
<Typography noWrap>
Result :{" "}
{gameFromUrl?.termination || game.header().Termination || "?"}
</Typography>
</Grid>
</Grid>
);
}

View File

@@ -1,62 +0,0 @@
import { Grid, Typography } from "@mui/material";
import { useGameDatabase } from "@/hooks/useGameDatabase";
import { useAtomValue } from "jotai";
import { gameAtom } from "../../states";
import PlayerInfo from "./playerInfo";
export default function GamePanel() {
const { gameFromUrl } = useGameDatabase();
const game = useAtomValue(gameAtom);
const hasGameInfo = gameFromUrl !== undefined || !!game.header().White;
if (!hasGameInfo) return null;
return (
<Grid
item
container
xs={12}
justifyContent="center"
alignItems="center"
gap={2}
>
<Grid item container xs={12} justifyContent="center" alignItems="center">
<PlayerInfo color="white" />
<Typography marginX={1.5}>vs</Typography>
<PlayerInfo color="black" />
</Grid>
<Grid
item
container
xs={11}
justifyContent="space-evenly"
alignItems="center"
rowGap={1}
columnGap={3}
>
<Grid item container xs justifyContent="center" alignItems="center">
<Typography noWrap>
Site : {gameFromUrl?.site || game.header().Site || "?"}
</Typography>
</Grid>
<Grid item container xs justifyContent="center" alignItems="center">
<Typography noWrap>
Date : {gameFromUrl?.date || game.header().Date || "?"}
</Typography>
</Grid>
<Grid item container xs justifyContent="center" alignItems="center">
<Typography noWrap>
Result :{" "}
{gameFromUrl?.termination || game.header().Termination || "?"}
</Typography>
</Grid>
</Grid>
</Grid>
);
}

View File

@@ -1,38 +0,0 @@
import { useGameDatabase } from "@/hooks/useGameDatabase";
import { Grid, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { gameAtom } from "../../states";
interface Props {
color: "white" | "black";
}
export default function PlayerInfo({ color }: Props) {
const { gameFromUrl } = useGameDatabase();
const game = useAtomValue(gameAtom);
const rating =
gameFromUrl?.[color]?.rating ||
game.header()[color === "white" ? "WhiteElo" : "BlackElo"];
const playerName =
gameFromUrl?.[color]?.name ||
game.header()[color === "white" ? "White" : "Black"];
return (
<Grid
item
container
xs={5}
justifyContent={color === "white" ? "flex-end" : "flex-start"}
alignItems="center"
gap={0.5}
>
<Typography>
{playerName || (color === "white" ? "White" : "Black")}
</Typography>
<Typography>{rating ? `(${rating})` : "(?)"}</Typography>
</Grid>
);
}

View File

@@ -45,6 +45,7 @@ export interface CurrentPosition {
lastMove?: Move; lastMove?: Move;
eval?: PositionEval; eval?: PositionEval;
lastEval?: PositionEval; lastEval?: PositionEval;
currentMoveIdx?: number;
} }
export interface EvaluateGameParams { export interface EvaluateGameParams {