diff --git a/src/components/Analysis/AnalysisSidebar.tsx b/src/components/Analysis/AnalysisSidebar.tsx index c67a4dc3..a732d27d 100644 --- a/src/components/Analysis/AnalysisSidebar.tsx +++ b/src/components/Analysis/AnalysisSidebar.tsx @@ -141,6 +141,7 @@ export const AnalysisSidebar: React.FC = ({ const movesByRatingProps = { moves: analysisEnabled ? controller.movesByRating : undefined, colorSanMapping: analysisEnabled ? controller.colorSanMapping : {}, + positionKey: analysisEnabled ? controller.currentNode?.fen : undefined, } const renderHeader = ( diff --git a/src/components/Analysis/MovesByRating.tsx b/src/components/Analysis/MovesByRating.tsx index 2b1fe133..6cdf2356 100644 --- a/src/components/Analysis/MovesByRating.tsx +++ b/src/components/Analysis/MovesByRating.tsx @@ -8,7 +8,7 @@ import { CartesianGrid, ResponsiveContainer, } from 'recharts' -import { useContext } from 'react' +import { useContext, useEffect, useMemo, useRef, useState } from 'react' import { ColorSanMapping } from 'src/types' import { WindowSizeContext } from 'src/contexts' @@ -16,18 +16,45 @@ interface Props { moves: { [key: string]: number }[] | undefined colorSanMapping: ColorSanMapping isHomePage?: boolean + positionKey?: string } export const MovesByRating: React.FC = ({ moves, colorSanMapping, isHomePage = false, + positionKey, }: Props) => { const { isMobile } = useContext(WindowSizeContext) + const [displayedMoves, setDisplayedMoves] = useState(moves) + const [displayedColorSanMapping, setDisplayedColorSanMapping] = + useState(colorSanMapping) + const [displayedPositionKey, setDisplayedPositionKey] = useState(positionKey) + const lastRenderedPositionKeyRef = useRef(positionKey) + const shouldAnimateSeries = + !!displayedPositionKey && + displayedPositionKey !== lastRenderedPositionKeyRef.current - const maxValue = moves + useEffect(() => { + if (!moves?.length || !positionKey) return + setDisplayedMoves(moves) + setDisplayedColorSanMapping(colorSanMapping) + setDisplayedPositionKey(positionKey) + }, [moves, colorSanMapping, positionKey]) + + useEffect(() => { + if (!displayedPositionKey) return + lastRenderedPositionKeyRef.current = displayedPositionKey + }, [displayedPositionKey]) + + const moveKeys = useMemo(() => { + if (!displayedMoves?.length) return [] + return Object.keys(displayedMoves[0]).filter((move) => move !== 'rating') + }, [displayedMoves]) + + const maxValue = displayedMoves ? Math.max( - ...moves.flatMap((move) => + ...displayedMoves.flatMap((move) => Object.entries(move) .filter(([key]) => key !== 'rating') .map(([, value]) => value as number), @@ -48,7 +75,7 @@ export const MovesByRating: React.FC = ({ = ({ tickFormatter={(value) => `${value}%`} /> - {moves && - Object.keys(moves[0]).map((move, i) => { - if (move === 'rating') { - return null - } + {displayedMoves && + moveKeys.map((move) => { return ( = ({ > ) })} - {moves && + {displayedMoves && // First, collect all the end points and sort them by y-position (() => { - const lastIndex = moves.length - 1 + const lastIndex = displayedMoves.length - 1 // Define the type for end points interface EndPoint { @@ -142,42 +170,38 @@ export const MovesByRating: React.FC = ({ adjustment?: number } - const endPoints = Object.keys(moves[0]) - .filter((move) => move !== 'rating') + const endPoints = moveKeys .map((move) => { - const value = moves[lastIndex][move] as number + const value = displayedMoves[lastIndex][move] as number return { move, value, - san: colorSanMapping[move]?.san || move, - color: colorSanMapping[move]?.color ?? '#fff', + san: displayedColorSanMapping[move]?.san || move, + color: displayedColorSanMapping[move]?.color ?? '#fff', } as EndPoint }) .sort((a, b) => a.value - b.value) // Sort by value (y-position) // Return the original map function with adjusted positions - return Object.keys(moves[0]).map((move, i) => { - if (move === 'rating') { - return null - } - + return moveKeys.map((move, index) => { const endPoint = endPoints.find((ep) => ep.move === move) const san = endPoint?.san || move return ( = ({ dy={isMobile ? 3 : 4} fontSize={11} fontWeight={600} - fill={colorSanMapping[move]?.color ?? '#fff'} + fill={displayedColorSanMapping[move]?.color ?? '#fff'} textAnchor="start" > {san} @@ -298,8 +322,8 @@ export const MovesByRating: React.FC = ({ fontSize: 14, }} iconSize={0} - formatter={(value) => { - return colorSanMapping[value as string]?.san ?? value + formatter={(value: string) => { + return displayedColorSanMapping[value as string]?.san ?? value }} /> diff --git a/src/hooks/useAnalysisController/useAnalysisController.ts b/src/hooks/useAnalysisController/useAnalysisController.ts index ce594f0b..f81c585e 100644 --- a/src/hooks/useAnalysisController/useAnalysisController.ts +++ b/src/hooks/useAnalysisController/useAnalysisController.ts @@ -179,12 +179,12 @@ export const useAnalysisController = ( } if (moveEvaluation?.stockfish) { - const bestMove = Object.entries(moveEvaluation.stockfish.cp_vec)[0] + const bestMove = moveEvaluation.stockfish.model_move if (bestMove) { arrows.push({ brush: 'blue', - orig: bestMove[0].slice(0, 2) as Key, - dest: bestMove[0].slice(2, 4) as Key, + orig: bestMove.slice(0, 2) as Key, + dest: bestMove.slice(2, 4) as Key, modifiers: { lineWidth: 8 }, } as DrawShape) } diff --git a/src/hooks/useAnalysisController/useMoveRecommendations.ts b/src/hooks/useAnalysisController/useMoveRecommendations.ts index 7299fc86..e38ae1c3 100644 --- a/src/hooks/useAnalysisController/useMoveRecommendations.ts +++ b/src/hooks/useAnalysisController/useMoveRecommendations.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { Chess } from 'chess.ts' import { MAIA_MODELS } from 'src/constants/common' import { GameNode, MaiaEvaluation, StockfishEvaluation } from 'src/types' +import { sortStockfishMoves } from './utils' export const useMoveRecommendations = ( currentNode: GameNode | null, @@ -23,6 +24,7 @@ export const useMoveRecommendations = ( cp: number winrate?: number winrate_loss?: number + cp_relative?: number }[] isBlackTurn?: boolean } = { @@ -44,10 +46,14 @@ export const useMoveRecommendations = ( const cp_relative_vec = moveEvaluation.stockfish.cp_relative_vec || {} const winrate_vec = moveEvaluation.stockfish.winrate_vec || {} const winrate_loss_vec = moveEvaluation.stockfish.winrate_loss_vec || {} + const sortedMoves = sortStockfishMoves( + moveEvaluation.stockfish, + Object.keys(cp_vec), + ) - const stockfish = Object.entries(cp_vec).map(([move, cp]) => ({ + const stockfish = sortedMoves.map((move) => ({ move, - cp, + cp: cp_vec[move] || 0, winrate: winrate_vec[move] || 0, winrate_loss: winrate_loss_vec[move] || 0, cp_relative: cp_relative_vec[move] || 0, @@ -80,7 +86,10 @@ export const useMoveRecommendations = ( // Get top 3 Stockfish moves if (stockfish) { - for (const move of Object.keys(stockfish.cp_vec).slice(0, 3)) { + for (const move of sortStockfishMoves( + stockfish, + Object.keys(stockfish.cp_vec), + ).slice(0, 3)) { if (candidates.find((c) => c[0] === move)) continue candidates.push([move, move]) } diff --git a/src/hooks/useAnalysisController/utils.ts b/src/hooks/useAnalysisController/utils.ts index e8917a25..9f0dd04c 100644 --- a/src/hooks/useAnalysisController/utils.ts +++ b/src/hooks/useAnalysisController/utils.ts @@ -14,6 +14,51 @@ type ColorSanMappingResult = { } } +const getStockfishMoveOrderingScore = ( + stockfish: StockfishEvaluation, + move: string, +): number => { + const winrateLoss = stockfish.winrate_loss_vec?.[move] + if (winrateLoss !== undefined) { + return winrateLoss + } + + const relativeEval = stockfish.cp_relative_vec?.[move] + if (relativeEval !== undefined) { + return relativeEval + } + + const cp = stockfish.cp_vec?.[move] + if (cp !== undefined) { + return cp + } + + return Number.NEGATIVE_INFINITY +} + +export const sortStockfishMoves = ( + stockfish: StockfishEvaluation, + moves: string[], +): string[] => + [...moves].sort((a, b) => { + const scoreDiff = + getStockfishMoveOrderingScore(stockfish, b) - + getStockfishMoveOrderingScore(stockfish, a) + + if (scoreDiff !== 0) { + return scoreDiff + } + + const cpDiff = + (stockfish.cp_vec?.[b] ?? Number.NEGATIVE_INFINITY) - + (stockfish.cp_vec?.[a] ?? Number.NEGATIVE_INFINITY) + if (cpDiff !== 0) { + return cpDiff + } + + return a.localeCompare(b) + }) + // Unified function to calculate color for a single move export const calculateMoveColor = ( stockfish: StockfishEvaluation | undefined, @@ -56,6 +101,7 @@ export const generateColorSanMapping = ( const chess = new Chess(fen) const moves = chess.moves({ verbose: true }) + const moveKeys = moves.map((m) => `${m.from}${m.to}${m.promotion || ''}`) moves.forEach((m) => { const moveKey = `${m.from}${m.to}${m.promotion || ''}` mapping[moveKey] = { @@ -81,51 +127,37 @@ export const generateColorSanMapping = ( stockfish.winrate_loss_vec && Object.keys(stockfish.winrate_loss_vec).length > 0 ) { - const goodMoves = moves - .map((m) => `${m.from}${m.to}${m.promotion || ''}`) - .filter((move) => { + const goodMoves = sortStockfishMoves( + stockfish, + moveKeys.filter((move) => { const winrateLoss = stockfish.winrate_loss_vec?.[move] return ( winrateLoss !== undefined && winrateLoss >= -MOVE_CLASSIFICATION_THRESHOLDS.INACCURACY_THRESHOLD ) - }) - .sort((a, b) => { - const aLoss = stockfish.winrate_loss_vec?.[a] || 0 - const bLoss = stockfish.winrate_loss_vec?.[b] || 0 - return bLoss - aLoss - }) - - const okMoves = moves - .map((m) => `${m.from}${m.to}${m.promotion || ''}`) - .filter((move) => { + }), + ) + const okMoves = sortStockfishMoves( + stockfish, + moveKeys.filter((move) => { const winrateLoss = stockfish.winrate_loss_vec?.[move] return ( winrateLoss !== undefined && winrateLoss >= -MOVE_CLASSIFICATION_THRESHOLDS.BLUNDER_THRESHOLD && winrateLoss < -MOVE_CLASSIFICATION_THRESHOLDS.INACCURACY_THRESHOLD ) - }) - .sort((a, b) => { - const aLoss = stockfish.winrate_loss_vec?.[a] || 0 - const bLoss = stockfish.winrate_loss_vec?.[b] || 0 - return bLoss - aLoss - }) - - const blunderMoves = moves - .map((m) => `${m.from}${m.to}${m.promotion || ''}`) - .filter((move) => { + }), + ) + const blunderMoves = sortStockfishMoves( + stockfish, + moveKeys.filter((move) => { const winrateLoss = stockfish.winrate_loss_vec?.[move] return ( winrateLoss !== undefined && winrateLoss < -MOVE_CLASSIFICATION_THRESHOLDS.BLUNDER_THRESHOLD ) - }) - .sort((a, b) => { - const aLoss = stockfish.winrate_loss_vec?.[a] || 0 - const bLoss = stockfish.winrate_loss_vec?.[b] || 0 - return bLoss - aLoss - }) + }), + ) goodMoves.forEach((move, i) => { mapping[move].color = COLORS.good[Math.min(i, COLORS.good.length - 1)] @@ -140,30 +172,23 @@ export const generateColorSanMapping = ( COLORS.blunder[Math.min(i, COLORS.blunder.length - 1)] }) } else { - const goodMoves = moves - .map((m) => `${m.from}${m.to}${m.promotion || ''}`) - .filter((move) => stockfish.cp_relative_vec[move] >= -50) - .sort( - (a, b) => stockfish.cp_relative_vec[b] - stockfish.cp_relative_vec[a], - ) - - const okMoves = moves - .map((m) => `${m.from}${m.to}${m.promotion || ''}`) - .filter( - (move) => + const goodMoves = sortStockfishMoves( + stockfish, + moveKeys.filter((move) => stockfish.cp_relative_vec[move] >= -50), + ) + const okMoves = sortStockfishMoves( + stockfish, + moveKeys.filter((move) => { + return ( stockfish.cp_relative_vec[move] >= -150 && - stockfish.cp_relative_vec[move] < -50, - ) - .sort( - (a, b) => stockfish.cp_relative_vec[b] - stockfish.cp_relative_vec[a], - ) - - const blunderMoves = moves - .map((m) => `${m.from}${m.to}${m.promotion || ''}`) - .filter((move) => stockfish.cp_relative_vec[move] < -150) - .sort( - (a, b) => stockfish.cp_relative_vec[b] - stockfish.cp_relative_vec[a], - ) + stockfish.cp_relative_vec[move] < -50 + ) + }), + ) + const blunderMoves = sortStockfishMoves( + stockfish, + moveKeys.filter((move) => stockfish.cp_relative_vec[move] < -150), + ) goodMoves.forEach((move, i) => { mapping[move].color = COLORS.good[Math.min(i, COLORS.good.length - 1)]