From b09892afbe344bb1e268c41596e025a5fc8b0e5b Mon Sep 17 00:00:00 2001 From: Klimbar Date: Tue, 3 Mar 2026 18:54:28 +0530 Subject: [PATCH 1/5] refactor(board): optimize rendering, refine interactions, and fix state bugs This commit introduces a major overhaul to the chessboard rendering logic, significantly improving performance, fixing interaction bugs, and adding new shortcut features for a smoother gameplay and analysis experience. - **Custom Piece Rendering**: Replaced the default piece renderer with a highly optimized `CustomPiece` component. This prevents unnecessary re-renders across the board during interactions and uses hardware acceleration (`translateZ`) for z-index management. - **Preloaded Assets**: Added `` to `_document.tsx` for the default `maestro` king and pawn SVGs, drastically cutting down the perceived initial load time. - **Zero-Latency Drops**: Dynamically disables piece transition animations when a piece is dropped to ensure instantaneous visual snapping. - **Jotai State Granularity**: Refactored `currentPositionAtom` usage to derive primitive values, preventing the board from re-rendering every time the complex engine evaluation object updates. - **Drag Cancellation Fix**: Fixed a bug where a right-click during a piece drag would incorrectly drop the piece if it's a movable square. Implemented a custom ghost-piece system `animateReturnFlight()` to smoothly fly the piece back to its origin square upon cancellation. - **Annotation Modifiers**: - `Ctrl + Right Click: Draws **red** arrows with **yellow** squares. - `Alt` + Right Click: Draws **blue** arrows with **blue** squares. - Default Right Click remains **yellow** arrows with **red** squares. - **Spacebar Best Move Shortcut**: Implemented a global keyboard listener (ignoring input fields) that allows users to instantly execute the engine's current top recommended move by pressing the spacebar. - **Check Visuals and Audio**: Added a red drop-shadow `filter` to the King when in check. Also integrated a new `check.mp3` sound effect to trigger on checking moves. - **Stale State Prevention**: Added FEN tracking to the `CurrentPosition` type. This ensures that old engine lines and previous opening names don't briefly flash on the UI during rapid, multi-move sequences. - **CSS Improvements**: Added `board.css` to remove mobile swipe defaults (`touch-action: none`), improve piece dragging cursors, and fix z-index clipping issues with the promotion dialog. - Added `bench.js` at the root directory for profiling `chess.js` history and PGN loading performance. - Added a circle indicator to the capturable pieces --- .gitignore | 2 + bench.js | 24 + package.json | 2 +- public/sounds/check.mp3 | Bin 0 -> 6121 bytes src/components/board/board.css | 48 + src/components/board/capturedPieces.tsx | 8 +- src/components/board/evaluationBar.tsx | 8 +- src/components/board/index.tsx | 1148 +++++++++++++++-- src/components/board/playerHeader.tsx | 12 +- src/components/board/squareRenderer.tsx | 203 ++- src/components/board/types.ts | 12 + src/hooks/useChessActions.ts | 36 +- src/hooks/useEngine.ts | 13 + src/lib/chess.ts | 44 +- src/lib/engine/uciEngine.ts | 25 +- src/lib/engine/worker.ts | 3 +- src/lib/lichess.ts | 9 +- src/lib/sounds.ts | 44 +- src/pages/_document.tsx | 24 + src/sections/analysis/board/index.tsx | 2 + .../analysis/hooks/useCurrentPosition.ts | 1 + .../analysisTab/engineLines/index.tsx | 45 +- .../engineLines/lineEvaluation.tsx | 4 + .../panelBody/analysisTab/opening.tsx | 6 +- src/sections/analysis/states.ts | 2 + src/types/eval.ts | 1 + 26 files changed, 1483 insertions(+), 243 deletions(-) create mode 100644 bench.js create mode 100644 public/sounds/check.mp3 create mode 100644 src/components/board/board.css create mode 100644 src/components/board/types.ts diff --git a/.gitignore b/.gitignore index 87904c12..c8bdcb5f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ tsconfig.tsbuildinfo .cdk.staging cdk.out cdk.context.json + +/docs \ No newline at end of file diff --git a/bench.js b/bench.js new file mode 100644 index 00000000..837efd66 --- /dev/null +++ b/bench.js @@ -0,0 +1,24 @@ +const { Chess } = require('chess.js'); +const c = new Chess(); +for(let i=0; i<40; i++) { + const moves = c.moves(); + c.move(moves[Math.floor(Math.random() * moves.length)]); +} +const pgn = c.pgn(); +const history = c.history({verbose:true}); + +console.time('loadPgn'); +for(let i=0; i<100; i++) { + const c2 = new Chess(); + c2.loadPgn(pgn); +} +console.timeEnd('loadPgn'); + +console.time('replayHistory'); +for(let i=0; i<100; i++) { + const c3 = new Chess(); + for(const m of history) { + c3.move(m); + } +} +console.timeEnd('replayHistory'); diff --git a/package.json b/package.json index 5c51c6b8..33ee3acc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "license": "GPL-3.0-only", "scripts": { - "dev": "next dev --turbo", + "dev": "SENTRY_SUPPRESS_TURBOPACK_WARNING=1 next dev --turbo", "build": "next build", "start": "next start", "lint": "next lint && tsc --noEmit", diff --git a/public/sounds/check.mp3 b/public/sounds/check.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..82cce2158953c4e0047c3bb8cb5ecc3278bcee85 GIT binary patch literal 6121 zcmd^@S5Q+?w}4LqL^`2qC`#xZsY=z*2{lrrcS4sEih$BvKzb1nkS-{QG!ZEhniLU4 z5D-OL=pbFC=7P>Y_rBcM`*OdTGqd*Ed#|(Rn|=1fL8?d)1LuI)3{`cN&jShoAVOh0 z;f9``?mmE_iHXU1lJh+2iE(jq@v!yv#8_j{j%W1#R#`5U+ahAR3O z6)4Xai;IfM2#ZPzi;MlI1oL^;*X17ie{2@JDK0H~0bpogpnATO=6sE|t-qs`w1~Kj zh}ikWe?vGn{O`g45+OYtJoi{?Mf{^9nQ2meU;N8Vpv{-f<5 zLx1`4kF9?Y{&LaEdC&IeJxfVm4E_Tbc<*0$1)<2c9z{6LZ~y@CFNwa_RB®G3NK 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 Cseq^e literal 0 HcmV?d00001 diff --git a/src/components/board/board.css b/src/components/board/board.css new file mode 100644 index 00000000..fd54519f --- /dev/null +++ b/src/components/board/board.css @@ -0,0 +1,48 @@ +/* Override react-chessboard's piece slide animation easing. + ease-out-cubic: snappy initial velocity, smooth deceleration. */ +[data-boardid] div[style*="transition: transform"] { + transition-timing-function: cubic-bezier(0.33, 1, 0.68, 1) !important; + z-index: 300 !important; +} + +/* Temporarily disable animations for zero-latency drops */ +.disable-piece-animations div[style*="transition: transform"] { + transition: none !important; +} + +/* Piece cursor: hand on hover */ +[data-piece] { + cursor: grab !important; + touch-action: none !important; /* Prevent mobile page swipe on pieces */ + -webkit-user-drag: none !important; /* Prevent browser-default image drag */ +} + +/* Disable text/element selection on the board */ +[data-boardid], +[data-boardid] * { + user-select: none !important; + -webkit-user-select: none !important; +} + +/* Piece return flight */ +.piece-return-ghost { + position: absolute; + pointer-events: none; + z-index: 15; + will-change: transform; + transition: transform 150ms cubic-bezier(0.33, 1, 0.68, 1); +} + +/* Ensure react-chessboard promotion dialog rises above hardware-accelerated custom pieces */ +[data-boardid] > div:not([data-piece]):not([data-square]) { + z-index: 1000 !important; + transform: translateZ(1px); +} + +/* Prevent the board container from cutting off the overflowing promotion dialog */ +[data-boardid], +[data-boardid] div { + overflow: visible !important; + clip-path: none !important; + transform-style: preserve-3d !important; +} diff --git a/src/components/board/capturedPieces.tsx b/src/components/board/capturedPieces.tsx index ddd4d949..eba56d20 100644 --- a/src/components/board/capturedPieces.tsx +++ b/src/components/board/capturedPieces.tsx @@ -1,7 +1,7 @@ import { getCapturedPieces, getMaterialDifference } from "@/lib/chess"; import { Color } from "@/types/enums"; import { Box, Grid2 as Grid, Stack, Typography } from "@mui/material"; -import { ReactElement, useMemo } from "react"; +import { ReactElement, useMemo, memo } from "react"; export interface Props { fen: string; @@ -10,7 +10,7 @@ export interface Props { const PIECE_SCALE = 0.55; -export default function CapturedPieces({ fen, color }: Props) { +const CapturedPieces = memo(function CapturedPieces({ fen, color }: Props) { const piecesComponents = useMemo(() => { const capturedPieces = getCapturedPieces(fen, color); return capturedPieces.map(({ piece, count }) => @@ -46,7 +46,7 @@ export default function CapturedPieces({ fen, color }: Props) { )} ); -} +}); const getCapturedPiecesComponents = ( pieceSymbol: string, @@ -74,3 +74,5 @@ const getCapturedPiecesComponents = ( ); }; + +export default CapturedPieces; diff --git a/src/components/board/evaluationBar.tsx b/src/components/board/evaluationBar.tsx index 78d33dd8..fcbd0ccf 100644 --- a/src/components/board/evaluationBar.tsx +++ b/src/components/board/evaluationBar.tsx @@ -1,6 +1,6 @@ import { Box, Grid2 as Grid, Typography } from "@mui/material"; import { PrimitiveAtom, atom, useAtomValue } from "jotai"; -import { useEffect, useState } from "react"; +import { useEffect, useState, memo } from "react"; import { getEvaluationBarValue } from "@/lib/chess"; import { Color } from "@/types/enums"; import { CurrentPosition } from "@/types/eval"; @@ -11,7 +11,7 @@ interface Props { currentPositionAtom?: PrimitiveAtom; } -export default function EvaluationBar({ +const EvaluationBar = memo(function EvaluationBar({ height, boardOrientation, currentPositionAtom = atom({}), @@ -107,4 +107,6 @@ export default function EvaluationBar({ ); -} +}); + +export default EvaluationBar; diff --git a/src/components/board/index.tsx b/src/components/board/index.tsx index 40cf5ca5..be78320a 100644 --- a/src/components/board/index.tsx +++ b/src/components/board/index.tsx @@ -1,26 +1,73 @@ -import { Box, Grid2 as Grid } from "@mui/material"; +import { Grid2 as Grid } from "@mui/material"; import { Chessboard } from "react-chessboard"; -import { PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; +import { PrimitiveAtom, atom, useAtomValue, useSetAtom, Atom } from "jotai"; import { Arrow, CustomPieces, - CustomSquareRenderer, Piece, PromotionPieceOption, Square, } from "react-chessboard/dist/chessboard/types"; import { useChessActions } from "@/hooks/useChessActions"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + memo, +} from "react"; import { Color, MoveClassification } from "@/types/enums"; import { Chess } from "chess.js"; -import { getSquareRenderer } from "./squareRenderer"; import { CurrentPosition } from "@/types/eval"; import EvaluationBar from "./evaluationBar"; import { CLASSIFICATION_COLORS } from "@/constants"; import { Player } from "@/types/game"; import PlayerHeader from "./playerHeader"; import { boardHueAtom, pieceSetAtom } from "./states"; +import type { ClickedSquare } from "./types"; import tinycolor from "tinycolor2"; +import "./board.css"; +import { createContext, useContext } from "react"; + +const clickedSquaresAtom = atom([]); +const playableSquaresAtom = atom([]); +const captureSquaresAtom = atom([]); +const moveClickFromAtom = atom(null); +const moveClickToAtom = atom(null); + + +const defaultCurrentPositionAtom = atom({} as CurrentPosition); +const defaultShowPlayerMoveIconAtom = atom(false); + +export const BoardStateContext = createContext<{ + pieceSet: string; + checkSquare: Square | null; + turn: "w" | "b"; + boardHue: number; + boardSize: number; + currentPositionAtom: Atom; + clickedSquaresAtom: Atom; + playableSquaresAtom: Atom; + captureSquaresAtom: Atom; + showPlayerMoveIconAtom: Atom; + moveClickFromAtom: Atom; +}>({ + pieceSet: "maestro", + checkSquare: null, + turn: "w", + boardHue: 0, + boardSize: 400, + currentPositionAtom: defaultCurrentPositionAtom, + clickedSquaresAtom, + playableSquaresAtom, + captureSquaresAtom, + showPlayerMoveIconAtom: atom(false), + moveClickFromAtom, +}); + +import { getSquareRenderer } from "./squareRenderer"; export interface Props { id: string; @@ -34,9 +81,80 @@ export interface Props { showBestMoveArrow?: boolean; showPlayerMoveIconAtom?: PrimitiveAtom; showEvaluationBar?: boolean; + animationDurationAtom?: PrimitiveAtom; } -export default function Board({ +const CustomPiece = memo( + ({ + squareWidth, + isDragging, + piece, + }: { + squareWidth: number; + isDragging: boolean; + piece: string; + }) => { + const { pieceSet, checkSquare, turn, boardHue } = useContext(BoardStateContext); + + const isCheck = + (piece === "wK" && turn === "w" && checkSquare) || + (piece === "bK" && turn === "b" && checkSquare); + + const hueFilter = boardHue ? `hue-rotate(-${boardHue}deg)` : ""; + + const checkShadow = isCheck + ? "drop-shadow(0 0 8px rgba(235, 97, 80, 1)) drop-shadow(0 0 16px rgba(235, 97, 80, 0.8))" + : ""; + const dragShadow = isDragging + ? "drop-shadow(0 6px 8px rgba(0, 0, 0, 0.25))" + : "drop-shadow(0 0px 0px rgba(0, 0, 0, 0))"; + + return ( +
+ ); + } +); + +CustomPiece.displayName = "CustomPiece"; + +export const PIECE_CODES = [ + "wP", + "wB", + "wN", + "wR", + "wQ", + "wK", + "bP", + "bB", + "bN", + "bR", + "bQ", + "bK", +] as const satisfies Piece[]; + + + +function Board({ id: boardId, canPlay, gameAtom, @@ -44,33 +162,173 @@ export default function Board({ whitePlayer, blackPlayer, boardOrientation = Color.White, - currentPositionAtom = atom({}), + currentPositionAtom = defaultCurrentPositionAtom, showBestMoveArrow = false, showPlayerMoveIconAtom, showEvaluationBar = false, + animationDurationAtom, }: Props) { const boardRef = useRef(null); const game = useAtomValue(gameAtom); - const { playMove } = useChessActions(gameAtom); - const clickedSquaresAtom = useMemo(() => atom([]), []); + const { playMove, undoMove } = useChessActions(gameAtom); + const pieceSet = useAtomValue(pieceSetAtom); + const boardHue = useAtomValue(boardHueAtom); + + const checkSquareAtom = useMemo( + () => + atom((get) => { + const game = get(gameAtom); + if (!game.inCheck() && !game.isCheckmate()) return null; + const turn = game.turn(); + return ( + game + .board() + .flat() + .find((p) => p?.type === "k" && p?.color === turn)?.square ?? null + ); + }), + [gameAtom] + ); + + // Jotai Setters const setClickedSquares = useSetAtom(clickedSquaresAtom); - const playableSquaresAtom = useMemo(() => atom([]), []); const setPlayableSquares = useSetAtom(playableSquaresAtom); - const position = useAtomValue(currentPositionAtom); + const setCaptureSquares = useSetAtom(captureSquaresAtom); + + + // Jotai Getters (derived state) + const [moveClickFrom, setMoveClickFrom] = [ + useAtomValue(moveClickFromAtom), + useSetAtom(moveClickFromAtom), + ]; + const [moveClickTo, setMoveClickTo] = [ + useAtomValue(moveClickToAtom), + useSetAtom(moveClickToAtom), + ]; + + const checkSquare = useAtomValue(checkSquareAtom); + + const customPieces = useMemo(() => { + return PIECE_CODES.reduce((acc, pieceCode) => { + acc[pieceCode] = (props) => ( + + ); + return acc; + }, {}); + }, []); + + // Derive only the specific primitive values Board needs from currentPositionAtom. + const arrowBestMove = useAtomValue( + useMemo( + () => atom((get) => get(currentPositionAtom)?.lastEval?.bestMove), + [currentPositionAtom] + ) + ); + const arrowMoveClassification = useAtomValue( + useMemo( + () => atom((get) => get(currentPositionAtom)?.eval?.moveClassification), + [currentPositionAtom] + ) + ); + + // Local State + const [userArrows, setUserArrows] = useState([]); + const [newArrow, setNewArrow] = useState(null); const [showPromotionDialog, setShowPromotionDialog] = useState(false); - const [moveClickFrom, setMoveClickFrom] = useState(null); - const [moveClickTo, setMoveClickTo] = useState(null); - const pieceSet = useAtomValue(pieceSetAtom); - const boardHue = useAtomValue(boardHueAtom); + const [localAnimationDuration, setLocalAnimationDuration] = useState(150); - const gameFen = game.fen(); + // Animation Duration Logic + const externalAnimationDuration = useAtomValue( + useMemo(() => animationDurationAtom || atom(150), [animationDurationAtom]) + ); + const setExternalAnimationDuration = useSetAtom( + useMemo(() => animationDurationAtom || atom(150), [animationDurationAtom]) + ); + + const animationDurationToUse = animationDurationAtom ? externalAnimationDuration : localAnimationDuration; + const setAnimationDurationToUse = useCallback((duration: number) => { + if (animationDurationAtom) { + setExternalAnimationDuration(duration); + } else { + setLocalAnimationDuration(duration); + } + }, [animationDurationAtom, setExternalAnimationDuration]); + + // Refs for event handling and drag state + const isAltPressedRef = useRef(false); + const isCtrlPressedRef = useRef(false); + const isDraggingRef = useRef(false); + const shouldCancelDragRef = useRef(false); + const lastRightClickRef = useRef(0); + const dragCancelledRef = useRef(0); + const rightClickDownRef = useRef(false); + const lastRightClickUpTimeRef = useRef(0); + const lastDropMoveTimeRef = useRef(0); + const dragOriginSquareRef = useRef(null); + const dragPieceRef = useRef(null); + const rightClickDragStartRef = useRef(null); + + + // Custom pointer drag refs + const customDragGhostRef = useRef(null); + const dragStartPosRef = useRef<{ + x: number; + y: number; + constraints?: { + minDx: number; + maxDx: number; + minDy: number; + maxDy: number; + }; + } | null>(null); + const draggedPieceElementRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Alt") isAltPressedRef.current = true; + if (e.key === "Control" || e.key === "Meta") + isCtrlPressedRef.current = true; + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "Alt") isAltPressedRef.current = false; + if (e.key === "Control" || e.key === "Meta") + isCtrlPressedRef.current = false; + }; + const handleBlur = () => { + isAltPressedRef.current = false; + isCtrlPressedRef.current = false; + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, []); + + const gameFen = useMemo(() => game.fen(), [game]); useEffect(() => { setClickedSquares([]); + setUserArrows([]); }, [gameFen, setClickedSquares]); const isPiecePlayable = useCallback( ({ piece }: { piece: string }): boolean => { + if ( + rightClickDownRef.current || + Date.now() - lastRightClickUpTimeRef.current < 250 || + Date.now() - lastRightClickRef.current < 250 || + Date.now() - dragCancelledRef.current < 250 + ) { + return false; + } + if (game.isGameOver() || !canPlay) return false; if (canPlay === true || canPlay === piece[0]) return true; return false; @@ -78,71 +336,642 @@ export default function Board({ [canPlay, game] ); + const resetMoveClick = useCallback( + (square?: Square | null) => { + setMoveClickFrom(square ?? null); + setMoveClickTo(null); + setShowPromotionDialog(false); + if (square) { + const moves = game.moves({ square, verbose: true }); + setPlayableSquares(moves.map((m) => m.to)); + setCaptureSquares(moves.filter((m) => m.captured).map((m) => m.to)); + } else { + setPlayableSquares([]); + setCaptureSquares([]); + } + }, + [ + setMoveClickFrom, + setMoveClickTo, + setPlayableSquares, + setCaptureSquares, + game, + ] + ); + + const getSquareFromCoords = useCallback( + (clientX: number, clientY: number): Square | null => { + if (!boardRef.current) return null; + const rect = boardRef.current.getBoundingClientRect(); + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null; + } + + const squareSize = rect.width / 8; + const col = Math.floor((clientX - rect.left) / squareSize); + const row = Math.floor((clientY - rect.top) / squareSize); + + if (col < 0 || col > 7 || row < 0 || row > 7) return null; + + const file = + boardOrientation === Color.White + ? String.fromCharCode(97 + col) + : String.fromCharCode(97 + (7 - col)); + const rank = + boardOrientation === Color.White ? String(8 - row) : String(row + 1); + + return `${file}${rank}` as Square; + }, + [boardOrientation] + ); + + + + const animateReturnFlight = useCallback( + ( + sourceSquare: Square, + piece: string, + existingGhost?: HTMLDivElement | null, + draggedPiece?: HTMLElement | null + ) => { + const boardElement = boardRef.current; + const targetSquareElement = boardElement?.querySelector( + `[data-square="${sourceSquare}"]` + ) as HTMLElement; + + if (!targetSquareElement || !boardElement) { + if (existingGhost) existingGhost.remove(); + if (draggedPiece) draggedPiece.style.opacity = "1"; + return; + } + + const targetRect = targetSquareElement.getBoundingClientRect(); + + if (existingGhost) { + // Animate the actual custom drag ghost back to its origin square + // We override its transform to fly to the origin instead of translating + const flightTimeMs = 150; + existingGhost.style.transition = `top ${flightTimeMs}ms ease-out, left ${flightTimeMs}ms ease-out, transform ${flightTimeMs}ms ease-out`; + existingGhost.style.transform = "scale(1.0) translate(0px, 0px)"; + existingGhost.style.top = `${targetRect.top}px`; + existingGhost.style.left = `${targetRect.left}px`; + + setTimeout(() => { + if (existingGhost.parentNode) { + existingGhost.remove(); + } + if (draggedPiece) draggedPiece.style.opacity = "1"; + }, flightTimeMs); + } else { + // This is the fallback fading ghost for invalid drops + const ghost = document.createElement("div"); + ghost.style.position = "fixed"; + ghost.style.top = `${targetRect.top}px`; + ghost.style.left = `${targetRect.left}px`; + ghost.style.width = `${targetRect.width}px`; + ghost.style.height = `${targetRect.height}px`; + ghost.style.backgroundImage = `url(/piece/${pieceSet}/${piece}.svg)`; + ghost.style.backgroundSize = "contain"; + ghost.style.backgroundRepeat = "no-repeat"; + ghost.style.backgroundPosition = "center"; + ghost.style.pointerEvents = "none"; + ghost.style.zIndex = "100"; + ghost.classList.add("piece-return-ghost"); + + document.body.appendChild(ghost); + + setTimeout(() => { + if (ghost.parentNode) ghost.remove(); + if (draggedPiece) draggedPiece.style.opacity = "1"; + }, 150); + } + }, + [pieceSet] + ); + + const abortCustomDrag = useCallback(() => { + if (!isDraggingRef.current) return; + dragCancelledRef.current = Date.now(); + shouldCancelDragRef.current = true; + isDraggingRef.current = false; + setClickedSquares((prev) => [...prev]); + resetMoveClick(); + + // Let the ghost fly back + if ( + dragOriginSquareRef.current && + dragPieceRef.current && + customDragGhostRef.current + ) { + animateReturnFlight( + dragOriginSquareRef.current, + dragPieceRef.current, + customDragGhostRef.current, + draggedPieceElementRef.current + ); + } else { + // Cleanup immediately if no ghost was created yet + if (customDragGhostRef.current) { + customDragGhostRef.current.remove(); + } + if (draggedPieceElementRef.current) { + draggedPieceElementRef.current.style.opacity = "1"; + } + } + + customDragGhostRef.current = null; + draggedPieceElementRef.current = null; + dragOriginSquareRef.current = null; + dragPieceRef.current = null; + }, [resetMoveClick, setClickedSquares, animateReturnFlight]); + + const handleGlobalPointerMove = useCallback((e: PointerEvent) => { + e.preventDefault(); + if (!customDragGhostRef.current || !dragStartPosRef.current) return; + + const rawDx = e.clientX - dragStartPosRef.current.x; + const rawDy = e.clientY - dragStartPosRef.current.y; + + // Threshold to prevent ghost initialization on pure clicks + if (Math.abs(rawDx) > 3 || Math.abs(rawDy) > 3) { + if (!customDragGhostRef.current.parentNode) { + document.body.appendChild(customDragGhostRef.current); + if (draggedPieceElementRef.current) { + draggedPieceElementRef.current.style.opacity = "0"; + } + } + + let dx = rawDx; + let dy = rawDy; + + const constraints = dragStartPosRef.current.constraints; + if (constraints) { + dx = Math.max(constraints.minDx, Math.min(dx, constraints.maxDx)); + dy = Math.max(constraints.minDy, Math.min(dy, constraints.maxDy)); + } + + const translateX = Math.round(dx / 1.05); + const translateY = Math.round(dy / 1.05); + customDragGhostRef.current.style.transform = `scale(1.05) translate(${translateX}px, ${translateY}px)`; + } + }, []); + + const handleGlobalPointerMoveRightClick = useCallback( + (e: PointerEvent) => { + e.preventDefault(); + if (!rightClickDragStartRef.current) return; + const hoverSquare = getSquareFromCoords(e.clientX, e.clientY); + if (hoverSquare) { + const color = isAltPressedRef.current + ? "#70bbd9" + : isCtrlPressedRef.current + ? "#eb6150" + : "#ffaa00"; + setNewArrow([rightClickDragStartRef.current, hoverSquare, color]); + } + }, + [getSquareFromCoords] + ); + + const handleGlobalPointerUpRightClick = useCallback( + (e: PointerEvent) => { + document.removeEventListener( + "pointermove", + handleGlobalPointerMoveRightClick + ); + document.removeEventListener( + "pointerup", + handleGlobalPointerUpRightClick + ); + + const startSquare = rightClickDragStartRef.current; + rightClickDragStartRef.current = null; + setNewArrow(null); + + if (!startSquare) return; + const hoverSquare = getSquareFromCoords(e.clientX, e.clientY); + + if (hoverSquare && hoverSquare !== startSquare) { + const finalColor = isAltPressedRef.current + ? "#70bbd9" + : isCtrlPressedRef.current + ? "#eb6150" + : "#ffaa00"; + const finalArrow = [startSquare, hoverSquare, finalColor] as Arrow; + setUserArrows((prev) => { + const existing = prev.find( + (a) => a[0] === finalArrow[0] && a[1] === finalArrow[1] + ); + if (existing) { + if (existing[2] === finalArrow[2]) { + return prev.filter((a) => a !== existing); + } else { + return [...prev.filter((a) => a !== existing), finalArrow]; + } + } + return [...prev, finalArrow]; + }); + } + }, + [ + getSquareFromCoords, + handleGlobalPointerMoveRightClick, + isAltPressedRef, + isCtrlPressedRef, + ] + ); + const onPieceDrop = useCallback( (source: Square, target: Square, piece: string): boolean => { + if ( + shouldCancelDragRef.current || + rightClickDownRef.current || + Date.now() - lastRightClickUpTimeRef.current < 250 || + Date.now() - lastRightClickRef.current < 250 || + Date.now() - dragCancelledRef.current < 250 + ) { + return false; + } + if (!isPiecePlayable({ piece })) return false; + setAnimationDurationToUse(0); + const result = playMove({ from: source, to: target, promotion: piece[1]?.toLowerCase() ?? "q", }); + if (result) { + lastDropMoveTimeRef.current = Date.now(); + } + return !!result; }, [isPiecePlayable, playMove] ); - const resetMoveClick = useCallback( - (square?: Square | null) => { - setMoveClickFrom(square ?? null); - setMoveClickTo(null); - setShowPromotionDialog(false); - if (square) { - const moves = game.moves({ square, verbose: true }); - setPlayableSquares(moves.map((m) => m.to)); + const handleGlobalPointerUp = useCallback( + (e: PointerEvent) => { + document.removeEventListener("pointermove", handleGlobalPointerMove); + document.removeEventListener("pointerup", handleGlobalPointerUp); + + if (!isDraggingRef.current || shouldCancelDragRef.current) { + return; + } + + const targetSquare = getSquareFromCoords(e.clientX, e.clientY); + const sourceSquare = dragOriginSquareRef.current; + const piece = dragPieceRef.current; + + const wasDraggingVisibly = !!customDragGhostRef.current?.parentNode; + let moveSucceeded = false; + let isPendingPromotion = false; + + if ( + wasDraggingVisibly && + targetSquare && + sourceSquare && + piece && + targetSquare !== sourceSquare + ) { + const validMoves = game.moves({ square: sourceSquare, verbose: true }); + let move = validMoves.find((m) => m.to === targetSquare); + let actualTargetSquare = targetSquare; + + if (!move) { + const epMove = validMoves.find( + (m) => m.isEnPassant() && m.to[0] + m.from[1] === targetSquare + ); + if (epMove) { + move = epMove; + actualTargetSquare = epMove.to as Square; + } + } + + if ( + move && + move.piece === "p" && + ((move.color === "w" && actualTargetSquare[1] === "8") || + (move.color === "b" && actualTargetSquare[1] === "1")) + ) { + isPendingPromotion = true; + setAnimationDurationToUse(0); + setMoveClickFrom(sourceSquare); + setMoveClickTo(actualTargetSquare); + setShowPromotionDialog(true); + } else { + moveSucceeded = onPieceDrop(sourceSquare, actualTargetSquare, piece); + } + } + + if ( + !moveSucceeded && + !isPendingPromotion && + wasDraggingVisibly && + sourceSquare && + piece + ) { + animateReturnFlight( + sourceSquare, + piece, + customDragGhostRef.current, + draggedPieceElementRef.current + ); + } else if (moveSucceeded && wasDraggingVisibly && targetSquare) { + if (customDragGhostRef.current) { + customDragGhostRef.current.remove(); + } } else { + if (customDragGhostRef.current) { + customDragGhostRef.current.remove(); + } + if (draggedPieceElementRef.current) { + draggedPieceElementRef.current.style.opacity = "1"; + } + } + + customDragGhostRef.current = null; + draggedPieceElementRef.current = null; + isDraggingRef.current = false; + if (wasDraggingVisibly) { setPlayableSquares([]); + setCaptureSquares([]); } + dragOriginSquareRef.current = null; + dragPieceRef.current = null; + shouldCancelDragRef.current = false; }, - [setMoveClickFrom, setMoveClickTo, setPlayableSquares, game] + [ + getSquareFromCoords, + onPieceDrop, + animateReturnFlight, + setPlayableSquares, + setCaptureSquares, + handleGlobalPointerMove, + game, + setMoveClickFrom, + setMoveClickTo, + ] + ); + + const handleBoardPointerDownCapture = useCallback( + (e: React.PointerEvent) => { + if (e.button === 2) { + rightClickDownRef.current = true; + lastRightClickRef.current = Date.now(); + if (isDraggingRef.current) { + e.stopPropagation(); + abortCustomDrag(); + } else { + const target = e.target as HTMLElement; + const squareElement = target.closest("[data-square]") as HTMLElement; + const square = squareElement?.dataset.square as Square; + if (square) { + rightClickDragStartRef.current = square; + const color = isAltPressedRef.current + ? "#70bbd9" + : isCtrlPressedRef.current + ? "#eb6150" + : "#ffaa00"; + setNewArrow([square, square, color]); + document.addEventListener( + "pointermove", + handleGlobalPointerMoveRightClick + ); + document.addEventListener( + "pointerup", + handleGlobalPointerUpRightClick + ); + } + } + return; + } + + if (e.button === 0) { + // Prevent browser default selection/drag on the entire board + e.preventDefault(); + + setClickedSquares([]); + setUserArrows([]); + + const target = e.target as HTMLElement; + const pieceElement = target.closest("[data-piece]") as HTMLElement; + if (!pieceElement) return; + + const piece = pieceElement.dataset.piece; + const squareElement = pieceElement.closest( + "[data-square]" + ) as HTMLElement; + const square = squareElement?.dataset.square as Square; + + if (moveClickFrom) { + const validMoves = game.moves({ + square: moveClickFrom, + verbose: true, + }); + + let move = validMoves.find((m) => m.to === square); + let actualTargetSquare = square; + + if (!move) { + const epMove = validMoves.find( + (m) => m.isEnPassant() && m.to[0] + m.from[1] === square + ); + if (epMove) { + move = epMove; + actualTargetSquare = epMove.to as Square; + } + } + + if (move) { + e.preventDefault(); + e.stopPropagation(); + + if ( + move.piece === "p" && + ((move.color === "w" && actualTargetSquare[1] === "8") || + (move.color === "b" && actualTargetSquare[1] === "1")) + ) { + setAnimationDurationToUse(150); + setMoveClickTo(actualTargetSquare); + setShowPromotionDialog(true); + return; + } + + setAnimationDurationToUse(150); + playMove({ + from: moveClickFrom, + to: actualTargetSquare, + }); + + resetMoveClick(undefined); + return; + } + } + + if (!piece || !square || !isPiecePlayable({ piece })) return; + + shouldCancelDragRef.current = false; + isDraggingRef.current = true; + dragOriginSquareRef.current = square; + dragPieceRef.current = piece; + + setMoveClickFrom(null); + setMoveClickTo(null); + setShowPromotionDialog(false); + const moves = game.moves({ square, verbose: true }); + setPlayableSquares(moves.map((m) => m.to)); + setCaptureSquares(moves.filter((m) => m.captured).map((m) => m.to)); + + const rect = pieceElement.getBoundingClientRect(); + const ghost = document.createElement("div"); + ghost.style.position = "fixed"; + ghost.style.top = `${rect.top}px`; + ghost.style.left = `${rect.left}px`; + ghost.style.width = `${rect.width}px`; + ghost.style.height = `${rect.height}px`; + ghost.style.backgroundImage = `url(/piece/${pieceSet}/${piece}.svg)`; + ghost.style.backgroundSize = "contain"; + ghost.style.backgroundRepeat = "no-repeat"; + ghost.style.backgroundPosition = "center"; + ghost.style.pointerEvents = "none"; + ghost.style.zIndex = "9999"; + ghost.style.transform = "scale(1.05)"; + ghost.style.filter = "drop-shadow(0 4px 10px rgba(0,0,0,0.5))"; + ghost.style.transition = "transform 0.05s linear"; + + customDragGhostRef.current = ghost; + draggedPieceElementRef.current = pieceElement; + + let constraints; + if (boardRef.current) { + const squares = Array.from( + boardRef.current.querySelectorAll("[data-square]") + ) as HTMLElement[]; + + if (squares.length > 0) { + let left = Infinity, + right = -Infinity, + top = Infinity, + bottom = -Infinity; + for (const s of squares) { + const r = s.getBoundingClientRect(); + if (r.left < left) left = r.left; + if (r.right > right) right = r.right; + if (r.top < top) top = r.top; + if (r.bottom > bottom) bottom = r.bottom; + } + left = Math.ceil(left); + right = Math.floor(right); + top = Math.ceil(top); + bottom = Math.floor(bottom); + constraints = { + minDx: left - rect.left, + maxDx: right - rect.right, + minDy: top - rect.top, + maxDy: bottom - rect.bottom, + }; + } + } + + dragStartPosRef.current = { x: e.clientX, y: e.clientY, constraints }; + document.addEventListener("pointermove", handleGlobalPointerMove); + document.addEventListener("pointerup", handleGlobalPointerUp); + } + }, + [ + resetMoveClick, + setClickedSquares, + isPiecePlayable, + game, + pieceSet, + setPlayableSquares, + setCaptureSquares, + abortCustomDrag, + moveClickFrom, + setMoveClickFrom, + handleGlobalPointerMove, + handleGlobalPointerUp, + playMove, + setMoveClickTo, + handleGlobalPointerMoveRightClick, + handleGlobalPointerUpRightClick, + ] ); const handleSquareLeftClick = useCallback( (square: Square, piece?: string) => { + if (isDraggingRef.current || shouldCancelDragRef.current) return; + + if ( + rightClickDownRef.current || + Date.now() - lastRightClickUpTimeRef.current < 250 || + Date.now() - lastRightClickRef.current < 250 || + Date.now() - dragCancelledRef.current < 250 + ) { + return; + } setClickedSquares([]); + setUserArrows([]); + + if (moveClickFrom === square) { + resetMoveClick(); + return; + } if (!moveClickFrom) { - if (piece && !isPiecePlayable({ piece })) return; + if (!piece) return; + if (!isPiecePlayable({ piece })) return; resetMoveClick(square); return; } const validMoves = game.moves({ square: moveClickFrom, verbose: true }); - const move = validMoves.find((m) => m.to === square); + let move = validMoves.find((m) => m.to === square); + let actualTargetSquare = square; if (!move) { - resetMoveClick(square); + const epMove = validMoves.find( + (m) => m.isEnPassant() && m.to[0] + m.from[1] === square + ); + if (epMove) { + move = epMove; + actualTargetSquare = epMove.to as Square; + } + } + + if (!move) { + resetMoveClick(piece ? square : undefined); return; } - setMoveClickTo(square); + setMoveClickTo(actualTargetSquare); if ( move.piece === "p" && - ((move.color === "w" && square[1] === "8") || - (move.color === "b" && square[1] === "1")) + ((move.color === "w" && actualTargetSquare[1] === "8") || + (move.color === "b" && actualTargetSquare[1] === "1")) ) { + setAnimationDurationToUse(150); setShowPromotionDialog(true); return; } + setAnimationDurationToUse(150); const result = playMove({ from: moveClickFrom, - to: square, + to: actualTargetSquare, }); - resetMoveClick(result ? undefined : square); + + resetMoveClick(result ? undefined : piece ? square : undefined); }, [ game, @@ -151,30 +980,126 @@ export default function Board({ playMove, resetMoveClick, setClickedSquares, + setMoveClickTo, ] ); const handleSquareRightClick = useCallback( (square: Square) => { - setClickedSquares((prev) => - prev.includes(square) - ? prev.filter((s) => s !== square) - : [...prev, square] - ); + if ( + isDraggingRef.current || + shouldCancelDragRef.current || + Date.now() - dragCancelledRef.current < 250 + ) { + shouldCancelDragRef.current = true; + isDraggingRef.current = false; + resetMoveClick(); + return; + } + + const color = isAltPressedRef.current + ? "#70bbd9" + : isCtrlPressedRef.current + ? "#ffaa00" + : "#eb6150"; + setClickedSquares((prev) => { + const actual = prev.filter((s) => s !== undefined) as ClickedSquare[]; + const exists = actual.find((s) => s.square === square); + if (exists) { + if (exists.color === color) { + return actual.filter((s) => s.square !== square); + } else { + return [ + ...actual.filter((s) => s.square !== square), + { square, color }, + ]; + } + } else { + return [...actual, { square, color }]; + } + }); + }, + [resetMoveClick, setClickedSquares] + ); + + const handleBoardPointerUpCapture = useCallback( + (e: React.PointerEvent) => { + if (e.button === 2) { + rightClickDownRef.current = false; + lastRightClickUpTimeRef.current = Date.now(); + setClickedSquares((prev) => [...prev]); + } }, [setClickedSquares] ); const handlePieceDragBegin = useCallback( - (_: string, square: Square) => { - resetMoveClick(square); + (piece: string, square: Square) => { + shouldCancelDragRef.current = false; + isDraggingRef.current = true; + dragOriginSquareRef.current = square; + dragPieceRef.current = piece; + setMoveClickFrom(null); + setMoveClickTo(null); + setShowPromotionDialog(false); + const moves = game.moves({ square, verbose: true }); + setPlayableSquares(moves.map((m) => m.to)); + setCaptureSquares(moves.filter((m) => m.captured).map((m) => m.to)); }, - [resetMoveClick] + [ + game, + setMoveClickFrom, + setMoveClickTo, + setPlayableSquares, + setCaptureSquares, + ] ); const handlePieceDragEnd = useCallback(() => { - resetMoveClick(); - }, [resetMoveClick]); + const wasCancelled = shouldCancelDragRef.current; + isDraggingRef.current = false; + setPlayableSquares([]); + setCaptureSquares([]); + + if (wasCancelled && dragOriginSquareRef.current && dragPieceRef.current) { + animateReturnFlight(dragOriginSquareRef.current, dragPieceRef.current); + } + + dragOriginSquareRef.current = null; + dragPieceRef.current = null; + + if (wasCancelled) { + dragCancelledRef.current = Date.now(); + setTimeout(() => { + shouldCancelDragRef.current = false; + }, 50); + } else { + shouldCancelDragRef.current = false; + } + }, [setPlayableSquares, setCaptureSquares, animateReturnFlight]); + + useLayoutEffect(() => { + if (!isDraggingRef.current && draggedPieceElementRef.current) { + draggedPieceElementRef.current.style.opacity = "1"; + draggedPieceElementRef.current = null; + } + }, [gameFen]); + + useEffect(() => { + const handleContextMenuUndo = (e: MouseEvent) => { + if (Date.now() - lastDropMoveTimeRef.current < 150) { + lastDropMoveTimeRef.current = 0; + setAnimationDurationToUse(0); + undoMove(); + } else if (isDraggingRef.current) { + e.preventDefault(); + abortCustomDrag(); + } + }; + document.addEventListener("contextmenu", handleContextMenuUndo, true); + return () => + document.removeEventListener("contextmenu", handleContextMenuUndo, true); + }, [undoMove, abortCustomDrag]); const onPromotionPieceSelect = useCallback( (piece?: PromotionPieceOption, from?: Square, to?: Square) => { @@ -208,82 +1133,88 @@ export default function Board({ ); const customArrows: Arrow[] = useMemo(() => { - const bestMove = position?.lastEval?.bestMove; - const moveClassification = position?.eval?.moveClassification; + let arrows = [...userArrows]; + + if (newArrow && newArrow[0] && newArrow[1] && newArrow[0] !== newArrow[1]) { + arrows = arrows.filter( + (a) => !(a[0] === newArrow[0] && a[1] === newArrow[1]) + ); + arrows.push(newArrow); + } if ( - bestMove && + arrowBestMove && showBestMoveArrow && - moveClassification !== MoveClassification.Best && - moveClassification !== MoveClassification.Opening && - moveClassification !== MoveClassification.Forced && - moveClassification !== MoveClassification.Perfect + arrowMoveClassification !== MoveClassification.Best && + arrowMoveClassification !== MoveClassification.Opening && + arrowMoveClassification !== MoveClassification.Forced && + arrowMoveClassification !== MoveClassification.Perfect ) { const bestMoveArrow = [ - bestMove.slice(0, 2), - bestMove.slice(2, 4), + arrowBestMove.slice(0, 2), + arrowBestMove.slice(2, 4), tinycolor(CLASSIFICATION_COLORS[MoveClassification.Best]) .spin(-boardHue) .toHexString(), ] as Arrow; - return [bestMoveArrow]; + if (bestMoveArrow[0] && bestMoveArrow[1]) { + arrows.push(bestMoveArrow); + } } - return []; - }, [position, showBestMoveArrow, boardHue]); - - const SquareRenderer: CustomSquareRenderer = useMemo(() => { - return getSquareRenderer({ - currentPositionAtom: currentPositionAtom, - clickedSquaresAtom, - playableSquaresAtom, - showPlayerMoveIconAtom, - boardSize: boardSize || 400, + const uniqueArrows = new Map(); + arrows.forEach((a) => { + if (a && a[0] && a[1]) { + uniqueArrows.set(`${a[0]}-${a[1]}`, a); + } }); + + return Array.from(uniqueArrows.values()); }, [ - currentPositionAtom, - clickedSquaresAtom, - playableSquaresAtom, - showPlayerMoveIconAtom, - boardSize, + arrowBestMove, + arrowMoveClassification, + showBestMoveArrow, + boardHue, + userArrows, + newArrow, ]); - const customPieces = useMemo( - () => - PIECE_CODES.reduce((acc, piece) => { - acc[piece] = ({ squareWidth }) => ( - - ); - - return acc; - }, {}), - [pieceSet] - ); + const SquareRendererComponent = useMemo(() => getSquareRenderer(), []); const customBoardStyle = useMemo(() => { const commonBoardStyle = { borderRadius: "5px", boxShadow: "0 2px 10px rgba(0, 0, 0, 0.5)", + transform: "translateZ(0)", + backfaceVisibility: "hidden" as const, + WebkitBackfaceVisibility: "hidden" as const, + overflow: "visible", + backgroundColor: "#b58863", }; if (boardHue) { return { ...commonBoardStyle, filter: `hue-rotate(${boardHue}deg)`, + willChange: "filter", }; } return commonBoardStyle; }, [boardHue]); + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + lastDropMoveTimeRef.current = 0; + if (isDraggingRef.current) { + abortCustomDrag(); + } + }, + [abortCustomDrag] + ); + return ( + + handleSquareLeftClick(square, piece) + } onSquareRightClick={handleSquareRightClick} onPieceDragBegin={handlePieceDragBegin} onPieceDragEnd={handlePieceDragEnd} onPromotionPieceSelect={onPromotionPieceSelect} showPromotionDialog={showPromotionDialog} promotionToSquare={moveClickTo} - animationDuration={200} + animationDuration={animationDurationToUse} customPieces={customPieces} /> + ; } -export default function PlayerHeader({ color, player, gameAtom }: Props) { +const PlayerHeader = memo(function PlayerHeader({ + color, + player, + gameAtom, +}: Props) { const game = useAtomValue(gameAtom); const gameFen = game.fen(); @@ -96,7 +100,7 @@ export default function PlayerHeader({ color, player, gameAtom }: Props) { )} ); -} +}); const getClock = (comment: string | undefined) => { if (!comment) return undefined; @@ -111,3 +115,5 @@ const getClock = (comment: string | undefined) => { tenths: match[4] ? parseInt(match[4]) : 0, }; }; + +export default PlayerHeader; diff --git a/src/components/board/squareRenderer.tsx b/src/components/board/squareRenderer.tsx index add5599e..d7e7e259 100644 --- a/src/components/board/squareRenderer.tsx +++ b/src/components/board/squareRenderer.tsx @@ -1,74 +1,136 @@ -import { CurrentPosition } from "@/types/eval"; import { MoveClassification } from "@/types/enums"; -import { PrimitiveAtom, atom, useAtomValue } from "jotai"; +import { atom, useAtomValue } from "jotai"; import Image from "next/image"; -import { CSSProperties, forwardRef, useMemo } from "react"; +import { CSSProperties, forwardRef, memo, useMemo, useContext } from "react"; import { CustomSquareProps, - Square, } from "react-chessboard/dist/chessboard/types"; import { CLASSIFICATION_COLORS } from "@/constants"; -import { boardHueAtom } from "./states"; - -export interface Props { - currentPositionAtom: PrimitiveAtom; - clickedSquaresAtom: PrimitiveAtom; - playableSquaresAtom: PrimitiveAtom; - showPlayerMoveIconAtom?: PrimitiveAtom; - boardSize: number; -} +import { BoardStateContext } from "./index"; -export function getSquareRenderer({ - currentPositionAtom, - clickedSquaresAtom, - playableSquaresAtom, - showPlayerMoveIconAtom = atom(false), - boardSize, -}: Props) { - const squareRenderer = forwardRef( - (props, ref) => { +export function getSquareRenderer() { + const SquareRendererComponent = memo( + forwardRef((props, ref) => { const { children, square, style } = props; - const showPlayerMoveIcon = useAtomValue(showPlayerMoveIconAtom); - const position = useAtomValue(currentPositionAtom); - const clickedSquares = useAtomValue(clickedSquaresAtom); - const playableSquares = useAtomValue(playableSquaresAtom); - const boardHue = useAtomValue(boardHueAtom); + const { backgroundColor, ...containerStyle } = (style || {}) as any; + + const { + boardHue, + currentPositionAtom, + clickedSquaresAtom, + playableSquaresAtom, + captureSquaresAtom, + showPlayerMoveIconAtom, + moveClickFromAtom, + boardSize, + } = useContext(BoardStateContext); + + // Derived stable subscriptions to prevent O(N) re-render cycles + const isPlayable = useAtomValue( + useMemo( + () => atom((get) => get(playableSquaresAtom).includes(square)), + [playableSquaresAtom, square] + ) + ); - const fromSquare = position.lastMove?.from; - const toSquare = position.lastMove?.to; - const moveClassification = position?.eval?.moveClassification; + const isCapture = useAtomValue( + useMemo( + () => atom((get) => get(captureSquaresAtom).includes(square)), + [captureSquaresAtom, square] + ) + ); + + const clickedSquare = useAtomValue( + useMemo( + () => + atom((get) => + get(clickedSquaresAtom).find((s) => s.square === square) + ), + [clickedSquaresAtom, square] + ) + ); + + const isMoveClickFrom = useAtomValue( + useMemo( + () => atom((get) => get(moveClickFromAtom) === square), + [moveClickFromAtom, square] + ) + ); + + const classification = useAtomValue( + useMemo( + () => + atom((get) => { + const pos = get(currentPositionAtom); + const isLastMove = + pos.lastMove?.from === square || pos.lastMove?.to === square; + return isLastMove ? pos.eval?.moveClassification || null : null; + }), + [currentPositionAtom, square] + ) + ); + + const isLastMoveTo = useAtomValue( + useMemo( + () => atom((get) => get(currentPositionAtom).lastMove?.to === square), + [currentPositionAtom, square] + ) + ); + + const showPlayerMoveIcon = useAtomValue(showPlayerMoveIconAtom); const highlightSquareStyle: CSSProperties | undefined = useMemo( () => - clickedSquares.includes(square) - ? rightClickSquareStyle - : fromSquare === square || toSquare === square - ? previousMoveSquareStyle(moveClassification) - : undefined, - [clickedSquares, square, fromSquare, toSquare, moveClassification] + isMoveClickFrom + ? activeSquareStyle + : clickedSquare + ? rightClickSquareStyle(clickedSquare.color) + : classification !== null + ? previousMoveSquareStyle(classification) + : undefined, + [clickedSquare, isMoveClickFrom, classification] ); const playableSquareStyle: CSSProperties | undefined = useMemo( () => - playableSquares.includes(square) ? playableSquareStyles : undefined, - [playableSquares, square] + isPlayable + ? isCapture + ? captureRingStyle + : playableSquareStyles + : undefined, + [isPlayable, isCapture] ); + const showIcon = classification && showPlayerMoveIcon && isLastMoveTo; + return (
- {children} + {backgroundColor && ( +
+ )} {highlightSquareStyle &&
} {playableSquareStyle &&
} - {moveClassification && showPlayerMoveIcon && square === toSquare && ( + {children} + {showIcon && ( move-icon )}
); + }), + (prev, next) => { + // aggressive layout-shift prevention cache + if (prev.square !== next.square) return false; + if (prev.children !== next.children) return false; + return true; } ); - squareRenderer.displayName = "SquareRenderer"; + SquareRendererComponent.displayName = "SquareRenderer"; - return squareRenderer; + return SquareRendererComponent; } -const rightClickSquareStyle: CSSProperties = { +const rightClickSquareStyle = (color?: string): CSSProperties => ({ position: "absolute", width: "100%", height: "100%", - backgroundColor: "#eb6150", + backgroundColor: color || "#eb6150", opacity: "0.8", + transform: "translateZ(1px)", + pointerEvents: "none", +}); + +const activeSquareStyle: CSSProperties = { + position: "absolute", + width: "100%", + height: "100%", + backgroundColor: "#fad541", + opacity: 0.5, + transform: "translateZ(1px)", + pointerEvents: "none", }; const playableSquareStyles: CSSProperties = { @@ -107,16 +191,33 @@ const playableSquareStyles: CSSProperties = { backgroundClip: "content-box", borderRadius: "50%", boxSizing: "border-box", + transform: "translateZ(1px)", + pointerEvents: "none", +}; + +const captureRingStyle: CSSProperties = { + position: "absolute", + width: "100%", + height: "100%", + borderRadius: "50%", + boxSizing: "border-box", + background: + "radial-gradient(transparent 60%, rgba(0,0,0,.14) 60%, rgba(0,0,0,.14) 100%)", + transform: "translateZ(1px)", + pointerEvents: "none", }; const previousMoveSquareStyle = ( - moveClassification?: MoveClassification + moveClassification?: MoveClassification | null ): CSSProperties => ({ position: "absolute", width: "100%", height: "100%", - backgroundColor: moveClassification - ? CLASSIFICATION_COLORS[moveClassification] - : "#fad541", + backgroundColor: + moveClassification && moveClassification !== MoveClassification.Opening + ? CLASSIFICATION_COLORS[moveClassification] + : "#fad541", opacity: 0.5, + transform: "translateZ(1px)", + pointerEvents: "none", }); diff --git a/src/components/board/types.ts b/src/components/board/types.ts new file mode 100644 index 00000000..b88b87bd --- /dev/null +++ b/src/components/board/types.ts @@ -0,0 +1,12 @@ +import { Square } from "react-chessboard/dist/chessboard/types"; + +export interface CapturedSquare { + square: Square; + piece: string; + timestamp: number; +} + +export interface ClickedSquare { + square: Square; + color: string; +} diff --git a/src/hooks/useChessActions.ts b/src/hooks/useChessActions.ts index cb931c3d..1af8845a 100644 --- a/src/hooks/useChessActions.ts +++ b/src/hooks/useChessActions.ts @@ -35,20 +35,17 @@ export const useChessActions = (chessAtom: PrimitiveAtom) => { const copyGame = useCallback(() => { const newGame = new Chess(); - - if (game.history().length === 0) { - const pgnSplitted = game.pgn().split("]"); - if ( - ["1-0", "0-1", "1/2-1/2", "*"].includes( - pgnSplitted.at(-1)?.trim() ?? "" - ) - ) { - newGame.loadPgn(pgnSplitted.slice(0, -1).join("]") + "]"); - return newGame; + try { + newGame.loadPgn(game.pgn()); + } catch { + // Fallback for custom-FEN or edge-case PGN formats + newGame.load(game.getHeaders().FEN || DEFAULT_POSITION, { + preserveHeaders: true, + }); + for (const move of game.history()) { + newGame.move(move); } } - - newGame.loadPgn(game.pgn()); return newGame; }, [game]); @@ -92,12 +89,17 @@ export const useChessActions = (chessAtom: PrimitiveAtom) => { (moves: string[]) => { const newGame = copyGame(); - let lastMove: Move | null = null; - for (const move of moves) { - lastMove = newGame.move(move); + try { + let lastMove: Move | null = null; + for (const move of moves) { + lastMove = newGame.move(move); + } + setGame(newGame); + if (lastMove) playSoundFromMove(lastMove); + } catch (e) { + console.warn("Invalid move sequence", e); + playIllegalMoveSound(); } - setGame(newGame); - if (lastMove) playSoundFromMove(lastMove); }, [copyGame, setGame] ); diff --git a/src/hooks/useEngine.ts b/src/hooks/useEngine.ts index e51a6f7a..32b44373 100644 --- a/src/hooks/useEngine.ts +++ b/src/hooks/useEngine.ts @@ -11,6 +11,8 @@ export const useEngine = (engineName: EngineName | undefined) => { const [engine, setEngine] = useState(null); useEffect(() => { + let isMounted = true; + if (!engineName) return; if (engineName !== EngineName.Stockfish11 && !isWasmSupported()) { @@ -18,11 +20,22 @@ export const useEngine = (engineName: EngineName | undefined) => { } pickEngine(engineName).then((newEngine) => { + if (!isMounted) { + // If React unmounted this hook while the worker was booting, + // instantly kill the orphaned worker to prevent invisible memory/CPU leaks + newEngine.shutdown(); + return; + } + setEngine((prev) => { prev?.shutdown(); return newEngine; }); }); + + return () => { + isMounted = false; + }; }, [engineName]); return engine; diff --git a/src/lib/chess.ts b/src/lib/chess.ts index af3f0f5f..a84ac5e3 100644 --- a/src/lib/chess.ts +++ b/src/lib/chess.ts @@ -251,37 +251,27 @@ export const getIsPieceSacrifice = ( }; export const getMaterialDifference = (fen: string): number => { - const game = new Chess(fen); - const board = game.board().flat(); - - return board.reduce((acc, square) => { - if (!square) return acc; - const piece = square.type; - - if (square.color === "w") { - return acc + getPieceValue(piece); + const placement = fen.split(" ")[0]; + let diff = 0; + + for (let i = 0; i < placement.length; i++) { + const c = placement[i]; + switch (c) { + case "P": diff += 1; break; + case "N": case "B": diff += 3; break; + case "R": diff += 5; break; + case "Q": diff += 9; break; + case "p": diff -= 1; break; + case "n": case "b": diff -= 3; break; + case "r": diff -= 5; break; + case "q": diff -= 9; break; } + } - return acc - getPieceValue(piece); - }, 0); + return diff; }; -const getPieceValue = (piece: PieceSymbol): number => { - switch (piece) { - case "p": - return 1; - case "n": - return 3; - case "b": - return 3; - case "r": - return 5; - case "q": - return 9; - default: - return 0; - } -}; + export const isCheck = (fen: string): boolean => { const game = new Chess(fen); diff --git a/src/lib/engine/uciEngine.ts b/src/lib/engine/uciEngine.ts index ef0a2599..1d618375 100644 --- a/src/lib/engine/uciEngine.ts +++ b/src/lib/engine/uciEngine.ts @@ -144,7 +144,20 @@ export class UciEngine { } public async stopAllCurrentJobs(): Promise { + // Prevent unhandled promise rejections by cleanly resolving abandoned queue jobs + const abandonedJobs = [...this.workerQueue]; this.workerQueue = []; + + for (const job of abandonedJobs) { + job.resolve([]); // Yield empty string array to fulfill the pending Promise safely + } + + // Unbind any active React callback listeners immediately so in-flight engine updates + // do not trigger "Maximum update depth exceeded" during the stop pipeline. + for (const worker of this.workers) { + worker.listen = () => null; + } + await this.sendCommandsToEachWorker(["stop", "isready"], "readyok"); for (const worker of this.workers) { @@ -369,14 +382,20 @@ export class UciEngine { await this.stopAllCurrentJobs(); await this.setMultiPv(multiPv); + let lastUpdate = 0; + const THROTTLE_MS = 60; // Limit UI updates to ~16 updates per second for maximum fluidity and zero lag + const onNewMessage = (messages: string[]) => { if (!setPartialEval) return; + + const now = performance.now(); + if (now - lastUpdate < THROTTLE_MS) return; + lastUpdate = now; + const parsedResults = parseEvaluationResults(messages, fen); setPartialEval(parsedResults); }; - console.log(`Evaluating position: ${fen}`); - const lichessEval = await lichessEvalPromise; if ( lichessEval.lines.length >= multiPv && @@ -405,8 +424,6 @@ export class UciEngine { await this.stopAllCurrentJobs(); await this.setElo(elo); - console.log(`Evaluating position: ${fen}`); - const results = await this.sendCommands( [`position fen ${fen}`, `go depth ${depth}`], "bestmove" diff --git a/src/lib/engine/worker.ts b/src/lib/engine/worker.ts index 224e0d9c..24ee4247 100644 --- a/src/lib/engine/worker.ts +++ b/src/lib/engine/worker.ts @@ -2,8 +2,6 @@ import { EngineWorker } from "@/types/engine"; import { isIosDevice, isMobileDevice } from "./shared"; export const getEngineWorker = (enginePath: string): EngineWorker => { - console.log(`Creating worker from ${enginePath}`); - const worker = new window.Worker(enginePath); const engineWorker: EngineWorker = { @@ -34,6 +32,7 @@ export const sendCommandsToWorker = ( onNewMessage?.(messages); if (data.startsWith(finalMessage)) { + worker.listen = () => null; // Cleanup memory and prevent dangling state updates resolve(messages); } }; diff --git a/src/lib/lichess.ts b/src/lib/lichess.ts index 5215e3ef..222d4fdd 100644 --- a/src/lib/lichess.ts +++ b/src/lib/lichess.ts @@ -85,12 +85,17 @@ const fetchLichessEval = async ( try { const res = await fetch( `https://lichess.org/api/cloud-eval?fen=${fen}&multiPv=${multiPv}`, - { method: "GET", signal: AbortSignal.timeout(200) } + { method: "GET", signal: AbortSignal.timeout(1200) } // Increased buffer for international latency ); return res.json(); } catch (error) { - console.error(error); + // We intentionally silence TimeoutError and DOMException logs here. + // Failing over to the local WebAssembly engine is a routine, intended behavior + // and does not need to flood the user's console visually. + if (!(error instanceof Error && error.name === "TimeoutError")) { + console.error("Lichess fetch error:", error); + } return { error: LichessError.NotFound }; } diff --git a/src/lib/sounds.ts b/src/lib/sounds.ts index 87158aff..0339ff57 100644 --- a/src/lib/sounds.ts +++ b/src/lib/sounds.ts @@ -1,48 +1,44 @@ import { Move } from "chess.js"; let audioContext: AudioContext | null = null; -let timeout: NodeJS.Timeout | null = null; const soundsCache = new Map(); -type Sound = "move" | "capture" | "illegalMove"; +type Sound = "move" | "capture" | "illegalMove" | "check"; const soundUrls: Record = { move: "/sounds/move.mp3", capture: "/sounds/capture.mp3", illegalMove: "/sounds/error.mp3", + check: "/sounds/check.mp3", }; export const play = async (sound: Sound) => { - if (timeout) clearTimeout(timeout); + if (!audioContext) audioContext = new AudioContext(); + if (audioContext.state === "suspended") await audioContext.resume(); - timeout = setTimeout(async () => { - if (!audioContext) audioContext = new AudioContext(); - if (audioContext.state === "suspended") await audioContext.resume(); + let audioBuffer = soundsCache.get(soundUrls[sound]); + if (!audioBuffer) { + const res = await fetch(soundUrls[sound]); + const buffer = await audioContext.decodeAudioData(await res.arrayBuffer()); + audioBuffer = buffer; + soundsCache.set(soundUrls[sound], buffer); + } - let audioBuffer = soundsCache.get(soundUrls[sound]); - if (!audioBuffer) { - const res = await fetch(soundUrls[sound]); - const buffer = await audioContext.decodeAudioData( - await res.arrayBuffer() - ); - audioBuffer = buffer; - soundsCache.set(soundUrls[sound], buffer); - } - - const audioSrc = audioContext.createBufferSource(); - audioSrc.buffer = audioBuffer; - const volume = audioContext.createGain(); - volume.gain.value = 0.3; - audioSrc.connect(volume); - volume.connect(audioContext.destination); - audioSrc.start(); - }, 1); + const audioSrc = audioContext.createBufferSource(); + audioSrc.buffer = audioBuffer; + const volume = audioContext.createGain(); + volume.gain.value = 0.3; + audioSrc.connect(volume); + volume.connect(audioContext.destination); + audioSrc.start(); }; export const playCaptureSound = () => play("capture"); export const playIllegalMoveSound = () => play("illegalMove"); export const playMoveSound = () => play("move"); +export const playCheckSound = () => play("check"); export const playSoundFromMove = (move: Move | null) => { if (!move) return playIllegalMoveSound(); + if (move.san.includes("+") || move.san.includes("#")) return playCheckSound(); if (move.captured) return playCaptureSound(); return playMoveSound(); }; diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index 38a7e1d2..7b8e10c2 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -1,5 +1,7 @@ import { Head, Html, Main, NextScript } from "next/document"; +const DEFAULT_PIECE_SET = "maestro"; + export default function Document() { return ( @@ -22,6 +24,28 @@ export default function Document() { sizes="16x16" href="/favicon-16x16.png" /> + + + + + + ); } diff --git a/src/sections/analysis/hooks/useCurrentPosition.ts b/src/sections/analysis/hooks/useCurrentPosition.ts index 257be3f8..f22af367 100644 --- a/src/sections/analysis/hooks/useCurrentPosition.ts +++ b/src/sections/analysis/hooks/useCurrentPosition.ts @@ -27,6 +27,7 @@ export const useCurrentPosition = (engine: UciEngine | null) => { useEffect(() => { const boardHistory = board.history({ verbose: true }); const position: CurrentPosition = { + fen: board.fen(), lastMove: boardHistory.at(-1), }; diff --git a/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx b/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx index f554afbf..f18c8927 100644 --- a/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx +++ b/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx @@ -5,22 +5,63 @@ import { currentPositionAtom, engineMultiPvAtom, } from "../../../states"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { LineEval } from "@/types/eval"; +import { useEffect, useRef } from "react"; +import { useChessActions } from "@/hooks/useChessActions"; +import { boardAnimationDurationAtom } from "../../../states"; export default function EngineLines(props: GridProps) { const board = useAtomValue(boardAtom); const linesNumber = useAtomValue(engineMultiPvAtom); const position = useAtomValue(currentPositionAtom); + const { addMoves } = useChessActions(boardAtom); + const setAnimationDuration = useSetAtom(boardAnimationDurationAtom); const linesSkeleton: LineEval[] = Array.from({ length: linesNumber }).map( (_, i) => ({ pv: [`${i}`], depth: 0, multiPv: i + 1 }) ); - const engineLines = position?.eval?.lines?.length + const isStale = position?.fen !== board.fen(); + + const engineLines = position?.eval?.lines?.length && !isStale ? position.eval.lines : linesSkeleton; + const positionRef = useRef(position); + const boardRef = useRef(board); + const addMovesRef = useRef(addMoves); + + useEffect(() => { + positionRef.current = position; + boardRef.current = board; + addMovesRef.current = addMoves; + }, [position, board, addMoves]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Allow spacebar only if target is not an input or textarea + const target = e.target as HTMLElement; + const isInput = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable; + + if (e.code === "Space" && !isInput) { + e.preventDefault(); + if (boardRef.current?.isCheckmate()) return; + const bestLine = positionRef.current?.eval?.lines?.[0]; + if (bestLine && bestLine.pv && bestLine.pv.length > 0) { + setAnimationDuration(150); + addMovesRef.current([bestLine.pv[0]]); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + if (board.isCheckmate()) return null; return ( diff --git a/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx b/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx index e503f23a..6b7533e3 100644 --- a/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx +++ b/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx @@ -5,6 +5,8 @@ import { boardAtom } from "../../../states"; import { getLineEvalLabel, moveLineUciToSan } from "@/lib/chess"; import { useChessActions } from "@/hooks/useChessActions"; import PrettyMoveSan from "@/components/prettyMoveSan"; +import { useSetAtom } from "jotai"; +import { boardAnimationDurationAtom } from "../../../states"; interface Props { line: LineEval; @@ -13,6 +15,7 @@ interface Props { export default function LineEvaluation({ line }: Props) { const board = useAtomValue(boardAtom); const { addMoves } = useChessActions(boardAtom); + const setAnimationDuration = useSetAtom(boardAnimationDurationAtom); const lineLabel = getLineEvalLabel(line); const isBlackCp = @@ -78,6 +81,7 @@ export default function LineEvaluation({ line }: Props) { additionalText={i < line.pv.length - 1 ? "," : ""} boxProps={{ onClick: () => { + setAnimationDuration(150); addMoves(line.pv.slice(0, i + 1)); }, sx: { diff --git a/src/sections/analysis/panelBody/analysisTab/opening.tsx b/src/sections/analysis/panelBody/analysisTab/opening.tsx index 1cad63d6..c81e107b 100644 --- a/src/sections/analysis/panelBody/analysisTab/opening.tsx +++ b/src/sections/analysis/panelBody/analysisTab/opening.tsx @@ -1,14 +1,16 @@ import { useAtomValue } from "jotai"; import { Grid2 as Grid, Skeleton, Typography } from "@mui/material"; -import { currentPositionAtom } from "../../states"; +import { boardAtom, currentPositionAtom } from "../../states"; export default function Opening() { const position = useAtomValue(currentPositionAtom); + const board = useAtomValue(boardAtom); const lastMove = position?.lastMove; if (!lastMove) return null; + const isStale = position?.fen !== board.fen(); - const opening = position?.eval?.opening || position.opening; + const opening = (!isStale && position?.eval?.opening) || position.opening; if (!opening) { return ( diff --git a/src/sections/analysis/states.ts b/src/sections/analysis/states.ts index a294bdf6..034e8509 100644 --- a/src/sections/analysis/states.ts +++ b/src/sections/analysis/states.ts @@ -25,3 +25,5 @@ export const engineWorkersNbAtom = atomWithStorage( export const evaluationProgressAtom = atom(0); export const savedEvalsAtom = atom({}); + +export const boardAnimationDurationAtom = atom(150); diff --git a/src/types/eval.ts b/src/types/eval.ts index 467aeb9a..f60d2067 100644 --- a/src/types/eval.ts +++ b/src/types/eval.ts @@ -48,6 +48,7 @@ export interface EvaluatePositionWithUpdateParams { } export interface CurrentPosition { + fen?: string; lastMove?: Move; eval?: PositionEval; lastEval?: PositionEval; From 04e9bd41381a2b73c1f81be6ba49234370c27757 Mon Sep 17 00:00:00 2001 From: Klimbar Date: Wed, 4 Mar 2026 00:16:10 +0530 Subject: [PATCH 2/5] fix the pieces not being in the center --- src/components/board/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/board/index.tsx b/src/components/board/index.tsx index be78320a..730dd263 100644 --- a/src/components/board/index.tsx +++ b/src/components/board/index.tsx @@ -125,7 +125,7 @@ const CustomPiece = memo( zIndex: isDragging ? 100 : 50, transform: isDragging ? "scale(1.05) translateZ(30px)" - : "scale(1) translateZ(20px)", + : "scale(1) translateZ(2px)", filter: `${dragShadow} ${checkShadow} ${hueFilter}`.trim(), transition: "filter 0.1s ease-out", // Removed transform transition pointerEvents: "none", From 277eeab810b794276f81faf0c0a8229863152cd0 Mon Sep 17 00:00:00 2001 From: Klimbar Date: Wed, 4 Mar 2026 00:50:15 +0530 Subject: [PATCH 3/5] fixed the triggering of error sound on promotion --- src/components/board/index.tsx | 35 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/components/board/index.tsx b/src/components/board/index.tsx index 730dd263..b56cda8f 100644 --- a/src/components/board/index.tsx +++ b/src/components/board/index.tsx @@ -1103,30 +1103,29 @@ function Board({ const onPromotionPieceSelect = useCallback( (piece?: PromotionPieceOption, from?: Square, to?: Square) => { - if (!piece) return false; - const promotionPiece = piece[1]?.toLowerCase() ?? "q"; - - if (moveClickFrom && moveClickTo) { - const result = playMove({ - from: moveClickFrom, - to: moveClickTo, - promotion: promotionPiece, - }); + if (!piece) { resetMoveClick(); - return !!result; + return false; } + const promotionPiece = piece[1]?.toLowerCase() ?? "q"; - if (from && to) { - const result = playMove({ - from, - to, - promotion: promotionPiece, - }); + const currentFrom = moveClickFrom || from; + const currentTo = moveClickTo || to; + + if (!currentFrom || !currentTo) { resetMoveClick(); - return !!result; + return false; } - resetMoveClick(moveClickFrom); + // We don't need the check anymore since we're returning false to stop react-chessboard + const result = playMove({ + from: currentFrom, + to: currentTo, + promotion: promotionPiece, + }); + + resetMoveClick(); + // ALWAYS return false to prevent react-chessboard from subsequently triggering onPieceDrop return false; }, [moveClickFrom, moveClickTo, playMove, resetMoveClick] From 4fafba627a8a72f16dbbe8cb9917dc58c7f4b4f7 Mon Sep 17 00:00:00 2001 From: Klimbar Date: Wed, 4 Mar 2026 01:08:19 +0530 Subject: [PATCH 4/5] fixed the build error --- src/components/board/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/board/index.tsx b/src/components/board/index.tsx index b56cda8f..53fa3198 100644 --- a/src/components/board/index.tsx +++ b/src/components/board/index.tsx @@ -1118,7 +1118,7 @@ function Board({ } // We don't need the check anymore since we're returning false to stop react-chessboard - const result = playMove({ + playMove({ from: currentFrom, to: currentTo, promotion: promotionPiece, From d887dacc53e3036159738b33fb57bd97226b65e1 Mon Sep 17 00:00:00 2001 From: Klimbar Date: Wed, 4 Mar 2026 01:35:27 +0530 Subject: [PATCH 5/5] feat: centralize board CSS import, refactor font handling, and improve code formatting --- .../fonts}/chess_merida_unicode.ttf | Bin src/components/board/board.css | 7 + src/components/board/index.tsx | 137 +++++++++--------- src/components/board/squareRenderer.tsx | 7 +- src/components/prettyMoveSan/index.tsx | 7 +- src/hooks/useEngine.ts | 2 +- src/lib/chess.ts | 36 +++-- src/lib/engine/uciEngine.ts | 2 +- src/lib/lichess.ts | 2 +- src/pages/_app.tsx | 1 + .../analysisTab/engineLines/index.tsx | 11 +- .../engineLines/lineEvaluation.tsx | 6 +- 12 files changed, 115 insertions(+), 103 deletions(-) rename {src/components/prettyMoveSan => public/fonts}/chess_merida_unicode.ttf (100%) diff --git a/src/components/prettyMoveSan/chess_merida_unicode.ttf b/public/fonts/chess_merida_unicode.ttf similarity index 100% rename from src/components/prettyMoveSan/chess_merida_unicode.ttf rename to public/fonts/chess_merida_unicode.ttf diff --git a/src/components/board/board.css b/src/components/board/board.css index fd54519f..95fd6d08 100644 --- a/src/components/board/board.css +++ b/src/components/board/board.css @@ -46,3 +46,10 @@ clip-path: none !important; transform-style: preserve-3d !important; } + +@font-face { + font-family: 'ChessMerida'; + src: url('/fonts/chess_merida_unicode.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} diff --git a/src/components/board/index.tsx b/src/components/board/index.tsx index 53fa3198..71707c1d 100644 --- a/src/components/board/index.tsx +++ b/src/components/board/index.tsx @@ -17,6 +17,8 @@ import { useRef, useState, memo, + createContext, + useContext, } from "react"; import { Color, MoveClassification } from "@/types/enums"; import { Chess } from "chess.js"; @@ -28,8 +30,6 @@ import PlayerHeader from "./playerHeader"; import { boardHueAtom, pieceSetAtom } from "./states"; import type { ClickedSquare } from "./types"; import tinycolor from "tinycolor2"; -import "./board.css"; -import { createContext, useContext } from "react"; const clickedSquaresAtom = atom([]); const playableSquaresAtom = atom([]); @@ -37,7 +37,6 @@ const captureSquaresAtom = atom([]); const moveClickFromAtom = atom(null); const moveClickToAtom = atom(null); - const defaultCurrentPositionAtom = atom({} as CurrentPosition); const defaultShowPlayerMoveIconAtom = atom(false); @@ -94,7 +93,8 @@ const CustomPiece = memo( isDragging: boolean; piece: string; }) => { - const { pieceSet, checkSquare, turn, boardHue } = useContext(BoardStateContext); + const { pieceSet, checkSquare, turn, boardHue } = + useContext(BoardStateContext); const isCheck = (piece === "wK" && turn === "w" && checkSquare) || @@ -152,8 +152,6 @@ export const PIECE_CODES = [ "bK", ] as const satisfies Piece[]; - - function Board({ id: boardId, canPlay, @@ -195,7 +193,6 @@ function Board({ const setPlayableSquares = useSetAtom(playableSquaresAtom); const setCaptureSquares = useSetAtom(captureSquaresAtom); - // Jotai Getters (derived state) const [moveClickFrom, setMoveClickFrom] = [ useAtomValue(moveClickFromAtom), @@ -210,12 +207,7 @@ function Board({ const customPieces = useMemo(() => { return PIECE_CODES.reduce((acc, pieceCode) => { - acc[pieceCode] = (props) => ( - - ); + acc[pieceCode] = (props) => ; return acc; }, {}); }, []); @@ -248,14 +240,19 @@ function Board({ useMemo(() => animationDurationAtom || atom(150), [animationDurationAtom]) ); - const animationDurationToUse = animationDurationAtom ? externalAnimationDuration : localAnimationDuration; - const setAnimationDurationToUse = useCallback((duration: number) => { - if (animationDurationAtom) { - setExternalAnimationDuration(duration); - } else { - setLocalAnimationDuration(duration); - } - }, [animationDurationAtom, setExternalAnimationDuration]); + const animationDurationToUse = animationDurationAtom + ? externalAnimationDuration + : localAnimationDuration; + const setAnimationDurationToUse = useCallback( + (duration: number) => { + if (animationDurationAtom) { + setExternalAnimationDuration(duration); + } else { + setLocalAnimationDuration(duration); + } + }, + [animationDurationAtom, setExternalAnimationDuration] + ); // Refs for event handling and drag state const isAltPressedRef = useRef(false); @@ -271,7 +268,6 @@ function Board({ const dragPieceRef = useRef(null); const rightClickDragStartRef = useRef(null); - // Custom pointer drag refs const customDragGhostRef = useRef(null); const dragStartPosRef = useRef<{ @@ -390,8 +386,6 @@ function Board({ [boardOrientation] ); - - const animateReturnFlight = useCallback( ( sourceSquare: Square, @@ -614,7 +608,7 @@ function Board({ return !!result; }, - [isPiecePlayable, playMove] + [isPiecePlayable, playMove, setAnimationDurationToUse] ); const handleGlobalPointerUp = useCallback( @@ -718,6 +712,7 @@ function Board({ game, setMoveClickFrom, setMoveClickTo, + setAnimationDurationToUse, ] ); @@ -903,6 +898,7 @@ function Board({ setMoveClickTo, handleGlobalPointerMoveRightClick, handleGlobalPointerUpRightClick, + setAnimationDurationToUse, ] ); @@ -970,7 +966,6 @@ function Board({ to: actualTargetSquare, }); - resetMoveClick(result ? undefined : piece ? square : undefined); }, [ @@ -981,6 +976,7 @@ function Board({ resetMoveClick, setClickedSquares, setMoveClickTo, + setAnimationDurationToUse, ] ); @@ -1099,7 +1095,7 @@ function Board({ document.addEventListener("contextmenu", handleContextMenuUndo, true); return () => document.removeEventListener("contextmenu", handleContextMenuUndo, true); - }, [undoMove, abortCustomDrag]); + }, [undoMove, abortCustomDrag, setAnimationDurationToUse]); const onPromotionPieceSelect = useCallback( (piece?: PromotionPieceOption, from?: Square, to?: Square) => { @@ -1254,50 +1250,49 @@ function Board({ onPointerDownCapture={handleBoardPointerDownCapture} onPointerUpCapture={handleBoardPointerUpCapture} > - - - handleSquareLeftClick(square, piece) - } - onSquareRightClick={handleSquareRightClick} - onPieceDragBegin={handlePieceDragBegin} - onPieceDragEnd={handlePieceDragEnd} - onPromotionPieceSelect={onPromotionPieceSelect} - showPromotionDialog={showPromotionDialog} - promotionToSquare={moveClickTo} - animationDuration={animationDurationToUse} - customPieces={customPieces} - /> - + + + handleSquareLeftClick(square, piece) + } + onSquareRightClick={handleSquareRightClick} + onPieceDragBegin={handlePieceDragBegin} + onPieceDragEnd={handlePieceDragEnd} + onPromotionPieceSelect={onPromotionPieceSelect} + showPromotionDialog={showPromotionDialog} + promotionToSquare={moveClickTo} + animationDuration={animationDurationToUse} + customPieces={customPieces} + /> + ((props, ref) => { const { children, square, style } = props; - const { backgroundColor, ...containerStyle } = (style || {}) as any; + const { backgroundColor, ...containerStyle } = (style || + {}) as React.CSSProperties; const { boardHue, diff --git a/src/components/prettyMoveSan/index.tsx b/src/components/prettyMoveSan/index.tsx index 954f6966..7e85a7c2 100644 --- a/src/components/prettyMoveSan/index.tsx +++ b/src/components/prettyMoveSan/index.tsx @@ -5,13 +5,8 @@ import { TypographyProps, useTheme, } from "@mui/material"; -import localFont from "next/font/local"; import { useMemo } from "react"; -const chessFont = localFont({ - src: "./chess_merida_unicode.ttf", -}); - interface Props { san: string; color: "w" | "b"; @@ -47,7 +42,7 @@ export default function PrettyMoveSan({ {icon && ( {icon} diff --git a/src/hooks/useEngine.ts b/src/hooks/useEngine.ts index 32b44373..96109a89 100644 --- a/src/hooks/useEngine.ts +++ b/src/hooks/useEngine.ts @@ -21,7 +21,7 @@ export const useEngine = (engineName: EngineName | undefined) => { pickEngine(engineName).then((newEngine) => { if (!isMounted) { - // If React unmounted this hook while the worker was booting, + // If React unmounted this hook while the worker was booting, // instantly kill the orphaned worker to prevent invisible memory/CPU leaks newEngine.shutdown(); return; diff --git a/src/lib/chess.ts b/src/lib/chess.ts index a84ac5e3..fadc52b1 100644 --- a/src/lib/chess.ts +++ b/src/lib/chess.ts @@ -257,22 +257,38 @@ export const getMaterialDifference = (fen: string): number => { for (let i = 0; i < placement.length; i++) { const c = placement[i]; switch (c) { - case "P": diff += 1; break; - case "N": case "B": diff += 3; break; - case "R": diff += 5; break; - case "Q": diff += 9; break; - case "p": diff -= 1; break; - case "n": case "b": diff -= 3; break; - case "r": diff -= 5; break; - case "q": diff -= 9; break; + case "P": + diff += 1; + break; + case "N": + case "B": + diff += 3; + break; + case "R": + diff += 5; + break; + case "Q": + diff += 9; + break; + case "p": + diff -= 1; + break; + case "n": + case "b": + diff -= 3; + break; + case "r": + diff -= 5; + break; + case "q": + diff -= 9; + break; } } return diff; }; - - export const isCheck = (fen: string): boolean => { const game = new Chess(fen); return game.inCheck(); diff --git a/src/lib/engine/uciEngine.ts b/src/lib/engine/uciEngine.ts index 1d618375..e2fc8e09 100644 --- a/src/lib/engine/uciEngine.ts +++ b/src/lib/engine/uciEngine.ts @@ -387,7 +387,7 @@ export class UciEngine { const onNewMessage = (messages: string[]) => { if (!setPartialEval) return; - + const now = performance.now(); if (now - lastUpdate < THROTTLE_MS) return; lastUpdate = now; diff --git a/src/lib/lichess.ts b/src/lib/lichess.ts index 222d4fdd..d13f4af2 100644 --- a/src/lib/lichess.ts +++ b/src/lib/lichess.ts @@ -90,7 +90,7 @@ const fetchLichessEval = async ( return res.json(); } catch (error) { - // We intentionally silence TimeoutError and DOMException logs here. + // We intentionally silence TimeoutError and DOMException logs here. // Failing over to the local WebAssembly engine is a routine, intended behavior // and does not need to flood the user's console visually. if (!(error instanceof Error && error.name === "TimeoutError")) { diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f863fb7d..7efc41dc 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -2,6 +2,7 @@ import "@fontsource/roboto/300.css"; import "@fontsource/roboto/400.css"; import "@fontsource/roboto/500.css"; import "@fontsource/roboto/700.css"; +import "@/components/board/board.css"; import { AppProps } from "next/app"; import Layout from "@/sections/layout"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; diff --git a/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx b/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx index f18c8927..5307ec9a 100644 --- a/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx +++ b/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx @@ -4,12 +4,12 @@ import { boardAtom, currentPositionAtom, engineMultiPvAtom, + boardAnimationDurationAtom, } from "../../../states"; import { useAtomValue, useSetAtom } from "jotai"; import { LineEval } from "@/types/eval"; import { useEffect, useRef } from "react"; import { useChessActions } from "@/hooks/useChessActions"; -import { boardAnimationDurationAtom } from "../../../states"; export default function EngineLines(props: GridProps) { const board = useAtomValue(boardAtom); @@ -24,9 +24,10 @@ export default function EngineLines(props: GridProps) { const isStale = position?.fen !== board.fen(); - const engineLines = position?.eval?.lines?.length && !isStale - ? position.eval.lines - : linesSkeleton; + const engineLines = + position?.eval?.lines?.length && !isStale + ? position.eval.lines + : linesSkeleton; const positionRef = useRef(position); const boardRef = useRef(board); @@ -60,7 +61,7 @@ export default function EngineLines(props: GridProps) { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, []); + }, [setAnimationDuration]); if (board.isCheckmate()) return null; diff --git a/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx b/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx index 6b7533e3..737e3ae3 100644 --- a/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx +++ b/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx @@ -1,12 +1,10 @@ import { LineEval } from "@/types/eval"; import { ListItem, Skeleton, Typography } from "@mui/material"; -import { useAtomValue } from "jotai"; -import { boardAtom } from "../../../states"; +import { useAtomValue, useSetAtom } from "jotai"; +import { boardAtom, boardAnimationDurationAtom } from "../../../states"; import { getLineEvalLabel, moveLineUciToSan } from "@/lib/chess"; import { useChessActions } from "@/hooks/useChessActions"; import PrettyMoveSan from "@/components/prettyMoveSan"; -import { useSetAtom } from "jotai"; -import { boardAnimationDurationAtom } from "../../../states"; interface Props { line: LineEval;