|
| 1 | +import { useMemo } from 'react' |
| 2 | +import type { Color } from 'src/types' |
| 3 | +import { Chess } from 'chess.ts' |
| 4 | + |
| 5 | +type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k' |
| 6 | +type MaterialCount = Record<PieceType, number> |
| 7 | + |
| 8 | +const PIECE_DISPLAY_ORDER: PieceType[] = ['p', 'n', 'b', 'r', 'q'] |
| 9 | + |
| 10 | +const PIECE_VALUES: Record<PieceType, number> = { |
| 11 | + p: 1, |
| 12 | + n: 3, |
| 13 | + b: 3, |
| 14 | + r: 5, |
| 15 | + q: 9, |
| 16 | + k: 0, |
| 17 | +} |
| 18 | + |
| 19 | +const STARTING_MATERIAL: { white: MaterialCount; black: MaterialCount } = { |
| 20 | + white: { p: 8, n: 2, b: 2, r: 2, q: 1, k: 1 }, |
| 21 | + black: { p: 8, n: 2, b: 2, r: 2, q: 1, k: 1 }, |
| 22 | +} |
| 23 | + |
| 24 | +const getPieceIcon = (piece: PieceType): string => { |
| 25 | + const iconMap: Record<PieceType, string> = { |
| 26 | + p: 'chess_pawn', |
| 27 | + n: 'chess_knight', |
| 28 | + b: 'chess_bishop', |
| 29 | + r: 'chess_rook', |
| 30 | + q: 'chess', |
| 31 | + k: 'chess', |
| 32 | + } |
| 33 | + |
| 34 | + return iconMap[piece] |
| 35 | +} |
| 36 | + |
| 37 | +const getPieceLabel = (piece: PieceType): string => { |
| 38 | + const labelMap: Record<PieceType, string> = { |
| 39 | + p: 'pawn', |
| 40 | + n: 'knight', |
| 41 | + b: 'bishop', |
| 42 | + r: 'rook', |
| 43 | + q: 'queen', |
| 44 | + k: 'king', |
| 45 | + } |
| 46 | + |
| 47 | + return labelMap[piece] |
| 48 | +} |
| 49 | + |
| 50 | +const calculateCapturedPieces = (fen: string) => { |
| 51 | + const chess = new Chess(fen) |
| 52 | + const board = chess.board() |
| 53 | + |
| 54 | + const currentMaterial: { white: MaterialCount; black: MaterialCount } = { |
| 55 | + white: { p: 0, n: 0, b: 0, r: 0, q: 0, k: 0 }, |
| 56 | + black: { p: 0, n: 0, b: 0, r: 0, q: 0, k: 0 }, |
| 57 | + } |
| 58 | + |
| 59 | + for (const row of board) { |
| 60 | + for (const square of row) { |
| 61 | + if (!square) continue |
| 62 | + |
| 63 | + const piece = square.type.toLowerCase() as PieceType |
| 64 | + const color = square.color === 'w' ? 'white' : 'black' |
| 65 | + currentMaterial[color][piece]++ |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + const captured = { |
| 70 | + white: {} as Partial<Record<PieceType, number>>, |
| 71 | + black: {} as Partial<Record<PieceType, number>>, |
| 72 | + } |
| 73 | + |
| 74 | + for (const piece of Object.keys(STARTING_MATERIAL.white) as PieceType[]) { |
| 75 | + const whiteCaptured = |
| 76 | + STARTING_MATERIAL.white[piece] - currentMaterial.white[piece] |
| 77 | + const blackCaptured = |
| 78 | + STARTING_MATERIAL.black[piece] - currentMaterial.black[piece] |
| 79 | + |
| 80 | + if (whiteCaptured > 0) captured.white[piece] = whiteCaptured |
| 81 | + if (blackCaptured > 0) captured.black[piece] = blackCaptured |
| 82 | + } |
| 83 | + |
| 84 | + return captured |
| 85 | +} |
| 86 | + |
| 87 | +const calculateMaterialAdvantage = (fen: string) => { |
| 88 | + const chess = new Chess(fen) |
| 89 | + const board = chess.board() |
| 90 | + |
| 91 | + let whiteTotal = 0 |
| 92 | + let blackTotal = 0 |
| 93 | + |
| 94 | + for (const row of board) { |
| 95 | + for (const square of row) { |
| 96 | + if (!square) continue |
| 97 | + |
| 98 | + const piece = square.type.toLowerCase() as PieceType |
| 99 | + if (square.color === 'w') { |
| 100 | + whiteTotal += PIECE_VALUES[piece] |
| 101 | + } else { |
| 102 | + blackTotal += PIECE_VALUES[piece] |
| 103 | + } |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + return { white: whiteTotal, black: blackTotal } |
| 108 | +} |
| 109 | + |
| 110 | +export const MaterialBalance = ({ |
| 111 | + fen, |
| 112 | + color, |
| 113 | + className = '', |
| 114 | + iconClassName = '', |
| 115 | + textClassName = '', |
| 116 | +}: { |
| 117 | + fen?: string |
| 118 | + color: Color |
| 119 | + className?: string |
| 120 | + iconClassName?: string |
| 121 | + textClassName?: string |
| 122 | +}) => { |
| 123 | + const materialData = useMemo(() => { |
| 124 | + if (!fen) { |
| 125 | + return null |
| 126 | + } |
| 127 | + |
| 128 | + const capturedPieces = calculateCapturedPieces(fen) |
| 129 | + const materialAdvantage = calculateMaterialAdvantage(fen) |
| 130 | + const myCapturedPieces = |
| 131 | + color === 'white' ? capturedPieces.black : capturedPieces.white |
| 132 | + const opponentCapturedPieces = |
| 133 | + color === 'white' ? capturedPieces.white : capturedPieces.black |
| 134 | + const pieceDifference: Partial<Record<PieceType, number>> = {} |
| 135 | + |
| 136 | + PIECE_DISPLAY_ORDER.forEach((piece) => { |
| 137 | + const myCount = myCapturedPieces[piece] ?? 0 |
| 138 | + const opponentCount = opponentCapturedPieces[piece] ?? 0 |
| 139 | + const net = myCount - opponentCount |
| 140 | + |
| 141 | + if (net > 0) { |
| 142 | + pieceDifference[piece] = net |
| 143 | + } |
| 144 | + }) |
| 145 | + |
| 146 | + const netAdvantage = materialAdvantage.white - materialAdvantage.black |
| 147 | + const advantage = |
| 148 | + netAdvantage === 0 |
| 149 | + ? 0 |
| 150 | + : netAdvantage > 0 |
| 151 | + ? color === 'white' |
| 152 | + ? netAdvantage |
| 153 | + : 0 |
| 154 | + : color === 'black' |
| 155 | + ? Math.abs(netAdvantage) |
| 156 | + : 0 |
| 157 | + |
| 158 | + return { pieceDifference, advantage } |
| 159 | + }, [color, fen]) |
| 160 | + |
| 161 | + if ( |
| 162 | + !materialData || |
| 163 | + (Object.keys(materialData.pieceDifference).length === 0 && |
| 164 | + materialData.advantage === 0) |
| 165 | + ) { |
| 166 | + return null |
| 167 | + } |
| 168 | + |
| 169 | + const capturedPieceToneClass = |
| 170 | + color === 'white' |
| 171 | + ? 'text-zinc-900 [text-shadow:0_0_1px_rgba(255,255,255,0.95)]' |
| 172 | + : 'text-white [text-shadow:0_0_1px_rgba(0,0,0,0.9)]' |
| 173 | + |
| 174 | + return ( |
| 175 | + <div className={`flex select-none items-center gap-1 ${className}`.trim()}> |
| 176 | + <div className="flex items-center"> |
| 177 | + {PIECE_DISPLAY_ORDER.map((piece) => { |
| 178 | + const count = materialData.pieceDifference[piece] ?? 0 |
| 179 | + |
| 180 | + if (count === 0) { |
| 181 | + return null |
| 182 | + } |
| 183 | + |
| 184 | + return ( |
| 185 | + <div key={piece} className="flex items-center gap-0"> |
| 186 | + {Array.from({ length: count }).map((_, index) => ( |
| 187 | + <span |
| 188 | + key={`${piece}-${index}`} |
| 189 | + className={`material-symbols-outlined material-symbols-filled text-sm ${capturedPieceToneClass} ${index > 0 ? '-ml-1.5' : ''} ${iconClassName}`.trim()} |
| 190 | + title={getPieceLabel(piece)} |
| 191 | + > |
| 192 | + {getPieceIcon(piece)} |
| 193 | + </span> |
| 194 | + ))} |
| 195 | + </div> |
| 196 | + ) |
| 197 | + })} |
| 198 | + </div> |
| 199 | + {materialData.advantage > 0 && ( |
| 200 | + <span |
| 201 | + className={`text-xxs font-medium text-secondary ${textClassName}`.trim()} |
| 202 | + > |
| 203 | + +{materialData.advantage} |
| 204 | + </span> |
| 205 | + )} |
| 206 | + </div> |
| 207 | + ) |
| 208 | +} |
0 commit comments