diff --git a/src/components/Analysis/AnalysisSummaryModal.tsx b/src/components/Analysis/AnalysisSummaryModal.tsx new file mode 100644 index 00000000..72fb2b5c --- /dev/null +++ b/src/components/Analysis/AnalysisSummaryModal.tsx @@ -0,0 +1,504 @@ +import React, { useMemo, useEffect } from 'react' +import { motion } from 'framer-motion' +import { + ComposedChart, + Line, + Area, + XAxis, + YAxis, + ResponsiveContainer, + ReferenceLine, + CartesianGrid, + Tooltip, + Dot +} from 'recharts' +import { AnalyzedGame } from 'src/types' +import { extractPlayerMistakes } from 'src/lib/analysis' + +interface Props { + isOpen: boolean + onClose: () => void + game: AnalyzedGame +} + +interface GameSummary { + whiteMistakes: { + total: number + blunders: number + inaccuracies: number + } + blackMistakes: { + total: number + blunders: number + inaccuracies: number + } + evaluationData: Array<{ + 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 = ({ + 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') + + // Generate evaluation data for the chart + const evaluationData = mainLine.slice(1).map((node, index) => { + 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 { + 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, + 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, + }, + evaluationData, + criticalMoments, + gameInsights, + } + }, [game.tree]) + + if (!isOpen) return null + + 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 = ({ + title, + color, + mistakes, + playerName + }: { + title: string + color: string + mistakes: { total: number; blunders: number; inaccuracies: number } + playerName: string + }) => ( +
+
+
+
+

{playerName}

+

{title}

+
+
+ + {mistakes.total === 0 ? ( +
+ check_circle + Clean game +
+ ) : ( +
+
+ {mistakes.blunders} + blunders +
+
+ {mistakes.inaccuracies} + inaccuracies +
+
+ {mistakes.total} + total +
+
+ )} +
+ ) + + return ( + { + if (e.target === e.currentTarget) onClose() + }} + > + e.stopPropagation()} + > + {/* Header */} +
+
+ + analytics + +
+

Analysis Complete

+

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

+
+
+ +
+ + {/* 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)} +

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

Position Evaluation

+
+
+
+ White advantage +
+
+
+ Black advantage +
+
+
+ +
+
+ + + + + + } /> + + {/* White advantage area */} + + + {/* Black advantage area */} + + + + + } + activeDot={false} + /> + + +
+
+
+
+ + {/* Footer */} +
+ +
+ + + ) +} \ No newline at end of file diff --git a/src/components/Analysis/MoveMap.tsx b/src/components/Analysis/MoveMap.tsx index 27b23b83..70ed1ab9 100644 --- a/src/components/Analysis/MoveMap.tsx +++ b/src/components/Analysis/MoveMap.tsx @@ -330,7 +330,7 @@ export const MoveMap: React.FC = ({ tickMargin={0} tickLine={false} tickFormatter={(value) => `${value}%`} - domain={([dataMin, dataMax]) => [0, dataMax > 60 ? 100 : 60]} + domain={([dataMin, dataMax]) => [0, dataMax > 40 ? 100 : 40]} >