diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx index 0a14e551..8b1c63a1 100644 --- a/src/components/Analysis/BroadcastAnalysis.tsx +++ b/src/components/Analysis/BroadcastAnalysis.tsx @@ -13,6 +13,7 @@ import type { DrawShape } from 'chessground/draw' import { TABLET_BREAKPOINT_PX, WindowSizeContext } from 'src/contexts' import { MAIA_MODELS } from 'src/constants/common' import { GameInfo } from 'src/components/Common/GameInfo' +import { MaterialBalance } from 'src/components/Common/MaterialBalance' import { GameBoard } from 'src/components/Board/GameBoard' import { PlayerInfo } from 'src/components/Common/PlayerInfo' import { MovesContainer } from 'src/components/Board/MovesContainer' @@ -214,6 +215,12 @@ export const BroadcastAnalysis: React.FC = ({ {game.whitePlayer.rating && ( ({game.whitePlayer.rating}) )} +
{broadcastController.broadcastState.isLive && !game.termination ? ( @@ -238,6 +245,12 @@ export const BroadcastAnalysis: React.FC = ({ {game.blackPlayer.rating && ( ({game.blackPlayer.rating}) )} +
diff --git a/src/components/Analysis/StreamAnalysis.tsx b/src/components/Analysis/StreamAnalysis.tsx index d2030e2c..ae431e93 100644 --- a/src/components/Analysis/StreamAnalysis.tsx +++ b/src/components/Analysis/StreamAnalysis.tsx @@ -13,6 +13,7 @@ import type { DrawShape } from 'chessground/draw' import { TABLET_BREAKPOINT_PX, WindowSizeContext } from 'src/contexts' import { MAIA_MODELS } from 'src/constants/common' import { GameInfo } from 'src/components/Common/GameInfo' +import { MaterialBalance } from 'src/components/Common/MaterialBalance' import { GameBoard } from 'src/components/Board/GameBoard' import { PlayerInfo } from 'src/components/Common/PlayerInfo' import { MovesContainer } from 'src/components/Board/MovesContainer' @@ -225,6 +226,12 @@ export const StreamAnalysis: React.FC = ({ {game.whitePlayer.rating && ( ({game.whitePlayer.rating}) )} +
{streamState.isLive ? ( @@ -249,6 +256,12 @@ export const StreamAnalysis: React.FC = ({ {game.blackPlayer.rating && ( ({game.blackPlayer.rating}) )} +
diff --git a/src/components/Board/GameClock.tsx b/src/components/Board/GameClock.tsx index 265d44ca..4eacc17b 100644 --- a/src/components/Board/GameClock.tsx +++ b/src/components/Board/GameClock.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useContext } from 'react' import { Color } from 'src/types' import { AuthContext } from 'src/contexts' import { PlayControllerContext } from 'src/contexts/PlayControllerContext' +import { MaterialBalance } from 'src/components/Common/MaterialBalance' interface Props { player: Color @@ -13,8 +14,15 @@ export const GameClock: React.FC = ( props: React.PropsWithChildren, ) => { const { user } = useContext(AuthContext) - const { player, toPlay, whiteClock, blackClock, lastMoveTime, maiaVersion } = - useContext(PlayControllerContext) + const { + player, + toPlay, + whiteClock, + blackClock, + lastMoveTime, + maiaVersion, + currentNode, + } = useContext(PlayControllerContext) const [referenceTime, setReferenceTime] = useState(Date.now()) @@ -54,11 +62,20 @@ export const GameClock: React.FC = (
-
- {props.player === 'black' ? '●' : '○'}{' '} - {player === props.player - ? user?.displayName - : getMaiaDisplayName(maiaVersion)} +
+ + {props.player === 'black' ? '●' : '○'}{' '} + {player === props.player + ? user?.displayName + : getMaiaDisplayName(maiaVersion)} + +
{minutes}:{('00' + seconds).slice(-2)} diff --git a/src/components/Common/MaterialBalance.tsx b/src/components/Common/MaterialBalance.tsx new file mode 100644 index 00000000..6071e2e6 --- /dev/null +++ b/src/components/Common/MaterialBalance.tsx @@ -0,0 +1,208 @@ +import { useMemo } from 'react' +import type { Color } from 'src/types' +import { Chess } from 'chess.ts' + +type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k' +type MaterialCount = Record + +const PIECE_DISPLAY_ORDER: PieceType[] = ['p', 'n', 'b', 'r', 'q'] + +const PIECE_VALUES: Record = { + p: 1, + n: 3, + b: 3, + r: 5, + q: 9, + k: 0, +} + +const STARTING_MATERIAL: { white: MaterialCount; black: MaterialCount } = { + white: { p: 8, n: 2, b: 2, r: 2, q: 1, k: 1 }, + black: { p: 8, n: 2, b: 2, r: 2, q: 1, k: 1 }, +} + +const getPieceIcon = (piece: PieceType): string => { + const iconMap: Record = { + p: 'chess_pawn', + n: 'chess_knight', + b: 'chess_bishop', + r: 'chess_rook', + q: 'chess', + k: 'chess', + } + + return iconMap[piece] +} + +const getPieceLabel = (piece: PieceType): string => { + const labelMap: Record = { + p: 'pawn', + n: 'knight', + b: 'bishop', + r: 'rook', + q: 'queen', + k: 'king', + } + + return labelMap[piece] +} + +const calculateCapturedPieces = (fen: string) => { + const chess = new Chess(fen) + const board = chess.board() + + const currentMaterial: { white: MaterialCount; black: MaterialCount } = { + white: { p: 0, n: 0, b: 0, r: 0, q: 0, k: 0 }, + black: { p: 0, n: 0, b: 0, r: 0, q: 0, k: 0 }, + } + + for (const row of board) { + for (const square of row) { + if (!square) continue + + const piece = square.type.toLowerCase() as PieceType + const color = square.color === 'w' ? 'white' : 'black' + currentMaterial[color][piece]++ + } + } + + const captured = { + white: {} as Partial>, + black: {} as Partial>, + } + + for (const piece of Object.keys(STARTING_MATERIAL.white) as PieceType[]) { + const whiteCaptured = + STARTING_MATERIAL.white[piece] - currentMaterial.white[piece] + const blackCaptured = + STARTING_MATERIAL.black[piece] - currentMaterial.black[piece] + + if (whiteCaptured > 0) captured.white[piece] = whiteCaptured + if (blackCaptured > 0) captured.black[piece] = blackCaptured + } + + return captured +} + +const calculateMaterialAdvantage = (fen: string) => { + const chess = new Chess(fen) + const board = chess.board() + + let whiteTotal = 0 + let blackTotal = 0 + + for (const row of board) { + for (const square of row) { + if (!square) continue + + const piece = square.type.toLowerCase() as PieceType + if (square.color === 'w') { + whiteTotal += PIECE_VALUES[piece] + } else { + blackTotal += PIECE_VALUES[piece] + } + } + } + + return { white: whiteTotal, black: blackTotal } +} + +export const MaterialBalance = ({ + fen, + color, + className = '', + iconClassName = '', + textClassName = '', +}: { + fen?: string + color: Color + className?: string + iconClassName?: string + textClassName?: string +}) => { + const materialData = useMemo(() => { + if (!fen) { + return null + } + + const capturedPieces = calculateCapturedPieces(fen) + const materialAdvantage = calculateMaterialAdvantage(fen) + const myCapturedPieces = + color === 'white' ? capturedPieces.black : capturedPieces.white + const opponentCapturedPieces = + color === 'white' ? capturedPieces.white : capturedPieces.black + const pieceDifference: Partial> = {} + + PIECE_DISPLAY_ORDER.forEach((piece) => { + const myCount = myCapturedPieces[piece] ?? 0 + const opponentCount = opponentCapturedPieces[piece] ?? 0 + const net = myCount - opponentCount + + if (net > 0) { + pieceDifference[piece] = net + } + }) + + const netAdvantage = materialAdvantage.white - materialAdvantage.black + const advantage = + netAdvantage === 0 + ? 0 + : netAdvantage > 0 + ? color === 'white' + ? netAdvantage + : 0 + : color === 'black' + ? Math.abs(netAdvantage) + : 0 + + return { pieceDifference, advantage } + }, [color, fen]) + + if ( + !materialData || + (Object.keys(materialData.pieceDifference).length === 0 && + materialData.advantage === 0) + ) { + return null + } + + const capturedPieceToneClass = + color === 'white' + ? 'text-zinc-900 [text-shadow:0_0_1px_rgba(255,255,255,0.95)]' + : 'text-white [text-shadow:0_0_1px_rgba(0,0,0,0.9)]' + + return ( +
+
+ {PIECE_DISPLAY_ORDER.map((piece) => { + const count = materialData.pieceDifference[piece] ?? 0 + + if (count === 0) { + return null + } + + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + 0 ? '-ml-1.5' : ''} ${iconClassName}`.trim()} + title={getPieceLabel(piece)} + > + {getPieceIcon(piece)} + + ))} +
+ ) + })} +
+ {materialData.advantage > 0 && ( + + +{materialData.advantage} + + )} +
+ ) +} diff --git a/src/components/Common/PlayerInfo.tsx b/src/components/Common/PlayerInfo.tsx index 55d256de..cb754e50 100644 --- a/src/components/Common/PlayerInfo.tsx +++ b/src/components/Common/PlayerInfo.tsx @@ -14,96 +14,8 @@ interface PlayerInfoProps { rounded?: 'all' | 'top' | 'bottom' | 'none' } -import { useState, useEffect, useMemo } from 'react' -import { Chess } from 'chess.ts' - -type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k' -type MaterialCount = Record - -const PIECE_DISPLAY_ORDER: PieceType[] = ['p', 'n', 'b', 'r', 'q'] - -const PIECE_VALUES: Record = { - p: 1, // pawn - n: 3, // knight - b: 3, // bishop - r: 5, // rook - q: 9, // queen - k: 0, // king (not counted) -} - -const STARTING_MATERIAL: { white: MaterialCount; black: MaterialCount } = { - white: { p: 8, n: 2, b: 2, r: 2, q: 1, k: 1 }, - black: { p: 8, n: 2, b: 2, r: 2, q: 1, k: 1 }, -} - -const calculateCapturedPieces = (fen?: string) => { - if (!fen) return { white: {}, black: {} } - - const chess = new Chess(fen) - const board = chess.board() - - // Count current pieces on board - const currentMaterial: { white: MaterialCount; black: MaterialCount } = { - white: { p: 0, n: 0, b: 0, r: 0, q: 0, k: 0 }, - black: { p: 0, n: 0, b: 0, r: 0, q: 0, k: 0 }, - } - - for (const row of board) { - for (const square of row) { - if (square) { - const piece = square.type.toLowerCase() as PieceType - const color = square.color === 'w' ? 'white' : 'black' - currentMaterial[color][piece]++ - } - } - } - - // Calculate captured pieces (starting - current) - const captured = { - white: {} as Record, - black: {} as Record, - } - - for (const piece of Object.keys(STARTING_MATERIAL.white) as PieceType[]) { - const whiteCaptured = - STARTING_MATERIAL.white[piece] - currentMaterial.white[piece] - const blackCaptured = - STARTING_MATERIAL.black[piece] - currentMaterial.black[piece] - - if (whiteCaptured > 0) captured.white[piece] = whiteCaptured - if (blackCaptured > 0) captured.black[piece] = blackCaptured - } - - return captured -} - -const calculateMaterialAdvantage = ( - fen?: string, -): { white: number; black: number } => { - if (!fen) return { white: 0, black: 0 } - - const chess = new Chess(fen) - const board = chess.board() - - let whiteTotal = 0 - let blackTotal = 0 - - for (const row of board) { - for (const square of row) { - if (square) { - const piece = square.type.toLowerCase() - const value = PIECE_VALUES[piece] || 0 - if (square.color === 'w') { - whiteTotal += value - } else { - blackTotal += value - } - } - } - } - - return { white: whiteTotal, black: blackTotal } -} +import { useState, useEffect } from 'react' +import { MaterialBalance } from './MaterialBalance' export const PlayerInfo: React.FC = ({ name, @@ -112,7 +24,6 @@ export const PlayerInfo: React.FC = ({ termination, showArrowLegend = false, currentFen, - orientation = 'white', clock, rounded = 'all', }) => { @@ -120,99 +31,6 @@ export const PlayerInfo: React.FC = ({ clock?.timeInSeconds || 0, ) - // Calculate captured pieces and material advantage - const capturedPieces = useMemo( - () => calculateCapturedPieces(currentFen), - [currentFen], - ) - const materialAdvantage = useMemo( - () => calculateMaterialAdvantage(currentFen), - [currentFen], - ) - - // Get pieces captured by this player (pieces of opposite color that were captured) - const myCapturedPieces = - color === 'white' ? capturedPieces.black : capturedPieces.white - const opponentCapturedPieces = - color === 'white' ? capturedPieces.white : capturedPieces.black - - const pieceDifference = useMemo(() => { - const diff: Partial> = {} - - PIECE_DISPLAY_ORDER.forEach((piece) => { - const myCount = myCapturedPieces?.[piece] ?? 0 - const opponentCount = opponentCapturedPieces?.[piece] ?? 0 - const net = myCount - opponentCount - - if (net > 0) { - diff[piece] = net - } - }) - - return diff - }, [myCapturedPieces, opponentCapturedPieces]) - - // Calculate net material advantage (white total - black total) - const netAdvantage = materialAdvantage.white - materialAdvantage.black - - // Only show advantage for the side that actually has more material - const myAdvantage = useMemo(() => { - if (netAdvantage === 0) return 0 - - if (netAdvantage > 0) { - // White has advantage - return color === 'white' ? netAdvantage : 0 - } else { - // Black has advantage - return color === 'black' ? Math.abs(netAdvantage) : 0 - } - }, [netAdvantage, color]) - - // Map chess pieces to Material UI icons - const getPieceIcon = (piece: string): string => { - const iconMap: Record = { - p: 'chess_pawn', - n: 'chess_knight', - b: 'chess_bishop', - r: 'chess_rook', - q: 'chess', // queen uses 'chess' icon - } - return iconMap[piece] || 'chess' - } - - // Render captured pieces - const renderCapturedPieces = () => { - const pieceGroups: React.JSX.Element[] = [] - - // Order pieces by value (lowest to highest) - PIECE_DISPLAY_ORDER.forEach((piece) => { - const count = pieceDifference[piece] || 0 - if (count > 0) { - const piecesOfType: React.JSX.Element[] = [] - - for (let i = 0; i < count; i++) { - piecesOfType.push( - 0 ? '-ml-1.5' : ''}`} - title={`${piece === 'p' ? 'pawn' : piece === 'n' ? 'knight' : piece === 'b' ? 'bishop' : piece === 'r' ? 'rook' : 'queen'}`} - > - {getPieceIcon(piece)} - , - ) - } - - pieceGroups.push( -
- {piecesOfType} -
, - ) - } - }) - - return pieceGroups - } - useEffect(() => { if (!clock || !clock.isActive) return @@ -263,16 +81,7 @@ export const PlayerInfo: React.FC = ({ {rating ? `(${rating})` : null}

- {currentFen && ( -
-
{renderCapturedPieces()}
- {myAdvantage > 0 && ( - - +{myAdvantage} - - )} -
- )} +
{showArrowLegend && ( diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index a96494dd..3752fd08 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -49,6 +49,7 @@ import { MovesContainer } from 'src/components/Board/MovesContainer' import { BoardController } from 'src/components/Board/BoardController' import { PromotionOverlay } from 'src/components/Board/PromotionOverlay' import { GameInfo } from 'src/components/Common/GameInfo' +import { MaterialBalance } from 'src/components/Common/MaterialBalance' import Head from 'next/head' import toast from 'react-hot-toast' import type { NextPage } from 'next' @@ -1330,12 +1331,14 @@ const Analysis: React.FC = ({
-

- {formatAnalysisPlayerName(player.name)} -

- - {player.rating ? <>({player.rating}) : null} - +
+

+ {formatAnalysisPlayerName(player.name)} +

+ + {player.rating ? <>({player.rating}) : null} + +
{analyzedGame.termination?.winner === (index == 0 ? 'white' : 'black') ? ( @@ -1595,11 +1598,29 @@ const Analysis: React.FC = ({ White Win %
-
+
+
+ +
+
+ +
@@ -1866,10 +1887,30 @@ const Analysis: React.FC = ({ Maia %
- +
+
+ +
+ +
+ +
+
SF Eval diff --git a/src/pages/puzzles.tsx b/src/pages/puzzles.tsx index 84832151..9a7325fa 100644 --- a/src/pages/puzzles.tsx +++ b/src/pages/puzzles.tsx @@ -378,7 +378,6 @@ const Train: React.FC = ({ analysisController.currentNode, controller.currentNode, ]) - useEffect(() => { if (analysisEnabled && showAnalysis && !analysisSyncedRef.current) { // Set the analysis controller to the current training controller's node diff --git a/src/pages/turing.tsx b/src/pages/turing.tsx index 10a23869..9e4b23e4 100644 --- a/src/pages/turing.tsx +++ b/src/pages/turing.tsx @@ -19,6 +19,7 @@ import { BoardController, TuringSubmission, } from 'src/components' +import { MaterialBalance } from 'src/components/Common/MaterialBalance' import { AllStats } from 'src/hooks/useStats' import { TuringGame } from 'src/types/turing' import { useTuringController } from 'src/hooks/useTuringController/useTuringController' @@ -121,7 +122,14 @@ const Turing: React.FC = (props: Props) => { <>
- ● Unknown +
+ ● Unknown + +
{game.termination.winner === 'white' ? ( 1 @@ -133,7 +141,14 @@ const Turing: React.FC = (props: Props) => {
- ○ Unknown +
+ ○ Unknown + +
{game.termination.winner === 'black' ? ( 1