From 99b0a9045f66afa3a6922933dc2ac9d6f1708a3e Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Wed, 6 Aug 2025 15:55:33 -0400 Subject: [PATCH 1/3] feat: add initial iteration of analysis summary modal --- .../Analysis/AnalysisSummaryModal.tsx | 242 ++++++++++++++++++ src/components/Analysis/index.ts | 1 + src/pages/analysis/[...id].tsx | 25 ++ 3 files changed, 268 insertions(+) create mode 100644 src/components/Analysis/AnalysisSummaryModal.tsx diff --git a/src/components/Analysis/AnalysisSummaryModal.tsx b/src/components/Analysis/AnalysisSummaryModal.tsx new file mode 100644 index 00000000..289cc69f --- /dev/null +++ b/src/components/Analysis/AnalysisSummaryModal.tsx @@ -0,0 +1,242 @@ +import React, { useMemo, useEffect } from 'react' +import { motion } from 'framer-motion' +import { AnalyzedGame } from 'src/types' +import { extractPlayerMistakes } from 'src/lib/analysis' + +interface Props { + isOpen: boolean + onClose: () => void + game: AnalyzedGame +} + +interface GameSummary { + totalMoves: number + whiteMistakes: { + total: number + blunders: number + inaccuracies: number + } + blackMistakes: { + total: number + blunders: number + inaccuracies: number + } + averageDepth: number + positionsAnalyzed: number +} + +export const AnalysisSummaryModal: React.FC = ({ + isOpen, + onClose, + game, +}) => { + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = 'unset' + } + + return () => { + document.body.style.overflow = 'unset' + } + }, [isOpen]) + + const summary = useMemo((): GameSummary => { + const mainLine = game.tree.getMainLine() + const whiteMistakes = extractPlayerMistakes(game.tree, 'white') + const blackMistakes = extractPlayerMistakes(game.tree, 'black') + + // Calculate analysis depth statistics + let totalDepth = 0 + let positionsAnalyzed = 0 + + mainLine.forEach((node) => { + if (node.analysis.stockfish && node.analysis.stockfish.depth > 0) { + totalDepth += node.analysis.stockfish.depth + positionsAnalyzed++ + } + }) + + const averageDepth = + positionsAnalyzed > 0 ? Math.round(totalDepth / positionsAnalyzed) : 0 + + return { + totalMoves: Math.ceil((mainLine.length - 1) / 2), // Convert plies to moves + whiteMistakes: { + total: whiteMistakes.length, + blunders: whiteMistakes.filter((m) => m.type === 'blunder').length, + inaccuracies: whiteMistakes.filter((m) => m.type === 'inaccuracy') + .length, + }, + blackMistakes: { + total: blackMistakes.length, + blunders: blackMistakes.filter((m) => m.type === 'blunder').length, + inaccuracies: blackMistakes.filter((m) => m.type === 'inaccuracy') + .length, + }, + averageDepth, + positionsAnalyzed, + } + }, [game.tree]) + + if (!isOpen) return null + + const MistakeSection = ({ + title, + color, + mistakes, + playerName, + }: { + title: string + color: string + mistakes: { total: number; blunders: number; inaccuracies: number } + playerName: string + }) => ( +
+
+
+

{title}

+ ({playerName}) +
+ + {mistakes.total === 0 ? ( +
+ + check_circle + + No significant mistakes detected +
+ ) : ( +
+
+ + {mistakes.blunders} + + Blunders +
+
+ + {mistakes.inaccuracies} + + Inaccuracies +
+
+ + {mistakes.total} + + Total +
+
+ )} +
+ ) + + return ( + { + if (e.target === e.currentTarget) onClose() + }} + > + e.stopPropagation()} + > +
+ + analytics + +

Analysis Summary

+
+ +
+ {/* Game Overview */} +
+

Game Overview

+
+
+ + {summary.totalMoves} + + Total Moves +
+
+ + {summary.positionsAnalyzed} + + Positions +
+
+ + d{summary.averageDepth} + + Avg Depth +
+
+ 100% + Complete +
+
+
+ + {/* Player Performance */} +
+

+ Player Performance +

+ + + + +
+ + {/* Analysis Tips */} +
+ + lightbulb + +
+

Next Steps

+

+ Navigate through the game to review specific positions. Use the + "Learn from Mistakes" feature to practice improving + the identified errors. +

+
+
+
+ +
+ +
+
+
+ ) +} diff --git a/src/components/Analysis/index.ts b/src/components/Analysis/index.ts index fc650a15..a9be0448 100644 --- a/src/components/Analysis/index.ts +++ b/src/components/Analysis/index.ts @@ -13,3 +13,4 @@ export * from './AnalysisOverlay' export * from './InteractiveDescription' export * from './AnalysisSidebar' export * from './LearnFromMistakes' +export * from './AnalysisSummaryModal' diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index 28757e06..b486bb9a 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -39,6 +39,7 @@ import { ConfigurableScreens } from 'src/components/Analysis/ConfigurableScreens import { AnalysisConfigModal } from 'src/components/Analysis/AnalysisConfigModal' import { AnalysisNotification } from 'src/components/Analysis/AnalysisNotification' import { AnalysisOverlay } from 'src/components/Analysis/AnalysisOverlay' +import { AnalysisSummaryModal } from 'src/components/Analysis/AnalysisSummaryModal' import { LearnFromMistakes } from 'src/components/Analysis/LearnFromMistakes' import { GameBoard } from 'src/components/Board/GameBoard' import { MovesContainer } from 'src/components/Board/MovesContainer' @@ -378,6 +379,8 @@ const Analysis: React.FC = ({ >(null) const [showCustomModal, setShowCustomModal] = useState(false) const [showAnalysisConfigModal, setShowAnalysisConfigModal] = useState(false) + const [showAnalysisSummaryModal, setShowAnalysisSummaryModal] = + useState(false) const [refreshTrigger, setRefreshTrigger] = useState(0) const [analysisEnabled, setAnalysisEnabled] = useState(true) // Analysis enabled by default const [lastMoveResult, setLastMoveResult] = useState< @@ -403,6 +406,19 @@ const Analysis: React.FC = ({ setHoverArrow(null) }, [controller.currentNode]) + // Show analysis summary modal when analysis completes + useEffect(() => { + if ( + controller.gameAnalysis.progress.isComplete && + !controller.gameAnalysis.progress.isCancelled + ) { + setShowAnalysisSummaryModal(true) + } + }, [ + controller.gameAnalysis.progress.isComplete, + controller.gameAnalysis.progress.isCancelled, + ]) + const launchContinue = useCallback(() => { const fen = controller.currentNode?.fen as string const url = '/play' + '?fen=' + encodeURIComponent(fen) @@ -1434,6 +1450,15 @@ const Analysis: React.FC = ({ /> )} + + {showAnalysisSummaryModal && ( + setShowAnalysisSummaryModal(false)} + game={analyzedGame} + /> + )} + {controller.gameAnalysis.progress.isAnalyzing && ( <> From 71ce04327a034f8716e5693a8cde4f1ba6a2f1ec Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Wed, 6 Aug 2025 16:02:26 -0400 Subject: [PATCH 2/3] feat: condense summary + add stockfish evaluation graph --- .../Analysis/AnalysisSummaryModal.tsx | 258 +++++++++--------- 1 file changed, 130 insertions(+), 128 deletions(-) diff --git a/src/components/Analysis/AnalysisSummaryModal.tsx b/src/components/Analysis/AnalysisSummaryModal.tsx index 289cc69f..db88f597 100644 --- a/src/components/Analysis/AnalysisSummaryModal.tsx +++ b/src/components/Analysis/AnalysisSummaryModal.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useEffect } from 'react' import { motion } from 'framer-motion' +import { LineChart, Line, XAxis, YAxis, ResponsiveContainer, ReferenceLine } from 'recharts' import { AnalyzedGame } from 'src/types' import { extractPlayerMistakes } from 'src/lib/analysis' @@ -10,7 +11,6 @@ interface Props { } interface GameSummary { - totalMoves: number whiteMistakes: { total: number blunders: number @@ -21,8 +21,11 @@ interface GameSummary { blunders: number inaccuracies: number } - averageDepth: number - positionsAnalyzed: number + evaluationData: Array<{ + ply: number + evaluation: number + moveNumber: number + }> } export const AnalysisSummaryModal: React.FC = ({ @@ -47,87 +50,79 @@ export const AnalysisSummaryModal: React.FC = ({ const whiteMistakes = extractPlayerMistakes(game.tree, 'white') const blackMistakes = extractPlayerMistakes(game.tree, 'black') - // Calculate analysis depth statistics - let totalDepth = 0 - let positionsAnalyzed = 0 - - mainLine.forEach((node) => { - if (node.analysis.stockfish && node.analysis.stockfish.depth > 0) { - totalDepth += node.analysis.stockfish.depth - positionsAnalyzed++ + // Generate evaluation data for the chart + const evaluationData = mainLine.slice(1).map((node, index) => { + const evaluation = node.analysis.stockfish?.model_optimal_cp || 0 + // Convert centipawns to evaluation (cap at +/- 5 for chart readability) + const normalizedEval = Math.max(-5, Math.min(5, evaluation / 100)) + + return { + ply: index + 1, + evaluation: normalizedEval, + moveNumber: Math.ceil((index + 1) / 2), } }) - const averageDepth = - positionsAnalyzed > 0 ? Math.round(totalDepth / positionsAnalyzed) : 0 - return { - totalMoves: Math.ceil((mainLine.length - 1) / 2), // Convert plies to moves whiteMistakes: { total: whiteMistakes.length, blunders: whiteMistakes.filter((m) => m.type === 'blunder').length, - inaccuracies: whiteMistakes.filter((m) => m.type === 'inaccuracy') - .length, + inaccuracies: whiteMistakes.filter((m) => m.type === 'inaccuracy').length, }, blackMistakes: { total: blackMistakes.length, blunders: blackMistakes.filter((m) => m.type === 'blunder').length, - inaccuracies: blackMistakes.filter((m) => m.type === 'inaccuracy') - .length, + inaccuracies: blackMistakes.filter((m) => m.type === 'inaccuracy').length, }, - averageDepth, - positionsAnalyzed, + evaluationData, } }, [game.tree]) if (!isOpen) return null - const MistakeSection = ({ - title, - color, - mistakes, - playerName, - }: { + const formatYAxisLabel = (value: number) => { + if (value === 0) return '0.00' + return value > 0 ? `+${value.toFixed(1)}` : `${value.toFixed(1)}` + } + + const PlayerPerformanceRow = ({ + title, + color, + mistakes, + playerName + }: { title: string color: string mistakes: { total: number; blunders: number; inaccuracies: number } - playerName: string + playerName: string }) => ( -
-
-
-

{title}

- ({playerName}) +
+
+
+
+

{playerName}

+

{title}

+
- + {mistakes.total === 0 ? ( -
- - check_circle - - No significant mistakes detected +
+ check_circle + Clean game
) : ( -
-
- - {mistakes.blunders} - - Blunders +
+
+ {mistakes.blunders} + blunders
-
- - {mistakes.inaccuracies} - - Inaccuracies +
+ {mistakes.inaccuracies} + inaccuracies
-
- - {mistakes.total} - - Total +
+ {mistakes.total} + total
)} @@ -136,7 +131,7 @@ export const AnalysisSummaryModal: React.FC = ({ return ( = ({ }} > e.stopPropagation()} > -
- - analytics - -

Analysis Summary

-
- -
- {/* Game Overview */} -
-

Game Overview

-
-
- - {summary.totalMoves} - - Total Moves -
-
- - {summary.positionsAnalyzed} - - Positions -
-
- - d{summary.averageDepth} - - Avg Depth -
-
- 100% - Complete -
-
+ {/* Header */} +
+
+ + analytics + +

Analysis Complete

+ +
+
{/* Player Performance */}
-

- Player Performance -

- - - - +

Player Performance

+
+ + +
- {/* Analysis Tips */} -
- - lightbulb - -
-

Next Steps

-

- Navigate through the game to review specific positions. Use the - "Learn from Mistakes" feature to practice improving - the identified errors. -

+ {/* Evaluation Chart */} +
+

Game Evaluation

+
+
+ + + + + + + + +
+
+
+
+ Stockfish Evaluation +
+ + Positive values favor White +
-
+ {/* Footer */} +
) -} +} \ No newline at end of file From 270ae486a8def69914464997c0dbaf52992faefc Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Wed, 6 Aug 2025 17:30:48 -0400 Subject: [PATCH 3/3] feat: improve summary modal --- .../Analysis/AnalysisSummaryModal.tsx | 372 +++++++++++++++--- 1 file changed, 316 insertions(+), 56 deletions(-) diff --git a/src/components/Analysis/AnalysisSummaryModal.tsx b/src/components/Analysis/AnalysisSummaryModal.tsx index db88f597..72fb2b5c 100644 --- a/src/components/Analysis/AnalysisSummaryModal.tsx +++ b/src/components/Analysis/AnalysisSummaryModal.tsx @@ -1,6 +1,17 @@ import React, { useMemo, useEffect } from 'react' import { motion } from 'framer-motion' -import { LineChart, Line, XAxis, YAxis, ResponsiveContainer, ReferenceLine } from 'recharts' +import { + ComposedChart, + Line, + Area, + XAxis, + YAxis, + ResponsiveContainer, + ReferenceLine, + CartesianGrid, + Tooltip, + Dot +} from 'recharts' import { AnalyzedGame } from 'src/types' import { extractPlayerMistakes } from 'src/lib/analysis' @@ -22,10 +33,98 @@ interface GameSummary { inaccuracies: number } evaluationData: Array<{ - ply: number + moveNumber: number | string evaluation: number + whiteAdvantage: number + blackAdvantage: number + san?: string + isBlunder: boolean + isInaccuracy: boolean + isWhiteMove: boolean + }> + criticalMoments: Array<{ moveNumber: number + san: string + playerColor: 'white' | 'black' + type: 'blunder' | 'inaccuracy' | 'excellent' + evaluation: number }> + gameInsights: { + totalMoves: number + turningPoints: number + maxAdvantage: { player: 'white' | 'black'; value: number } + gamePhase: 'opening' | 'middlegame' | 'endgame' + } +} + +// Custom dot component for move quality indicators +const CustomDot: React.FC<{ + cx?: number + cy?: number + payload?: { + isBlunder: boolean + isInaccuracy: boolean + isWhiteMove: boolean + } +}> = ({ cx, cy, payload }) => { + if (!payload || (!payload.isBlunder && !payload.isInaccuracy)) return null + + const color = payload.isBlunder ? '#ef4444' : '#eab308' // Red for blunders, yellow for inaccuracies + const radius = payload.isBlunder ? 5 : 4 + + return ( + + ) +} + +// Custom tooltip component +const CustomTooltip: React.FC<{ + active?: boolean + payload?: Array<{ + payload: { + san?: string + evaluation: number + moveNumber: number | string + isBlunder: boolean + isInaccuracy: boolean + isWhiteMove: boolean + } + }> +}> = ({ active, payload }) => { + if (!active || !payload || !payload[0]) return null + + const data = payload[0].payload + const formatEvaluation = (evaluation: number) => { + if (Math.abs(evaluation) >= 10) { + return evaluation > 0 ? '+M' : '-M' + } + return evaluation > 0 ? `+${evaluation.toFixed(1)}` : `${evaluation.toFixed(1)}` + } + + const moveType = data.isBlunder ? 'Blunder' : data.isInaccuracy ? 'Inaccuracy' : null + + return ( +
+

+ {typeof data.moveNumber === 'string' ? data.moveNumber : `${data.moveNumber}.`} {data.san} +

+

+ Evaluation: {formatEvaluation(data.evaluation)} +

+ {moveType && ( +

+ {moveType} +

+ )} +
+ ) } export const AnalysisSummaryModal: React.FC = ({ @@ -52,17 +151,62 @@ export const AnalysisSummaryModal: React.FC = ({ // Generate evaluation data for the chart const evaluationData = mainLine.slice(1).map((node, index) => { - const evaluation = node.analysis.stockfish?.model_optimal_cp || 0 - // Convert centipawns to evaluation (cap at +/- 5 for chart readability) - const normalizedEval = Math.max(-5, Math.min(5, evaluation / 100)) + const evaluation = (node.analysis.stockfish?.model_optimal_cp || 0) / 100 + // Cap evaluation for chart readability but store original for tooltips + const clampedEval = Math.max(-10, Math.min(10, evaluation)) + const isWhiteMove = index % 2 === 0 return { - ply: index + 1, - evaluation: normalizedEval, - moveNumber: Math.ceil((index + 1) / 2), + moveNumber: isWhiteMove ? Math.ceil((index + 1) / 2) : `${Math.ceil((index + 1) / 2)}...`, + evaluation: clampedEval, + whiteAdvantage: clampedEval > 0 ? clampedEval : 0, + blackAdvantage: clampedEval < 0 ? clampedEval : 0, + san: node.san || '', + isBlunder: node.blunder || false, + isInaccuracy: node.inaccuracy || false, + isWhiteMove, } }) + // Extract critical moments + const criticalMoments = mainLine + .slice(1) + .filter(node => node.blunder || node.inaccuracy || node.excellentMove) + .map((node, index) => ({ + moveNumber: node.moveNumber || Math.ceil((index + 1) / 2), + san: node.san || '', + playerColor: (node.moveNumber || 1) % 2 === 1 ? 'white' : 'black' as 'white' | 'black', + type: node.blunder ? 'blunder' : node.inaccuracy ? 'inaccuracy' : 'excellent' as 'blunder' | 'inaccuracy' | 'excellent', + evaluation: (node.analysis.stockfish?.model_optimal_cp || 0) / 100, + })) + .slice(0, 5) + + // Calculate game insights + const evaluations = evaluationData.map(d => d.evaluation) + const maxEval = Math.max(...evaluations) + const minEval = Math.min(...evaluations) + const maxAdvantageValue = Math.max(Math.abs(maxEval), Math.abs(minEval)) + const maxAdvantagePlayer = Math.abs(maxEval) > Math.abs(minEval) ? 'white' : 'black' + + // Count significant evaluation swings (turning points) + let turningPoints = 0 + for (let i = 1; i < evaluations.length - 1; i++) { + const prev = evaluations[i - 1] + const curr = evaluations[i] + const next = evaluations[i + 1] + if ((prev > 1 && curr < -1) || (prev < -1 && curr > 1) || + (Math.abs(curr - prev) > 2 && Math.abs(next - curr) > 2)) { + turningPoints++ + } + } + + const gameInsights = { + totalMoves: Math.ceil((mainLine.length - 1) / 2), + turningPoints, + maxAdvantage: { player: maxAdvantagePlayer, value: maxAdvantageValue }, + gamePhase: mainLine.length > 40 ? 'endgame' : mainLine.length > 20 ? 'middlegame' : 'opening' as 'opening' | 'middlegame' | 'endgame', + } + return { whiteMistakes: { total: whiteMistakes.length, @@ -75,14 +219,18 @@ export const AnalysisSummaryModal: React.FC = ({ inaccuracies: blackMistakes.filter((m) => m.type === 'inaccuracy').length, }, evaluationData, + criticalMoments, + gameInsights, } }, [game.tree]) if (!isOpen) return null - const formatYAxisLabel = (value: number) => { - if (value === 0) return '0.00' - return value > 0 ? `+${value.toFixed(1)}` : `${value.toFixed(1)}` + const formatEvaluation = (evaluation: number) => { + if (Math.abs(evaluation) >= 10) { + return evaluation > 0 ? '+M' : '-M' + } + return evaluation > 0 ? `+${evaluation.toFixed(1)}` : `${evaluation.toFixed(1)}` } const PlayerPerformanceRow = ({ @@ -131,7 +279,7 @@ export const AnalysisSummaryModal: React.FC = ({ return ( = ({ }} > = ({ analytics -

Analysis Complete

+
+

Analysis Complete

+

+ {summary.gameInsights.totalMoves} moves • {summary.gameInsights.gamePhase} phase +

+
-
- {/* Player Performance */} -
-

Player Performance

-
- - + {/* Content Grid */} +
+ {/* Left Column - Player Performance & Insights */} +
+ {/* Player Performance */} +
+

Player Performance

+
+ + +
+
+ + {/* Game Insights */} +
+

Game Insights

+
+
+

Turning Points

+

{summary.gameInsights.turningPoints}

+
+
+

Max Advantage

+

+ {formatEvaluation(summary.gameInsights.maxAdvantage.value)} +

+

+ {summary.gameInsights.maxAdvantage.player} +

+
+
+

Game Length

+

{summary.gameInsights.totalMoves}

+

moves

+
+
+

Phase

+

+ {summary.gameInsights.gamePhase} +

+
+
+ + {/* Critical Moments */} + {summary.criticalMoments.length > 0 && ( +
+

Critical Moments

+
+ {summary.criticalMoments.slice(0, 3).map((moment, index) => ( +
+
+
+

+ {moment.moveNumber}. {moment.san} +

+

+ {moment.type} • {formatEvaluation(moment.evaluation)} +

+
+
+ ))} +
+
+ )}
- {/* Evaluation Chart */} -
-

Game Evaluation

+ {/* Right Columns - Evaluation Chart */} +
+
+

Position Evaluation

+
+
+
+ White advantage +
+
+
+ Black advantage +
+
+
+
-
+
- + + - - } /> + + {/* White advantage area */} + + + {/* Black advantage area */} + + + + + } + activeDot={false} /> - +
-
-
-
- Stockfish Evaluation -
- - Positive values favor White -