diff --git a/package-lock.json b/package-lock.json index a087840..7be6c09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@emotion/styled": "^11.11.0", "@fontsource/roboto": "^5.0.3", "@iconify/react": "^4.1.0", + "@mui/lab": "^5.0.0-alpha.165", "@mui/material": "^5.13.4", "@mui/x-data-grid": "^6.19.4", "chess.js": "^1.0.0-beta.7", @@ -569,6 +570,46 @@ "url": "https://opencollective.com/mui-org" } }, + "node_modules/@mui/lab": { + "version": "5.0.0-alpha.165", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.165.tgz", + "integrity": "sha512-8/zJStT10nh9yrAzLOPTICGhpf5YiGp/JpM0bdTP7u5AE+YT+X2u6QwMxuCrVeW8/WVLAPFg0vtzyfgPcN5T7g==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.36", + "@mui/system": "^5.15.9", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.9", + "clsx": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material": ">=5.15.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "5.15.10", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.10.tgz", diff --git a/package.json b/package.json index b6e9cd2..3a25e96 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.11.0", "@fontsource/roboto": "^5.0.3", "@iconify/react": "^4.1.0", + "@mui/lab": "^5.0.0-alpha.165", "@mui/material": "^5.13.4", "@mui/x-data-grid": "^6.19.4", "chess.js": "^1.0.0-beta.7", diff --git a/src/components/slider.tsx b/src/components/slider.tsx new file mode 100644 index 0000000..0f423af --- /dev/null +++ b/src/components/slider.tsx @@ -0,0 +1,45 @@ +import { Grid, Slider as MuiSlider, Typography } from "@mui/material"; +import { PrimitiveAtom, useAtom } from "jotai"; + +interface Props { + atom: PrimitiveAtom; + min: number; + max: number; + label: string; + xs?: number; +} + +export default function Slider({ min, max, label, atom, xs }: Props) { + const [value, setValue] = useAtom(atom); + + return ( + + + {label} + + ({ + value: i + min, + label: `${i + min}`, + }))} + valueLabelDisplay="off" + value={value} + onChange={(_, value) => setValue(value as number)} + aria-labelledby={`input-${label}`} + /> + + ); +} diff --git a/src/hooks/useCurrentMove.ts b/src/hooks/useCurrentMove.ts index cbc7bcf..8dfb5bd 100644 --- a/src/hooks/useCurrentMove.ts +++ b/src/hooks/useCurrentMove.ts @@ -1,32 +1,44 @@ import { boardAtom, gameAtom, gameEvalAtom } from "@/sections/analysis/states"; +import { MoveEval } from "@/types/eval"; +import { Move } from "chess.js"; import { useAtomValue } from "jotai"; import { useMemo } from "react"; +export type CurrentMove = Partial & { + eval?: MoveEval; + lastEval?: MoveEval; +}; + export const useCurrentMove = () => { const gameEval = useAtomValue(gameEvalAtom); const game = useAtomValue(gameAtom); const board = useAtomValue(boardAtom); - const currentEvalMove = useMemo(() => { - if (!gameEval) return undefined; + const currentMove: CurrentMove = useMemo(() => { + const move = { + ...board.history({ verbose: true }).at(-1), + }; + + if (!gameEval) return move; const boardHistory = board.history(); const gameHistory = game.history(); if ( - boardHistory.length >= gameHistory.length || - gameHistory.slice(0, boardHistory.length).join() !== boardHistory.join() - ) - return; + boardHistory.length <= gameHistory.length && + gameHistory.slice(0, boardHistory.length).join() === boardHistory.join() + ) { + const evalIndex = board.history().length; - const evalIndex = board.history().length; + return { + ...move, + eval: gameEval.moves[evalIndex], + lastEval: evalIndex > 0 ? gameEval.moves[evalIndex - 1] : undefined, + }; + } - return { - ...board.history({ verbose: true }).at(-1), - eval: gameEval.moves[evalIndex], - lastEval: evalIndex > 0 ? gameEval.moves[evalIndex - 1] : undefined, - }; + return move; }, [gameEval, board, game]); - return currentEvalMove; + return currentMove; }; diff --git a/src/hooks/useGameDatabase.ts b/src/hooks/useGameDatabase.ts index 9b850af..0ad89e7 100644 --- a/src/hooks/useGameDatabase.ts +++ b/src/hooks/useGameDatabase.ts @@ -53,6 +53,54 @@ export const useGameDatabase = (shouldFetchGames?: boolean) => { loadGames(); }, [loadGames]); + const addGame = useCallback( + async (game: Chess) => { + if (!db) throw new Error("Database not initialized"); + + const gameToAdd = formatGameToDatabase(game); + const gameId = await db.add("games", gameToAdd as Game); + + loadGames(); + + return gameId; + }, + [db, loadGames] + ); + + const setGameEval = useCallback( + async (gameId: number, evaluation: GameEval) => { + if (!db) throw new Error("Database not initialized"); + + const game = await db.get("games", gameId); + if (!game) throw new Error("Game not found"); + + await db.put("games", { ...game, eval: evaluation }); + + loadGames(); + }, + [db, loadGames] + ); + + const getGame = useCallback( + async (gameId: number) => { + if (!db) return undefined; + + return db.get("games", gameId); + }, + [db] + ); + + const deleteGame = useCallback( + async (gameId: number) => { + if (!db) throw new Error("Database not initialized"); + + await db.delete("games", gameId); + + loadGames(); + }, + [db, loadGames] + ); + const router = useRouter(); const { gameId } = router.query; @@ -62,43 +110,7 @@ export const useGameDatabase = (shouldFetchGames?: boolean) => { setGameFromUrl(game); }); } - }, [gameId, games]); - - const addGame = async (game: Chess) => { - if (!db) throw new Error("Database not initialized"); - - const gameToAdd = formatGameToDatabase(game); - const gameId = await db.add("games", gameToAdd as Game); - - loadGames(); - - return gameId; - }; - - const setGameEval = async (gameId: number, evaluation: GameEval) => { - if (!db) throw new Error("Database not initialized"); - - const game = await db.get("games", gameId); - if (!game) throw new Error("Game not found"); - - await db.put("games", { ...game, eval: evaluation }); - - loadGames(); - }; - - const getGame = async (gameId: number) => { - if (!db) return undefined; - - return db.get("games", gameId); - }; - - const deleteGame = async (gameId: number) => { - if (!db) throw new Error("Database not initialized"); - - await db.delete("games", gameId); - - loadGames(); - }; + }, [gameId, setGameFromUrl, getGame]); const isReady = db !== null; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index a3bb2df..20e465d 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -19,11 +19,11 @@ export default function GameReport() { item container rowGap={2} - paddingLeft={{ xs: 0, md: 6 }} + paddingLeft={{ xs: 0, lg: 6 }} justifyContent="center" alignItems="center" xs={12} - md={6} + lg={6} > (null); - const [evaluationInProgress, setEvaluationInProgress] = useState(false); - const { setGameEval, gameFromUrl } = useGameDatabase(); - const setEval = useSetAtom(gameEvalAtom); - const game = useAtomValue(gameAtom); - - useEffect(() => { - const engine = new Stockfish(); - engine.init().then(() => { - setEngine(engine); - }); - - return () => { - engine.shutdown(); - }; - }, []); - - const readyToAnalyse = - engine?.isReady() && game.history().length > 0 && !evaluationInProgress; - - const handleAnalyze = async () => { - const gameFens = getFens(game); - if (!engine?.isReady() || gameFens.length === 0 || evaluationInProgress) - return; - - setEvaluationInProgress(true); - - const newGameEval = await engine.evaluateGame(gameFens); - setEval(newGameEval); - - setEvaluationInProgress(false); - - if (gameFromUrl) { - setGameEval(gameFromUrl.id, newGameEval); - } - }; - - return ( - - - - ); -} diff --git a/src/sections/analysis/reviewPanelBody.tsx b/src/sections/analysis/reviewPanelBody.tsx index ae74a17..b98f1fe 100644 --- a/src/sections/analysis/reviewPanelBody.tsx +++ b/src/sections/analysis/reviewPanelBody.tsx @@ -6,26 +6,16 @@ import LineEvaluation from "./lineEvaluation"; import { useCurrentMove } from "@/hooks/useCurrentMove"; export default function ReviewPanelBody() { + const move = useCurrentMove(); const game = useAtomValue(gameAtom); const board = useAtomValue(boardAtom); - const move = useCurrentMove(); + const boardHistory = board.history(); + const gameHistory = game.history(); - const getBestMoveLabel = () => { - const bestMove = move?.lastEval?.bestMove; - if (bestMove) { - return `${bestMove} was the best move`; - } - - const boardHistory = board.history(); - const gameHistory = game.history(); - - if (game.isGameOver() && boardHistory.join() === gameHistory.join()) { - return "Game is over"; - } - - return null; - }; + const bestMove = move?.lastEval?.bestMove; + const isGameOver = + gameHistory.length > 0 && boardHistory.join() === gameHistory.join(); return ( <> @@ -49,9 +39,21 @@ export default function ReviewPanelBody() { - - {getBestMoveLabel()} - + {!!bestMove && ( + + + {`${bestMove} was the best move`} + + + )} + + {isGameOver && ( + + + Game is over + + + )} diff --git a/src/sections/analysis/reviewPanelHeader/analyzePanel.tsx b/src/sections/analysis/reviewPanelHeader/analyzePanel.tsx new file mode 100644 index 0000000..e6dc5cd --- /dev/null +++ b/src/sections/analysis/reviewPanelHeader/analyzePanel.tsx @@ -0,0 +1,111 @@ +import { Stockfish } from "@/lib/engine/stockfish"; +import { Icon } from "@iconify/react"; +import { Grid } from "@mui/material"; +import { useEffect, useState } from "react"; +import { + engineDepthAtom, + engineMultiPvAtom, + gameAtom, + gameEvalAtom, +} from "../states"; +import { useAtomValue, useSetAtom } from "jotai"; +import { getFens } from "@/lib/chess"; +import { useGameDatabase } from "@/hooks/useGameDatabase"; +import { LoadingButton } from "@mui/lab"; +import Slider from "@/components/slider"; + +export default function AnalyzePanel() { + const [engine, setEngine] = useState(null); + const [evaluationInProgress, setEvaluationInProgress] = useState(false); + const engineDepth = useAtomValue(engineDepthAtom); + const engineMultiPv = useAtomValue(engineMultiPvAtom); + const { setGameEval, gameFromUrl } = useGameDatabase(); + const setEval = useSetAtom(gameEvalAtom); + const game = useAtomValue(gameAtom); + + useEffect(() => { + const engine = new Stockfish(); + engine.init().then(() => { + setEngine(engine); + }); + + return () => { + engine.shutdown(); + }; + }, []); + + const readyToAnalyse = + engine?.isReady() && game.history().length > 0 && !evaluationInProgress; + + const handleAnalyze = async () => { + const gameFens = getFens(game); + if (!engine?.isReady() || gameFens.length === 0 || evaluationInProgress) + return; + + setEvaluationInProgress(true); + + const newGameEval = await engine.evaluateGame( + gameFens, + engineDepth, + engineMultiPv + ); + setEval(newGameEval); + + setEvaluationInProgress(false); + + if (gameFromUrl) { + setGameEval(gameFromUrl.id, newGameEval); + } + }; + + return ( + + + + + + + + ) + } + onClick={handleAnalyze} + disabled={!readyToAnalyse} + loading={evaluationInProgress} + loadingPosition={evaluationInProgress ? "end" : undefined} + endIcon={ + evaluationInProgress && ( + + ) + } + > + {evaluationInProgress ? "Analyzing..." : "Analyze"} + + + + ); +} diff --git a/src/sections/analysis/reviewPanelHeader.tsx b/src/sections/analysis/reviewPanelHeader/index.tsx similarity index 90% rename from src/sections/analysis/reviewPanelHeader.tsx rename to src/sections/analysis/reviewPanelHeader/index.tsx index d7b9f27..90e6562 100644 --- a/src/sections/analysis/reviewPanelHeader.tsx +++ b/src/sections/analysis/reviewPanelHeader/index.tsx @@ -1,7 +1,7 @@ import { Icon } from "@iconify/react"; import { Grid, Typography } from "@mui/material"; import LoadGame from "./loadGame"; -import AnalyzeButton from "./analyzeButton"; +import AnalyzePanel from "./analyzePanel"; export default function ReviewPanelHeader() { return ( @@ -29,7 +29,7 @@ export default function ReviewPanelHeader() { - + ); } diff --git a/src/sections/analysis/loadGame.tsx b/src/sections/analysis/reviewPanelHeader/loadGame.tsx similarity index 91% rename from src/sections/analysis/loadGame.tsx rename to src/sections/analysis/reviewPanelHeader/loadGame.tsx index c33492b..c328d1d 100644 --- a/src/sections/analysis/loadGame.tsx +++ b/src/sections/analysis/reviewPanelHeader/loadGame.tsx @@ -1,5 +1,5 @@ import { Grid } from "@mui/material"; -import LoadGameButton from "../loadGame/loadGameButton"; +import LoadGameButton from "../../loadGame/loadGameButton"; import { useCallback, useEffect } from "react"; import { useChessActions } from "@/hooks/useChess"; import { @@ -7,7 +7,7 @@ import { boardOrientationAtom, gameAtom, gameEvalAtom, -} from "./states"; +} from "../states"; import { useGameDatabase } from "@/hooks/useGameDatabase"; import { useAtomValue, useSetAtom } from "jotai"; import { Chess } from "chess.js"; @@ -43,7 +43,7 @@ export default function LoadGame() { }; loadGame(); - }, [gameFromUrl, resetAndSetGamePgn, setEval]); + }, [gameFromUrl, game, resetAndSetGamePgn, setEval]); if (gameFromUrl) return null; diff --git a/src/sections/analysis/reviewPanelToolbar/saveButton.tsx b/src/sections/analysis/reviewPanelToolbar/saveButton.tsx index 7727f54..c7aecea 100644 --- a/src/sections/analysis/reviewPanelToolbar/saveButton.tsx +++ b/src/sections/analysis/reviewPanelToolbar/saveButton.tsx @@ -11,11 +11,13 @@ export default function SaveButton() { const board = useAtomValue(boardAtom); const gameEval = useAtomValue(gameEvalAtom); const { addGame, setGameEval, gameFromUrl } = useGameDatabase(); - const router = useRouter(); + const enableSave = + !gameFromUrl && (board.history().length || game.history().length); + const handleSave = async () => { - if (gameFromUrl) return; + if (!enableSave) return; const gameToSave = getGameToSave(game, board); @@ -35,8 +37,16 @@ export default function SaveButton() { }; return ( - - - + <> + {gameFromUrl ? ( + + + + ) : ( + + + + )} + ); } diff --git a/src/sections/analysis/states.ts b/src/sections/analysis/states.ts index 88c84a0..66456a0 100644 --- a/src/sections/analysis/states.ts +++ b/src/sections/analysis/states.ts @@ -6,3 +6,6 @@ export const gameEvalAtom = atom(undefined); export const gameAtom = atom(new Chess()); export const boardAtom = atom(new Chess()); export const boardOrientationAtom = atom(true); + +export const engineDepthAtom = atom(16); +export const engineMultiPvAtom = atom(3);