diff --git a/src/api/play.ts b/src/api/play.ts index c65220ee..8032f100 100644 --- a/src/api/play.ts +++ b/src/api/play.ts @@ -1,6 +1,14 @@ import { buildUrl } from './utils' import { Color, TimeControl } from 'src/types' +const normalizeOpeningBookFen = (fen: string) => { + const parts = fen.trim().split(/\s+/) + if (parts.length >= 4) { + return parts.slice(0, 4).join(' ') + } + return fen.trim() +} + export const startGame = async ( playerColor: Color, maiaVersion: string, @@ -107,27 +115,36 @@ export const fetchGameMove = async ( } export const fetchOpeningBookMoves = async (fen: string) => { - const res = await fetch(buildUrl(`play/get_book_moves?fen=${fen}`), { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', + const normalizedFen = normalizeOpeningBookFen(fen) + const res = await fetch( + buildUrl( + 'play/get_book_moves?' + + new URLSearchParams({ + fen: normalizedFen, + }), + ), + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + moves: [], + maia_names: [ + 'maia_kdd_1100', + 'maia_kdd_1200', + 'maia_kdd_1300', + 'maia_kdd_1400', + 'maia_kdd_1500', + 'maia_kdd_1600', + 'maia_kdd_1700', + 'maia_kdd_1800', + 'maia_kdd_1900', + ], + }), }, - body: JSON.stringify({ - moves: [], - maia_names: [ - 'maia_kdd_1100', - 'maia_kdd_1200', - 'maia_kdd_1300', - 'maia_kdd_1400', - 'maia_kdd_1500', - 'maia_kdd_1600', - 'maia_kdd_1700', - 'maia_kdd_1800', - 'maia_kdd_1900', - ], - }), - }) + ) return res.json() } diff --git a/src/components/Openings/OpeningDrillSidebar.tsx b/src/components/Openings/OpeningDrillSidebar.tsx index 6af82373..caed62b6 100644 --- a/src/components/Openings/OpeningDrillSidebar.tsx +++ b/src/components/Openings/OpeningDrillSidebar.tsx @@ -16,6 +16,7 @@ interface Props { analysisEnabled?: boolean continueAnalyzingMode?: boolean drillTerminationNote?: string + showBottomNavigation?: boolean } export const OpeningDrillSidebar: React.FC = ({ @@ -28,6 +29,7 @@ export const OpeningDrillSidebar: React.FC = ({ analysisEnabled, continueAnalyzingMode, drillTerminationNote, + showBottomNavigation = true, }) => { const containerClass = embedded ? 'flex h-full w-full max-w-full flex-col' @@ -304,7 +306,7 @@ export const OpeningDrillSidebar: React.FC = ({ {/* Bottom: Moves + Controller (embedded) */} - {tree?.gameTree && currentDrill && ( + {showBottomNavigation && tree?.gameTree && currentDrill && (
) const DRILL_STOCKFISH_TARGET_DEPTH = 18 +const DRILL_BOOK_SAMPLING_MAX_PLIES = 6 +const DRILL_BOOK_DEBUG_TAG = '[DRILL_BOOK]' + +const sampleWeightedMove = ( + moveWeights: Record, +): string | null => { + const entries = Object.entries(moveWeights).filter( + ([, weight]) => Number.isFinite(weight) && weight > 0, + ) + + if (!entries.length) { + return null + } + + const totalWeight = entries.reduce((sum, [, weight]) => sum + weight, 0) + if (totalWeight <= 0) { + return entries[0][0] + } + + let threshold = Math.random() * totalWeight + for (const [move, weight] of entries) { + threshold -= weight + if (threshold <= 0) { + return move + } + } + + return entries[entries.length - 1][0] +} const ensureValidFen = (fen: string): string => { const trimmed = fen.trim() @@ -138,6 +167,20 @@ const expandDrillSelections = ( return expanded } +const getRootNode = (node: GameNode): GameNode => { + let current = node + while (current.parent) { + current = current.parent + } + return current +} + +const createGameTreeFromRootNode = (rootNode: GameNode): GameTree => { + const tree = new GameTree(rootNode.fen) + ;(tree as unknown as { root: GameNode }).root = rootNode + return tree +} + type DrillCompletionReason = | 'target_moves_reached' | 'threefold_repetition' @@ -305,6 +348,9 @@ export const useOpeningDrillController = ( >(null) const [waitingForMaiaResponse, setWaitingForMaiaResponse] = useState(false) const [continueAnalyzingMode, setContinueAnalyzingMode] = useState(false) + const loadedCompletedDrillGameRef = useRef(null) + const loadedCompletedDrillFinalNodeRef = useRef(null) + const loadedCompletedDrillSelectionIdRef = useRef(null) const stockfish = useContext(StockfishEngineContext) const maiaEngine = useContext(MaiaEngineContext) @@ -404,6 +450,18 @@ export const useOpeningDrillController = ( return } + const loadedCompletedDrillGame = loadedCompletedDrillGameRef.current + if ( + loadedCompletedDrillGame && + loadedCompletedDrillGame.selection.id === currentDrill.id + ) { + setCurrentDrillGame(loadedCompletedDrillGame) + setWaitingForMaiaResponse(false) + setContinueAnalyzingMode(true) + loadedCompletedDrillGameRef.current = null + return + } + const startingFen = currentDrill.variation?.setupFen || currentDrill.opening.setupFen || @@ -454,6 +512,27 @@ export const useOpeningDrillController = ( } }, [currentDrillGame?.id, treeController]) + useEffect(() => { + if (!currentDrillGame) { + return + } + + const pendingFinalNode = loadedCompletedDrillFinalNodeRef.current + const pendingSelectionId = loadedCompletedDrillSelectionIdRef.current + + if ( + !pendingFinalNode || + !pendingSelectionId || + currentDrillGame.selection.id !== pendingSelectionId + ) { + return + } + + treeController.setCurrentNode(pendingFinalNode) + loadedCompletedDrillFinalNodeRef.current = null + loadedCompletedDrillSelectionIdRef.current = null + }, [currentDrillGame?.id, treeController]) + const isPlayerTurn = useMemo(() => { if (!currentDrillGame || !treeController.currentNode) return true const chess = new Chess(treeController.currentNode.fen) @@ -504,6 +583,53 @@ export const useOpeningDrillController = ( return moveMap }, [treeController.currentNode, isPlayerTurn]) + const getDrillMaiaPolicy = useCallback( + async (fen: string, maiaVersion: string) => { + let retries = 0 + const maxRetries = 30 + + while ( + maiaStatus !== 'ready' && + retries < maxRetries && + !analysisCancellationRef.current + ) { + await delay(100) + retries++ + } + + if ( + maiaStatus !== 'ready' || + !maiaInstance || + analysisCancellationRef.current + ) { + return null + } + + const rating = parseInt(maiaVersion.replace('maia_kdd_', ''), 10) + if (!Number.isFinite(rating)) { + return null + } + + try { + const { result } = await maiaInstance.batchEvaluate( + [fen], + [rating], + [rating], + ) + + return result?.[0]?.policy ?? null + } catch (error) { + console.warn( + DRILL_BOOK_DEBUG_TAG, + 'Failed to evaluate Maia policy for drill:', + error, + ) + return null + } + }, + [maiaInstance, maiaStatus], + ) + // Function to evaluate drill performance by extracting analysis from GameTree nodes const evaluateDrillPerformance = useCallback( async (drillGame: OpeningDrillGame): Promise => { @@ -1082,6 +1208,19 @@ export const useOpeningDrillController = ( setIsAnalyzingDrill(false) }, [setIsAnalyzingDrill, stockfish]) + const persistCompletedDrill = useCallback((drill: CompletedDrill) => { + setCompletedDrills((prev) => { + const alreadyPresent = prev.some((existing) => { + return ( + existing.selection.id === drill.selection.id && + existing.completedAt.getTime() === drill.completedAt.getTime() + ) + }) + + return alreadyPresent ? prev : [...prev, drill] + }) + }, []) + const resolveDrillEndReason = useCallback( ( drillGame: OpeningDrillGame, @@ -1160,7 +1299,7 @@ export const useOpeningDrillController = ( : performanceData setCurrentPerformanceData(enrichedPerformanceData) - setCompletedDrills((prev) => [...prev, enrichedPerformanceData.drill]) + persistCompletedDrill(enrichedPerformanceData.drill) // Simplified: just show the performance modal @@ -1176,6 +1315,7 @@ export const useOpeningDrillController = ( currentDrillGame, ensureDrillAnalysis, evaluateDrillPerformance, + persistCompletedDrill, resolveDrillEndReason, ], ) @@ -1198,6 +1338,9 @@ export const useOpeningDrillController = ( ) const moveToNextDrill = useCallback(() => { + if (currentPerformanceData?.drill) { + persistCompletedDrill(currentPerformanceData.drill) + } setShowPerformanceModal(false) setCurrentPerformanceData(null) setContinueAnalyzingMode(false) @@ -1208,7 +1351,7 @@ export const useOpeningDrillController = ( setDrillAnalysisProgress(getInitialAnalysisProgress()) setCurrentDrillGame(null) assignNextDrill() - }, [assignNextDrill]) + }, [assignNextDrill, currentPerformanceData, persistCompletedDrill]) // Continue analyzing current drill const continueAnalyzing = useCallback(() => { @@ -1248,6 +1391,44 @@ export const useOpeningDrillController = ( showPerformance() }, [showPerformance]) + const loadCompletedDrill = useCallback((completedDrill: CompletedDrill) => { + const rootNode = getRootNode(completedDrill.finalNode) + const restoredTree = createGameTreeFromRootNode(rootNode) + const finalPath = completedDrill.finalNode.getPath() + const openingEndNodeIndex = Math.max( + 0, + finalPath.length - completedDrill.allMoves.length - 1, + ) + const openingEndNode = + finalPath[openingEndNodeIndex] || restoredTree.getRoot() + + const restoredGame: OpeningDrillGame = { + id: completedDrill.selection.id, + selection: completedDrill.selection, + moves: completedDrill.allMoves, + tree: restoredTree, + currentFen: completedDrill.finalNode.fen, + toPlay: + new Chess(completedDrill.finalNode.fen).turn() === 'w' + ? 'white' + : 'black', + openingEndNode, + playerMoveCount: completedDrill.totalMoves, + } + + loadedCompletedDrillGameRef.current = restoredGame + loadedCompletedDrillFinalNodeRef.current = completedDrill.finalNode + loadedCompletedDrillSelectionIdRef.current = completedDrill.selection.id + setCurrentDrill(completedDrill.selection) + setCurrentDrillGame(restoredGame) + setAnalysisEnabled(true) + setContinueAnalyzingMode(true) + setShowPerformanceModal(false) + setCurrentPerformanceData(null) + setWaitingForMaiaResponse(false) + setDrillEndReasonMessage(null) + }, []) + // Reset drill to start over const resetDrillSession = useCallback(() => { attemptCountersRef.current = {} @@ -1405,18 +1586,122 @@ export const useOpeningDrillController = ( try { // Always respond from the tip of the main line, regardless of current view const tipNode = gameTree.getLastMainlineNode() - const path = tipNode.getPath() - const response = await fetchGameMove( - [], - currentDrill.maiaVersion, - tipNode.fen, - null, - 0, - 0, + const drillStartFen = + currentDrillGame.openingEndNode?.fen || + currentDrillGame.tree.getRoot().fen + const chess = new Chess(tipNode.fen) + const legalMoveSet = new Set( + chess + .moves({ verbose: true }) + .map((move) => `${move.from}${move.to}${move.promotion || ''}`), ) + let maiaMove: string | null = null + + if (currentDrillGame.moves.length < DRILL_BOOK_SAMPLING_MAX_PLIES) { + try { + const openingBookMoves = await fetchOpeningBookMoves(tipNode.fen) + const modelBookMoves = openingBookMoves?.[currentDrill.maiaVersion] + console.log(DRILL_BOOK_DEBUG_TAG, { + source: 'opening-book', + fen: tipNode.fen, + drillStartFen, + maiaVersion: currentDrill.maiaVersion, + moveCount: currentDrillGame.moves.length, + moves: currentDrillGame.moves, + distribution: modelBookMoves || null, + }) + if (modelBookMoves && Object.keys(modelBookMoves).length > 0) { + const filteredBookMoves = Object.entries(modelBookMoves).reduce< + Record + >((acc, [move, weight]) => { + if (legalMoveSet.has(move) && typeof weight === 'number') { + acc[move] = weight + } + return acc + }, {}) + maiaMove = sampleWeightedMove(filteredBookMoves) + console.log(DRILL_BOOK_DEBUG_TAG, { + source: 'opening-book-sampled', + fen: tipNode.fen, + maiaVersion: currentDrill.maiaVersion, + sampledMove: maiaMove, + filteredDistribution: filteredBookMoves, + }) + } + } catch (error) { + console.warn( + DRILL_BOOK_DEBUG_TAG, + 'Failed to fetch opening book moves for drill:', + error, + ) + } + } - console.log('Maia response:', response) - const maiaMove = response.top_move + if ( + !maiaMove && + currentDrillGame.moves.length < DRILL_BOOK_SAMPLING_MAX_PLIES + ) { + const maiaPolicy = await getDrillMaiaPolicy( + tipNode.fen, + currentDrill.maiaVersion, + ) + const filteredPolicy = maiaPolicy + ? Object.entries(maiaPolicy).reduce>( + (acc, [move, weight]) => { + if ( + legalMoveSet.has(move) && + typeof weight === 'number' && + weight > 0 + ) { + acc[move] = weight + } + return acc + }, + {}, + ) + : null + console.log(DRILL_BOOK_DEBUG_TAG, { + source: 'maia-policy', + fen: tipNode.fen, + drillStartFen, + maiaVersion: currentDrill.maiaVersion, + moveCount: currentDrillGame.moves.length, + moves: currentDrillGame.moves, + distribution: filteredPolicy, + }) + + if (filteredPolicy && Object.keys(filteredPolicy).length > 0) { + maiaMove = sampleWeightedMove(filteredPolicy) + console.log(DRILL_BOOK_DEBUG_TAG, { + source: 'maia-policy-sampled', + fen: tipNode.fen, + maiaVersion: currentDrill.maiaVersion, + sampledMove: maiaMove, + }) + } + } + + if (!maiaMove) { + const response = await fetchGameMove( + currentDrillGame.moves, + currentDrill.maiaVersion, + drillStartFen, + null, + 0, + 0, + ) + + console.log(DRILL_BOOK_DEBUG_TAG, { + source: 'fetch-game-move-fallback', + fen: tipNode.fen, + drillStartFen, + maiaVersion: currentDrill.maiaVersion, + moveCount: currentDrillGame.moves.length, + moves: currentDrillGame.moves, + response, + }) + maiaMove = response.top_move + } if (maiaMove && maiaMove.length >= 4) { let newNode: GameNode | null = null @@ -1684,5 +1969,8 @@ export const useOpeningDrillController = ( // Show performance modal for current drill showCurrentPerformance, + + // Load a previously completed drill into analysis mode + loadCompletedDrill, } } diff --git a/src/pages/openings/index.tsx b/src/pages/openings/index.tsx index 52c97ff9..57bd728d 100644 --- a/src/pages/openings/index.tsx +++ b/src/pages/openings/index.tsx @@ -1,18 +1,33 @@ import Head from 'next/head' import { NextPage } from 'next' -import { useState, useEffect, useContext, useCallback, useMemo } from 'react' +import { + useState, + useEffect, + useLayoutEffect, + useContext, + useCallback, + useMemo, + useRef, +} from 'react' import { useRouter } from 'next/router' import { Chess, PieceSymbol } from 'chess.ts' -import { AnimatePresence } from 'framer-motion' +import { AnimatePresence, useSpring, useTransform } from 'framer-motion' import type { Key } from 'chessground/types' -import type { DrawShape } from 'chessground/draw' +import type { DrawBrushes, DrawShape } from 'chessground/draw' import { WindowSizeContext, + PHONE_BREAKPOINT_PX, MaiaEngineContext, TreeControllerContext, } from 'src/contexts' -import { DrillConfiguration, AnalyzedGame } from 'src/types' +import { + DrillConfiguration, + AnalyzedGame, + GameNode, + MaiaEvaluation, + StockfishEvaluation, +} from 'src/types' import { MovesContainer, BoardController, @@ -21,16 +36,29 @@ import { DrillPerformanceModal, GameBoard, PromotionOverlay, - PlayerInfo, DownloadModelModal, AuthenticatedWrapper, AnalysisNotification, AnalysisOverlay, } from 'src/components' +import { + AnalysisSidebar, + ConfigurableScreens, + SimplifiedAnalysisOverview, + MovesByRating, +} from 'src/components/Analysis' +import { + AnalysisArrowLegend, + AnalysisCompactBlunderMeter, + AnalysisMaiaWinrateBar, + AnalysisStockfishEvalBar, +} from 'src/components/Analysis/BoardChrome' +import { MaterialBalance } from 'src/components/Common/MaterialBalance' import openings from 'src/constants/maia_openings_expanded.json' import endgamesRaw from 'src/constants/endgames.json' import { buildEndgameDataset, createEndgameOpenings } from 'src/lib/endgames' -import { OpeningDrillAnalysis } from 'src/components/Openings/OpeningDrillAnalysis' +import { MAIA_MODELS } from 'src/constants/common' +import { cpToWinrate } from 'src/lib/analysis' import { useOpeningDrillController, useAnalysisController } from 'src/hooks' import { @@ -39,6 +67,16 @@ import { requiresPromotion, } from 'src/lib/puzzle' +const EVAL_BAR_RANGE = 4 +const DEFAULT_STOCKFISH_EVAL_BAR = { + hasEval: false, + pawns: 0, + displayPawns: 0, + label: '--', +} +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect + const OpeningsPage: NextPage = () => { const router = useRouter() const [showSelectionModal, setShowSelectionModal] = useState(true) @@ -73,7 +111,9 @@ const OpeningsPage: NextPage = () => { ) const controller = useOpeningDrillController(safeConfiguration) - const { isMobile } = useContext(WindowSizeContext) + const { width: windowWidth, height: windowHeight } = + useContext(WindowSizeContext) + const [, setCurrentSquare] = useState(null) const playerNames = useMemo(() => { if (!controller.currentDrill) return null @@ -201,6 +241,482 @@ const OpeningsPage: NextPage = () => { analysisController.setCurrentNode, ]) + // --- Board chrome state (mirrors analysis page) --- + const width = windowWidth + const height = windowHeight + const isPhone = useMemo( + () => width > 0 && width <= PHONE_BREAKPOINT_PX, + [width], + ) + const useMobileStyleAnalysisLayout = useMemo(() => { + if (isPhone) return true + + return width > PHONE_BREAKPOINT_PX && width <= 1120 + }, [isPhone, width]) + const isTabletUsingMobileStyleLayout = useMemo( + () => useMobileStyleAnalysisLayout && !isPhone, + [isPhone, useMobileStyleAnalysisLayout], + ) + const analysisEnabled = + controller.analysisEnabled || controller.continueAnalyzingMode + + const emptyBlunderMeterData = useMemo( + () => ({ + goodMoves: { moves: [], probability: 0 }, + okMoves: { moves: [], probability: 0 }, + blunderMoves: { moves: [], probability: 0 }, + }), + [], + ) + const emptyRecommendations = useMemo( + () => ({ + maia: undefined, + stockfish: undefined, + }), + [], + ) + + const rawCompactBlunderMeterData = useMemo( + () => + analysisEnabled ? analysisController.blunderMeter : emptyBlunderMeterData, + [analysisEnabled, analysisController.blunderMeter, emptyBlunderMeterData], + ) + const [ + displayedCompactBlunderMeterData, + setDisplayedCompactBlunderMeterData, + ] = useState(rawCompactBlunderMeterData) + const hasUsableBlunderMeterData = useMemo(() => { + const totalProbability = + rawCompactBlunderMeterData.goodMoves.probability + + rawCompactBlunderMeterData.okMoves.probability + + rawCompactBlunderMeterData.blunderMoves.probability + const hasMoves = + rawCompactBlunderMeterData.goodMoves.moves.length > 0 || + rawCompactBlunderMeterData.okMoves.moves.length > 0 || + rawCompactBlunderMeterData.blunderMoves.moves.length > 0 + return totalProbability > 0 || hasMoves + }, [rawCompactBlunderMeterData]) + useIsomorphicLayoutEffect(() => { + if (!analysisEnabled) { + setDisplayedCompactBlunderMeterData(emptyBlunderMeterData) + return + } + if (hasUsableBlunderMeterData) { + setDisplayedCompactBlunderMeterData(rawCompactBlunderMeterData) + } + }, [ + analysisEnabled, + emptyBlunderMeterData, + hasUsableBlunderMeterData, + rawCompactBlunderMeterData, + ]) + const compactBlunderMeterData = displayedCompactBlunderMeterData + + const currentTurnForBars: 'w' | 'b' = + analysisController.currentNode?.turn || 'w' + const isCurrentPositionCheckmateForBars = useMemo(() => { + if (!analysisController.currentNode) return false + try { + const chess = new Chess(analysisController.currentNode.fen) + return chess.inCheckmate() + } catch { + return false + } + }, [analysisController.currentNode]) + const isInFirst10PlyForBars = useMemo(() => { + if (!analysisController.currentNode) return false + const moveNumber = analysisController.currentNode.moveNumber + const turn = analysisController.currentNode.turn + const plyFromStart = (moveNumber - 1) * 2 + (turn === 'b' ? 1 : 0) + return plyFromStart < 10 + }, [analysisController.currentNode]) + + // Stockfish eval bar + const rawStockfishEvalBar = useMemo(() => { + const stockfish = analysisController.moveEvaluation?.stockfish + const sideToMove = analysisController.currentNode?.turn || 'w' + if (!stockfish) return { ...DEFAULT_STOCKFISH_EVAL_BAR, depth: 0 } + + const mateIn = stockfish.mate_vec?.[stockfish.model_move] + if (mateIn !== undefined) { + const matingColor = + mateIn > 0 ? sideToMove : sideToMove === 'w' ? 'b' : 'w' + const whitePerspectiveSign = matingColor === 'w' ? 1 : -1 + return { + hasEval: true, + pawns: whitePerspectiveSign * EVAL_BAR_RANGE, + displayPawns: whitePerspectiveSign * EVAL_BAR_RANGE, + label: `M${Math.abs(mateIn)}`, + depth: stockfish.depth ?? 0, + } + } + + const cp = + stockfish.model_optimal_cp ?? Object.values(stockfish.cp_vec)[0] ?? 0 + const rawPawns = cp / 100 + const clampedPawns = Math.max( + -EVAL_BAR_RANGE, + Math.min(EVAL_BAR_RANGE, rawPawns), + ) + return { + hasEval: true, + pawns: clampedPawns, + displayPawns: rawPawns, + label: `${rawPawns > 0 ? '+' : ''}${rawPawns.toFixed(2)}`, + depth: stockfish.depth ?? 0, + } + }, [ + analysisController.currentNode?.turn, + analysisController.moveEvaluation?.stockfish, + ]) + + const [displayedStockfishEvalBar, setDisplayedStockfishEvalBar] = useState( + DEFAULT_STOCKFISH_EVAL_BAR, + ) + useIsomorphicLayoutEffect(() => { + if (!rawStockfishEvalBar.hasEval || rawStockfishEvalBar.depth <= 10) return + setDisplayedStockfishEvalBar({ + hasEval: true, + pawns: rawStockfishEvalBar.pawns, + displayPawns: rawStockfishEvalBar.displayPawns, + label: rawStockfishEvalBar.label, + }) + }, [rawStockfishEvalBar]) + const evalPositionPercent = useMemo(() => { + const normalized = + (displayedStockfishEvalBar.pawns + EVAL_BAR_RANGE) / (EVAL_BAR_RANGE * 2) + return Math.max(0, Math.min(1, normalized)) * 100 + }, [displayedStockfishEvalBar.pawns]) + const renderedStockfishEvalBar = useMemo( + () => + analysisEnabled + ? displayedStockfishEvalBar + : { hasEval: false, pawns: 0, displayPawns: 0, label: '--' }, + [analysisEnabled, displayedStockfishEvalBar], + ) + const displayedStockfishEvalText = useMemo(() => { + if (!displayedStockfishEvalBar.hasEval) return '--' + if (displayedStockfishEvalBar.label.startsWith('M')) + return displayedStockfishEvalBar.label + const roundedPawns = + Math.round(displayedStockfishEvalBar.displayPawns * 10) / 10 + const safePawns = Math.abs(roundedPawns) < 0.05 ? 0 : roundedPawns + return `${safePawns > 0 ? '+' : ''}${safePawns.toFixed(1)}` + }, [displayedStockfishEvalBar]) + const renderedStockfishEvalText = analysisEnabled + ? displayedStockfishEvalText + : '--' + const evalBarPositionTargetPercent = analysisEnabled + ? evalPositionPercent + : 50 + const smoothedEvalPosition = useSpring(50, { + stiffness: 520, + damping: 42, + mass: 0.25, + }) + const smoothedEvalVerticalPositionLabel = useTransform( + smoothedEvalPosition, + (value) => `${100 - value}%`, + ) + useIsomorphicLayoutEffect(() => { + smoothedEvalPosition.set(evalBarPositionTargetPercent) + }, [evalBarPositionTargetPercent, smoothedEvalPosition]) + + // Maia win rate bar + const rawMaiaWhiteWinBar = useMemo(() => { + const stockfishEval = analysisController.moveEvaluation?.stockfish + if (isCurrentPositionCheckmateForBars) { + const percent = currentTurnForBars === 'w' ? 0 : 100 + return { hasValue: true, percent, label: `${percent.toFixed(1)}%` } + } + if (stockfishEval?.is_checkmate) { + const percent = currentTurnForBars === 'w' ? 0 : 100 + return { hasValue: true, percent, label: `${percent.toFixed(1)}%` } + } + if ( + stockfishEval?.model_move && + stockfishEval.mate_vec && + stockfishEval.mate_vec[stockfishEval.model_move] !== undefined + ) { + const mateValue = stockfishEval.mate_vec[stockfishEval.model_move] + const deliveringColor = + mateValue > 0 + ? currentTurnForBars + : currentTurnForBars === 'w' + ? 'b' + : 'w' + const percent = deliveringColor === 'w' ? 100 : 0 + return { hasValue: true, percent, label: `${percent.toFixed(1)}%` } + } + if ( + isInFirst10PlyForBars && + stockfishEval?.model_optimal_cp !== undefined + ) { + const percent = Math.max( + 0, + Math.min(100, cpToWinrate(stockfishEval.model_optimal_cp) * 100), + ) + return { + hasValue: true, + percent, + label: `${(Math.round(percent * 10) / 10).toFixed(1)}%`, + } + } + if (analysisController.moveEvaluation?.maia) { + const percent = Math.max( + 0, + Math.min(100, analysisController.moveEvaluation.maia.value * 100), + ) + return { + hasValue: true, + percent, + label: `${(Math.round(percent * 10) / 10).toFixed(1)}%`, + } + } + return { hasValue: false, percent: 50, label: '--' } + }, [ + analysisController.moveEvaluation?.maia, + analysisController.moveEvaluation?.stockfish, + currentTurnForBars, + isCurrentPositionCheckmateForBars, + isInFirst10PlyForBars, + ]) + + const [displayedMaiaWhiteWinBar, setDisplayedMaiaWhiteWinBar] = + useState(rawMaiaWhiteWinBar) + useIsomorphicLayoutEffect(() => { + if (!rawMaiaWhiteWinBar.hasValue) return + setDisplayedMaiaWhiteWinBar(rawMaiaWhiteWinBar) + }, [rawMaiaWhiteWinBar]) + const maiaWhiteWinPositionPercent = useMemo( + () => Math.max(0, Math.min(100, displayedMaiaWhiteWinBar.percent)), + [displayedMaiaWhiteWinBar.percent], + ) + const renderedMaiaWhiteWinBar = useMemo( + () => + analysisEnabled + ? displayedMaiaWhiteWinBar + : { hasValue: false, percent: 50, label: '--' }, + [analysisEnabled, displayedMaiaWhiteWinBar], + ) + const maiaWhiteWinBarPositionTargetPercent = analysisEnabled + ? maiaWhiteWinPositionPercent + : 50 + const smoothedMaiaWhiteWinPosition = useSpring(50, { + stiffness: 520, + damping: 42, + mass: 0.25, + }) + const smoothedMaiaWhiteWinVerticalPositionLabel = useTransform( + smoothedMaiaWhiteWinPosition, + (value) => `${100 - value}%`, + ) + useIsomorphicLayoutEffect(() => { + smoothedMaiaWhiteWinPosition.set(maiaWhiteWinBarPositionTargetPercent) + }, [maiaWhiteWinBarPositionTargetPercent, smoothedMaiaWhiteWinPosition]) + + // Desktop board sizing + const desktopBoardHeaderStripRef = useRef(null) + const desktopBlunderMeterSectionRef = useRef(null) + const [desktopMiddleMeasuredHeights, setDesktopMiddleMeasuredHeights] = + useState({ + boardHeaderStripPx: 28, + blunderMeterSectionPx: 126, + }) + useIsomorphicLayoutEffect(() => { + if (useMobileStyleAnalysisLayout) return + const headerEl = desktopBoardHeaderStripRef.current + const blunderEl = desktopBlunderMeterSectionRef.current + if (!headerEl && !blunderEl) return + const updateHeights = () => { + const next = { + boardHeaderStripPx: + headerEl?.getBoundingClientRect().height ?? + desktopMiddleMeasuredHeights.boardHeaderStripPx, + blunderMeterSectionPx: + blunderEl?.getBoundingClientRect().height ?? + desktopMiddleMeasuredHeights.blunderMeterSectionPx, + } + setDesktopMiddleMeasuredHeights((prev) => { + if ( + Math.abs(prev.boardHeaderStripPx - next.boardHeaderStripPx) < 0.5 && + Math.abs(prev.blunderMeterSectionPx - next.blunderMeterSectionPx) < + 0.5 + ) + return prev + return next + }) + } + updateHeights() + }, [useMobileStyleAnalysisLayout, width]) + const desktopMaiaBubbleReservePx = useMemo( + () => (width >= 1360 ? 62 : 52), + [width], + ) + const desktopEvalBubbleReservePx = useMemo( + () => (width >= 1360 ? 56 : 48), + [width], + ) + const desktopEvalGutterWidthPx = useMemo( + () => desktopEvalBubbleReservePx + 6, + [desktopEvalBubbleReservePx], + ) + const desktopMaiaGutterWidthPx = useMemo( + () => desktopMaiaBubbleReservePx + 6, + [desktopMaiaBubbleReservePx], + ) + const desktopColumnTargetHeightCss = '85vh' + const desktopBoardBaselineSizeCss = 'min(42vw, 72vh)' + const desktopBoardWidthCapVw = useMemo(() => { + if (width >= 1536) return 48 + if (width >= 1280) return 46 + return 42 + }, [width]) + const desktopBoardHeightCapPx = useMemo(() => { + if (height <= 0) return null + const targetColumnHeightPx = height * 0.85 + const extraGapPx = 4 + const measuredNonBoardHeightPx = + desktopMiddleMeasuredHeights.boardHeaderStripPx + + desktopMiddleMeasuredHeights.blunderMeterSectionPx + + extraGapPx + return Math.max( + 320, + Math.floor(targetColumnHeightPx - measuredNonBoardHeightPx), + ) + }, [desktopMiddleMeasuredHeights, height]) + const desktopBoardSizeCss = useMemo(() => { + const heightCapCss = + desktopBoardHeightCapPx !== null ? `${desktopBoardHeightCapPx}px` : '72vh' + const expandedTargetCss = `min(${desktopBoardWidthCapVw}vw, ${heightCapCss})` + return `max(${desktopBoardBaselineSizeCss}, ${expandedTargetCss})` + }, [ + desktopBoardBaselineSizeCss, + desktopBoardHeightCapPx, + desktopBoardWidthCapVw, + ]) + const desktopBoardMinSizeCss = useMemo( + () => `calc(max(24rem, ${desktopBoardSizeCss}))`, + [desktopBoardSizeCss], + ) + const desktopConfigPanelHeightCss = '9.5rem' + const desktopSidebarContentHeightCss = `calc(${desktopColumnTargetHeightCss} - ${desktopConfigPanelHeightCss} - 0.75rem)` + const desktopBarChromeSize: 'compact' | 'expanded' = + width >= 1360 ? 'expanded' : 'compact' + const desktopCompactBlunderMaiaHeaderLabel = useMemo(() => { + const ratingLevel = + analysisController.currentMaiaModel?.replace('maia_kdd_', '') || '----' + return `Maia %\n@ ${ratingLevel}` + }, [analysisController.currentMaiaModel]) + + const mockHoverForChrome = useCallback(() => void 0, []) + const mockMakeMoveForChrome = useCallback(() => void 0, []) + const destinationBadges = useMemo(() => { + if ( + !analysisEnabled || + !analysisController.showTopMoveBadges || + !analysisController.topHumanMoveBadge + ) { + return [] + } + + return [ + { + square: analysisController.topHumanMoveBadge.square, + classification: analysisController.topHumanMoveBadge.classification, + }, + ] + }, [ + analysisController.showTopMoveBadges, + analysisController.topHumanMoveBadge, + analysisEnabled, + ]) + const analysisArrowBrushes = useMemo( + () => ({ + playedMoveOutline: { + key: 'playedMoveOutline', + color: '#4A8FB3', + opacity: 0.95, + lineWidth: 11, + }, + playedMoveCore: { + key: 'playedMoveCore', + color: '#FFFFFF', + opacity: 0.98, + lineWidth: 8, + }, + }), + [], + ) + const playedMoveShapes = useMemo(() => { + const playedMove = controller.currentNode?.mainChild?.move + if (!playedMove || playedMove.length < 4) { + return [] + } + + return [ + { + brush: 'playedMoveOutline', + orig: playedMove.slice(0, 2) as Key, + dest: playedMove.slice(2, 4) as Key, + }, + { + brush: 'playedMoveCore', + orig: playedMove.slice(0, 2) as Key, + dest: playedMove.slice(2, 4) as Key, + }, + ] as DrawShape[] + }, [controller.currentNode?.mainChild?.move]) + const staggerOverlappingArrows = useCallback((shapes: DrawShape[]) => { + const overlapGroups = new Map() + + shapes.forEach((shape, index) => { + const arrow = shape as DrawShape & { orig?: string; dest?: string } + if (!arrow.orig || !arrow.dest) return + + const key = `${arrow.orig}-${arrow.dest}` + const indices = overlapGroups.get(key) ?? [] + indices.push(index) + overlapGroups.set(key, indices) + }) + + const layeredShapes = shapes.map((shape) => { + const arrow = shape as DrawShape & { + modifiers?: { [key: string]: unknown; lineWidth?: number } + } + + return { + ...arrow, + modifiers: arrow.modifiers ? { ...arrow.modifiers } : undefined, + } as DrawShape + }) + + overlapGroups.forEach((indices) => { + if (indices.length < 2) return + + indices.forEach((shapeIndex, order) => { + const shape = layeredShapes[shapeIndex] as DrawShape & { + modifiers?: { [key: string]: unknown; lineWidth?: number } + } + + const baseWidth = + typeof shape.modifiers?.lineWidth === 'number' + ? shape.modifiers.lineWidth + : 8 + + const expandedWidth = baseWidth + (indices.length - order - 1) * 1.25 + const adjustedWidth = Math.min(13.5, Math.max(2.5, expandedWidth)) + + shape.modifiers = { + ...(shape.modifiers || {}), + lineWidth: adjustedWidth, + } + }) + }) + + return layeredShapes + }, []) + // Create game object for MovesContainer const gameForContainer = useMemo(() => { if (!controller.gameTree) return null @@ -288,38 +804,6 @@ const OpeningsPage: NextPage = () => { controller.availableMoves, ]) - // Player info for the board - optimized to use already computed playerNames - const topPlayer = useMemo(() => { - if (!controller.currentDrill || !playerNames) - return { name: 'Unknown', color: 'black' } - - const playerColor = controller.currentDrill.playerColor - const topPlayerColor = playerColor === 'white' ? 'black' : 'white' - - return { - name: - topPlayerColor === 'black' - ? playerNames.blackPlayer.name - : playerNames.whitePlayer.name, - color: topPlayerColor, - } - }, [controller.currentDrill?.playerColor, playerNames]) - - const bottomPlayer = useMemo(() => { - if (!controller.currentDrill || !playerNames) - return { name: 'Unknown', color: 'white' } - - const playerColor = controller.currentDrill.playerColor - - return { - name: - playerColor === 'black' - ? playerNames.blackPlayer.name - : playerNames.whitePlayer.name, - color: playerColor, - } - }, [controller.currentDrill?.playerColor, playerNames]) - // Make move function for analysis components const makeMove = useCallback( async (move: string) => { @@ -452,6 +936,121 @@ const OpeningsPage: NextPage = () => { // No special handling needed for opening drills }, []) + const handleToggleAnalysis = useCallback(() => { + controller.setAnalysisEnabled(!controller.analysisEnabled) + }, [controller]) + const launchContinue = useCallback(() => { + const fen = controller.currentNode?.fen + if (!fen) return + + window.open('/play?fen=' + encodeURIComponent(fen)) + }, [controller.currentNode?.fen]) + const handleAnalyzeEntireGame = useCallback(() => { + analysisController.gameAnalysis.resetProgress() + analysisController.gameAnalysis.startAnalysis(18) + }, [analysisController.gameAnalysis]) + + const currentPlayer = useMemo(() => { + if (!controller.currentNode) return 'white' + const chess = new Chess(controller.currentNode.fen) + return chess.turn() === 'w' ? 'white' : 'black' + }, [controller.currentNode]) + const isPostDrillAnalysisView = controller.continueAnalyzingMode + + const renderDrillProgress = (className?: string) => + controller.currentDrillGame && controller.currentDrill ? ( +
+
+
+ Move Progress + + {controller.currentDrillGame.playerMoveCount}/{targetMovesLabel} + +
+
+
+
+ {controller.drillEndReasonMessage && ( +

+ {controller.drillEndReasonMessage} +

+ )} +
+
+ ) : null + + const renderDrillActionButtons = (fullWidth = false) => ( +
+ {controller.currentPerformanceData && + !controller.showPerformanceModal && ( + + )} + {!controller.currentPerformanceData && + !controller.showPerformanceModal && + !controller.continueAnalyzingMode && ( + + )} + +
+ ) + + const renderLiveDrillSummary = () => + controller.currentDrill ? ( +
+

+ Current Drill +

+

+ {controller.currentDrill.opening.name} + {controller.currentDrill.variation + ? `, ${controller.currentDrill.variation.name}` + : ''} +

+

+ Drill {controller.currentDrillNumber || 1} ยท vs Maia{' '} + {controller.currentDrill.maiaVersion.replace('maia_kdd_', '')} +

+

+ {controller.currentPerformanceData + ? 'Drill Complete' + : controller.isPlayerTurn + ? 'Your Turn' + : 'Waiting for Maia'} +

+ {controller.drillEndReasonMessage && ( +

+ {controller.drillEndReasonMessage} +

+ )} +
+ ) : null + // // Don't render if user is not authenticated // if (user !== null && !user.lichessId) { // return null @@ -503,158 +1102,453 @@ const OpeningsPage: NextPage = () => { ) } - const desktopLayout = () => ( -
-
- {/* Left Sidebar - unified glass container */} -
+ const desktopPlayLayout = () => ( +
+
+
- {/* Opening drill info and drill list */}
- - {/* Center - Board */} -
-
- + + {promotionFromTo && ( + -
- - {promotionFromTo && ( - +
+
+
+ {controller.currentDrillGame && ( + )}
- +
+ +
+
+ {renderLiveDrillSummary()} + {renderDrillProgress()} + {renderDrillActionButtons()} +
+
+
+
+ ) - {/* Drill progress with next drill button */} - {controller.currentDrillGame && controller.currentDrill && ( -
-
-
- Move Progress - - {controller.currentDrillGame.playerMoveCount}/ - {targetMovesLabel} - -
-
-
( +
+
+
{renderLiveDrillSummary()}
+
+ + {promotionFromTo && ( + + )} +
+
+
+
+
+ {controller.currentDrillGame && ( + -
- {controller.drillEndReasonMessage && ( -

- {controller.drillEndReasonMessage} -

)}
- {controller.currentPerformanceData && - !controller.showPerformanceModal && ( - - )} - {!controller.currentPerformanceData && - !controller.showPerformanceModal && - !controller.continueAnalyzingMode && ( - - )} - +
+ +
+
+ {renderDrillProgress()} + {renderDrillActionButtons(true)} +
- )} +
+
+
+ ) - {/* Right Panel - Analysis */} + const desktopAnalysisLayout = () => ( +
+
+ {/* Left Panel - Drill Sidebar + Moves */}
- {analyzedGame && ( - + - controller.setAnalysisEnabled(!controller.analysisEnabled) - } - playerColor={controller.currentDrill?.playerColor || 'white'} - maiaVersion={ - controller.currentDrill?.maiaVersion || 'maia_kdd_1500' - } - analysisController={analysisController} - hover={hover} - setHoverArrow={setHoverArrow} - makeMove={makeMove} + continueAnalyzingMode={controller.continueAnalyzingMode} + embedded /> - )} +
+
+ {controller.currentDrillGame && ( +
+
+ +
+
+ )} + +
+ + {/* Center - Board with eval bars */} +
+
+
+
+
+
+ + White Win % + +
+
+
+ +
+ +
+ +
+
+
+ + SF Eval + +
+
+
+
+ +
+
+ { + const baseShapes = [...playedMoveShapes] + if (analysisEnabled) { + baseShapes.push(...analysisController.arrows) + } + if (hoverArrow) { + baseShapes.push(hoverArrow) + } + return staggerOverlappingArrows(baseShapes) + })()} + destinationBadges={destinationBadges} + brushes={analysisArrowBrushes as unknown as DrawBrushes} + /> + {promotionFromTo && ( + + )} +
+
+ +
+
+
+
+
+ +
+
+ + {renderDrillActionButtons()} +
+ + {/* Right Panel - Analysis Sidebar (same as analysis page) */} + + { + launchContinue() + }} + MAIA_MODELS={MAIA_MODELS} + game={analyzedGame} + currentNode={ + (controller.currentNode || + analysisController.currentNode) as GameNode + } + onAnalyzeEntireGame={handleAnalyzeEntireGame} + isAnalysisInProgress={ + analysisController.gameAnalysis.progress.isAnalyzing + } + autoSave={analysisController.gameAnalysis.autoSave} + /> +
+ ) : undefined + } + />
) - const mobileLayout = () => ( + const mobileAnalysisLayout = () => (
{/* Current Drill Info Header */}
-

Current Drill

+
+

Current Drill

+ +
{controller.currentDrill ? (
@@ -684,44 +1578,109 @@ const OpeningsPage: NextPage = () => {
{/* Board Section */} -
- -
- - {promotionFromTo && ( - +
+
+ + Maia % + +
+
+
+ +
+ - )} +
+ +
+
+
+ + SF Eval + +
- +
+ +
+
+ { + const baseShapes = [...playedMoveShapes] + + if (analysisEnabled) { + baseShapes.push(...analysisController.arrows) + } + + if (hoverArrow) { + baseShapes.push(hoverArrow) + } + + return staggerOverlappingArrows(baseShapes) + })()} + destinationBadges={destinationBadges} + brushes={analysisArrowBrushes as unknown as DrawBrushes} + /> + {promotionFromTo && ( + + )} +
+
+ +
+
+
@@ -747,7 +1706,13 @@ const OpeningsPage: NextPage = () => { {/* Moves Container */} {controller.currentDrillGame && ( -
+
{ controller.analysisEnabled || controller.continueAnalyzingMode } showVariations={controller.continueAnalyzingMode} + forceMobileLayout={useMobileStyleAnalysisLayout} />
)} - {/* Drill Progress */} - {controller.currentDrillGame && controller.currentDrill && ( -
-
-
- Move Progress - - {controller.currentDrillGame.playerMoveCount}/ - {targetMovesLabel} - -
-
-
+ {/* Action Buttons */} + {renderDrillActionButtons(true)} + + {/* Analysis Components Stacked */} +
+
+ + {!analysisEnabled && ( +
+
+ + lock + +

+ Analysis Disabled +

+
- {controller.drillEndReasonMessage && ( -

- {controller.drillEndReasonMessage} -

- )} -
+ )}
- )} - {/* Action Buttons */} -
- {controller.currentPerformanceData && - !controller.showPerformanceModal && ( - - )} - {!controller.currentPerformanceData && - !controller.showPerformanceModal && - !controller.continueAnalyzingMode && ( - +
+ + {!analysisEnabled && ( +
+
+ + lock + +

+ Analysis Disabled +

+
+
)} - -
+
- {/* Analysis Components Stacked */} -
{analyzedGame && ( - - controller.setAnalysisEnabled(!controller.analysisEnabled) + )}
@@ -878,7 +1890,13 @@ const OpeningsPage: NextPage = () => { plyCount: controller.plyCount, }} > - {isMobile ? mobileLayout() : desktopLayout()} + {isPostDrillAnalysisView + ? useMobileStyleAnalysisLayout + ? mobileAnalysisLayout() + : desktopAnalysisLayout() + : useMobileStyleAnalysisLayout + ? mobilePlayLayout() + : desktopPlayLayout()} {/* Performance Modal */}