Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/components/Analysis/BroadcastAnalysis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -214,6 +215,12 @@ export const BroadcastAnalysis: React.FC<Props> = ({
{game.whitePlayer.rating && (
<span className="text-primary/60">({game.whitePlayer.rating})</span>
)}
<MaterialBalance
fen={analysisController.currentNode?.fen}
color="white"
iconClassName="!text-xs text-primary/70"
textClassName="text-primary/70"
/>
</div>
<div className="flex items-center gap-1">
{broadcastController.broadcastState.isLive && !game.termination ? (
Expand All @@ -238,6 +245,12 @@ export const BroadcastAnalysis: React.FC<Props> = ({
{game.blackPlayer.rating && (
<span className="text-primary/60">({game.blackPlayer.rating})</span>
)}
<MaterialBalance
fen={analysisController.currentNode?.fen}
color="black"
iconClassName="!text-xs text-primary/70"
textClassName="text-primary/70"
/>
</div>
</div>
</div>
Expand Down
13 changes: 13 additions & 0 deletions src/components/Analysis/StreamAnalysis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -225,6 +226,12 @@ export const StreamAnalysis: React.FC<Props> = ({
{game.whitePlayer.rating && (
<span className="text-primary/60">({game.whitePlayer.rating})</span>
)}
<MaterialBalance
fen={analysisController.currentNode?.fen}
color="white"
iconClassName="!text-xs text-primary/70"
textClassName="text-primary/70"
/>
</div>
<div className="flex items-center gap-1">
{streamState.isLive ? (
Expand All @@ -249,6 +256,12 @@ export const StreamAnalysis: React.FC<Props> = ({
{game.blackPlayer.rating && (
<span className="text-primary/60">({game.blackPlayer.rating})</span>
)}
<MaterialBalance
fen={analysisController.currentNode?.fen}
color="black"
iconClassName="!text-xs text-primary/70"
textClassName="text-primary/70"
/>
</div>
</div>
</div>
Expand Down
31 changes: 24 additions & 7 deletions src/components/Board/GameClock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,8 +14,15 @@ export const GameClock: React.FC<Props> = (
props: React.PropsWithChildren<Props>,
) => {
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<number>(Date.now())

Expand Down Expand Up @@ -54,11 +62,20 @@ export const GameClock: React.FC<Props> = (
<div
className={`flex items-center justify-between bg-glass-strong md:items-start md:justify-start ${active ? 'opacity-100' : 'opacity-50'} flex-row md:flex-col`}
>
<div className="px-4 py-2">
{props.player === 'black' ? '●' : '○'}{' '}
{player === props.player
? user?.displayName
: getMaiaDisplayName(maiaVersion)}
<div className="flex w-full items-center justify-between gap-3 px-4 py-2">
<span>
{props.player === 'black' ? '●' : '○'}{' '}
{player === props.player
? user?.displayName
: getMaiaDisplayName(maiaVersion)}
</span>
<MaterialBalance
fen={currentNode?.fen}
color={props.player}
className="gap-1.5"
iconClassName="!text-base md:!text-lg text-white/85"
textClassName="text-sm md:text-base text-white/85"
/>
</div>
<div className="inline-flex self-start px-4 py-2 md:text-3xl">
{minutes}:{('00' + seconds).slice(-2)}
Expand Down
208 changes: 208 additions & 0 deletions src/components/Common/MaterialBalance.tsx
Original file line number Diff line number Diff line change
@@ -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<PieceType, number>

const PIECE_DISPLAY_ORDER: PieceType[] = ['p', 'n', 'b', 'r', 'q']

const PIECE_VALUES: Record<PieceType, number> = {
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<PieceType, string> = {
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<PieceType, string> = {
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<Record<PieceType, number>>,
black: {} as Partial<Record<PieceType, number>>,
}

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<Record<PieceType, number>> = {}

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 (
<div className={`flex select-none items-center gap-1 ${className}`.trim()}>
<div className="flex items-center">
{PIECE_DISPLAY_ORDER.map((piece) => {
const count = materialData.pieceDifference[piece] ?? 0

if (count === 0) {
return null
}

return (
<div key={piece} className="flex items-center gap-0">
{Array.from({ length: count }).map((_, index) => (
<span
key={`${piece}-${index}`}
className={`material-symbols-outlined material-symbols-filled text-sm ${capturedPieceToneClass} ${index > 0 ? '-ml-1.5' : ''} ${iconClassName}`.trim()}
title={getPieceLabel(piece)}
>
{getPieceIcon(piece)}
</span>
))}
</div>
)
})}
</div>
{materialData.advantage > 0 && (
<span
className={`text-xxs font-medium text-secondary ${textClassName}`.trim()}
>
+{materialData.advantage}
</span>
)}
</div>
)
}
Loading
Loading