diff --git a/src/components/Analysis/AnalysisSidebar.tsx b/src/components/Analysis/AnalysisSidebar.tsx index 2198a89b..bd80b57a 100644 --- a/src/components/Analysis/AnalysisSidebar.tsx +++ b/src/components/Analysis/AnalysisSidebar.tsx @@ -9,7 +9,7 @@ import { import { motion } from 'framer-motion' import type { DrawShape } from 'chessground/draw' import { Dispatch, SetStateAction, useCallback, useMemo } from 'react' -import type { ComponentProps } from 'react' +import type { ComponentProps, CSSProperties, ReactNode } from 'react' import { useLocalStorage } from 'src/hooks' import { useAnalysisController } from 'src/hooks/useAnalysisController' import type { MaiaEvaluation, StockfishEvaluation } from 'src/types' @@ -26,6 +26,10 @@ interface Props { analysisEnabled: boolean controller: ReturnType handleToggleAnalysis: () => void + hideDetailedBlunderMeter?: boolean + containerStyle?: CSSProperties + footerContent?: ReactNode + desktopContentHeightCss?: string itemVariants?: { hidden: { opacity: number @@ -59,6 +63,10 @@ export const AnalysisSidebar: React.FC = ({ setHoverArrow, analysisEnabled, handleToggleAnalysis, + hideDetailedBlunderMeter = false, + containerStyle, + footerContent, + desktopContentHeightCss = '85vh', itemVariants, }) => { const emptyBlunderMeterData = useMemo( @@ -125,6 +133,8 @@ export const AnalysisSidebar: React.FC = ({ boardDescription: highlightBoardDescription, currentNode: controller.currentNode ?? undefined, simplified: isSimplifiedView, + hideStockfishEvalSummary: hideDetailedBlunderMeter, + hideWhiteWinRateSummary: hideDetailedBlunderMeter, } const simplifiedBlunderMeterProps: ComponentProps< @@ -263,6 +273,7 @@ export const AnalysisSidebar: React.FC = ({ highlightProps={highlightProps} blunderMeterProps={simplifiedBlunderMeterProps} analysisEnabled={analysisEnabled} + hideBlunderMeter={hideDetailedBlunderMeter} /> {!analysisEnabled && @@ -279,6 +290,7 @@ export const AnalysisSidebar: React.FC = ({ highlightProps={highlightProps} blunderMeterProps={simplifiedBlunderMeterProps} analysisEnabled={analysisEnabled} + hideBlunderMeter={hideDetailedBlunderMeter} /> {!analysisEnabled && @@ -293,7 +305,10 @@ export const AnalysisSidebar: React.FC = ({ const detailedLayout = ( <>
-
+
{renderHeader('desktop')}
@@ -311,11 +326,14 @@ export const AnalysisSidebar: React.FC = ({ })}
-
+
- + {!hideDetailedBlunderMeter && } {!analysisEnabled && renderDisabledOverlay('Enable analysis to see position evaluation')}
@@ -327,11 +345,13 @@ export const AnalysisSidebar: React.FC = ({
-
-
- + {!hideDetailedBlunderMeter && ( +
+
+ +
-
+ )} {!analysisEnabled && renderDisabledOverlay('Enable analysis to see move evaluations', { offsetTop: true, @@ -362,9 +382,12 @@ export const AnalysisSidebar: React.FC = ({ id="analysis" variants={itemVariants ?? {}} className="desktop-right-column-container flex flex-col gap-3" - style={{ willChange: 'transform, opacity' }} + style={{ willChange: 'transform, opacity', ...containerStyle }} > - {isSimplifiedView ? simplifiedLayout : detailedLayout} +
+ {isSimplifiedView ? simplifiedLayout : detailedLayout} +
+ {footerContent ?
{footerContent}
: null} ) } diff --git a/src/components/Analysis/BoardChrome.tsx b/src/components/Analysis/BoardChrome.tsx new file mode 100644 index 00000000..72be0cc6 --- /dev/null +++ b/src/components/Analysis/BoardChrome.tsx @@ -0,0 +1,550 @@ +import { motion, type MotionValue } from 'framer-motion' +import type { ColorSanMapping, BlunderMeterResult } from 'src/types' + +type ArrowLegendLayout = 'horizontal' | 'vertical' +type ArrowLegendLabelMode = 'full' | 'short' +type CompactBlunderMeterVariant = 'mobile' | 'desktop' +type StockfishEvalBarVariant = 'mobile' | 'desktop' + +interface AnalysisArrowLegendProps { + layout?: ArrowLegendLayout + labelMode?: ArrowLegendLabelMode + className?: string +} + +interface AnalysisStockfishEvalBarProps { + hasEval: boolean + displayText: string + labelPositionTop: MotionValue + variant?: StockfishEvalBarVariant + className?: string + range?: number + bubbleMinWidthPx?: number + desktopSize?: 'compact' | 'expanded' +} + +interface AnalysisCompactBlunderMeterProps { + data: BlunderMeterResult + colorSanMapping?: ColorSanMapping + playedMove?: string + maiaHeaderLabel?: string + hover: (move?: string) => void + makeMove: (move: string) => void + className?: string + variant?: CompactBlunderMeterVariant +} + +interface AnalysisMaiaWinrateBarProps { + hasValue: boolean + displayText: string + labelPositionTop: MotionValue + className?: string + bubbleMinWidthPx?: number + desktopSize?: 'compact' | 'expanded' +} + +type ArrowLegendItem = { + key: 'maia' | 'stockfish' | 'played' + shortLabel: string + fullLabel: string + color: string + accentLine?: boolean + thicknessClass: string +} + +const ARROW_LEGEND_ITEMS: ArrowLegendItem[] = [ + { + key: 'maia', + shortLabel: 'Maia', + fullLabel: 'Most Human Move', + color: '#882020', + thicknessClass: 'h-[3px]', + }, + { + key: 'stockfish', + shortLabel: 'SF', + fullLabel: 'Best Engine Move', + color: '#003088', + thicknessClass: 'h-[3px]', + }, + { + key: 'played', + shortLabel: 'Played', + fullLabel: 'Move Played', + color: '#4A8FB3', + accentLine: true, + thicknessClass: 'h-[4.5px]', + }, +] +const renderArrowSwatch = (item: ArrowLegendItem, swatchWidthClass: string) => ( + + + {item.accentLine ? ( + + ) : null} + + +) + +export const AnalysisArrowLegend: React.FC = ({ + layout = 'horizontal', + labelMode = 'full', + className, +}) => { + const isVertical = layout === 'vertical' + const containerClass = isVertical + ? 'flex flex-col items-center gap-2.5' + : 'flex flex-wrap items-center gap-x-2.5 gap-y-0.5' + const textClass = isVertical + ? 'text-[8px] leading-none text-secondary/90' + : 'text-xxs leading-none text-secondary/90' + const swatchWidthClass = isVertical ? 'w-5' : 'w-4' + + return ( +
+ {ARROW_LEGEND_ITEMS.map((item) => ( + + {renderArrowSwatch(item, swatchWidthClass)} + + {labelMode === 'short' ? item.shortLabel : item.fullLabel} + + + ))} +
+ ) +} + +export const AnalysisStockfishEvalBar: React.FC< + AnalysisStockfishEvalBarProps +> = ({ + hasEval, + displayText, + labelPositionTop, + variant = 'mobile', + className, + range = 4, + bubbleMinWidthPx, + desktopSize = 'compact', +}) => { + const isDesktop = variant === 'desktop' + const isExpandedDesktop = isDesktop && desktopSize === 'expanded' + const widthClass = isDesktop + ? isExpandedDesktop + ? 'w-[18px]' + : 'w-[16px]' + : 'w-4' + const tickTextClass = isDesktop + ? isExpandedDesktop + ? 'text-[9px]' + : 'text-[8px]' + : 'text-[7px]' + const bubbleClass = isDesktop + ? isExpandedDesktop + ? 'h-6 min-w-[42px] rounded-full px-2 text-[11px]' + : 'h-5 min-w-[36px] rounded-full px-1.5 text-[10px]' + : 'h-4 min-w-[30px] rounded-full px-1 text-[8px]' + + return ( +
+
+
+
+ {Array.from( + { length: range * 2 - 1 }, + (_, index) => range - 1 - index, + ).map((value) => ( +
+ ))} +
+ +{range} +
+
+ -{range} +
+
+ + {displayText} + +
+
+ ) +} + +export const AnalysisMaiaWinrateBar: React.FC = ({ + hasValue, + displayText, + labelPositionTop, + className, + bubbleMinWidthPx, + desktopSize = 'compact', +}) => { + const isExpandedDesktop = desktopSize === 'expanded' + return ( +
+
+
+
+ {Array.from({ length: 9 }, (_, index) => 90 - index * 10).map( + (value) => ( +
+ ), + )} +
+ 100 +
+
+ 0 +
+
+ + {displayText} + +
+
+ ) +} + +type SegmentConfig = { + key: 'blunder' | 'ok' | 'good' + label: string + probability: number + topMoves: { move: string; probability: number; label: string }[] + badge: string + bgClass: string + badgeClass: string + pctClass: string + moveClass: string +} + +export const AnalysisCompactBlunderMeter: React.FC< + AnalysisCompactBlunderMeterProps +> = ({ + data, + colorSanMapping, + playedMove, + maiaHeaderLabel = 'Maia %', + hover, + makeMove, + className, + variant = 'mobile', +}) => { + const getTopCategoryMoves = ( + moves: { move: string; probability: number }[], + ) => { + const topMoves = moves + .filter((entry) => entry.probability >= 5) + .sort((a, b) => b.probability - a.probability) + .slice(0, 2) + .map((entry) => ({ + move: entry.move, + probability: Math.round(entry.probability), + label: colorSanMapping?.[entry.move]?.san || entry.move, + })) + + if (!playedMove || topMoves.some((entry) => entry.move === playedMove)) { + return topMoves + } + + const playedMoveEntry = moves.find((entry) => entry.move === playedMove) + if (!playedMoveEntry) { + return topMoves + } + + return [ + ...topMoves, + { + move: playedMoveEntry.move, + probability: Math.round(playedMoveEntry.probability), + label: + colorSanMapping?.[playedMoveEntry.move]?.san || playedMoveEntry.move, + }, + ] + } + + const segments: SegmentConfig[] = [ + { + key: 'blunder', + label: 'Blunders', + probability: data.blunderMoves.probability, + topMoves: getTopCategoryMoves(data.blunderMoves.moves), + badge: '??', + bgClass: 'bg-[#d73027]', + badgeClass: 'border-[#7f1813] bg-white/95 text-[#d73027]', + pctClass: 'text-white/95', + moveClass: 'text-[#d73027]', + }, + { + key: 'ok', + label: 'Mistakes', + probability: data.okMoves.probability, + topMoves: getTopCategoryMoves(data.okMoves.moves), + badge: '?', + bgClass: 'bg-[#fee08b]', + badgeClass: 'border-[#8f6b00] bg-white/95 text-[#8f6b00]', + pctClass: 'text-black/80', + moveClass: 'text-[#fee08b]', + }, + { + key: 'good', + label: 'Best Moves', + probability: data.goodMoves.probability, + topMoves: getTopCategoryMoves(data.goodMoves.moves), + badge: '✓', + bgClass: 'bg-[#1a9850]', + badgeClass: 'border-[#0e5a2f] bg-white/95 text-[#1a9850]', + pctClass: 'text-white/95', + moveClass: 'text-[#1a9850]', + }, + ] + + const isDesktop = variant === 'desktop' + const barHeightClass = isDesktop ? 'h-6' : 'h-[22px]' + const barLabelWidthClass = isDesktop ? 'w-[44px]' : '' + const badgeSizeClass = isDesktop + ? 'h-5 min-w-5 text-[10px]' + : 'h-3.5 min-w-3.5 text-[8px]' + const percentTextClass = isDesktop ? 'text-[11px]' : 'text-[9px]' + const metaTextClass = isDesktop ? 'text-xs py-1.5' : 'text-[9px] py-0.5' + const playedMoveOutlineOuterInsetClass = isDesktop + ? '-inset-x-[11px] -inset-y-[5px]' + : '-inset-x-[7px] -inset-y-[4px]' + const playedMoveOutlineInnerInsetClass = isDesktop + ? '-inset-x-[8px] -inset-y-[3px]' + : '-inset-x-[5px] -inset-y-[3px]' + const renderTopMoveButton = ( + segmentKey: string, + topMove: { move: string; probability: number; label: string }, + ) => { + const isPlayedMove = playedMove === topMove.move + + return ( + + ) + } + + return ( +
+
+ + {maiaHeaderLabel} + +
+ {segments.map((segment) => ( + + {segment.probability >= 7 ? ( + <> + + {segment.badge} + + + {segment.probability}% + + + ) : null} + + ))} +
+
+
+ {isDesktop ? ( +
+ + ) : ( +
+ {segments.map((segment) => ( +
+ {segment.label}:{' '} + {segment.topMoves.length + ? segment.topMoves.map((topMove, index) => ( + + {index > 0 ? , : null} + {renderTopMoveButton(segment.key, topMove)} + + )) + : '-'} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/components/Analysis/ConfigurableScreens.tsx b/src/components/Analysis/ConfigurableScreens.tsx index e5c41874..08003461 100644 --- a/src/components/Analysis/ConfigurableScreens.tsx +++ b/src/components/Analysis/ConfigurableScreens.tsx @@ -69,7 +69,7 @@ export const ConfigurableScreens: React.FC = ({ const screens = [ { id: 'configure', - name: 'Configure', + name: 'Options', }, { id: 'export', diff --git a/src/components/Analysis/ConfigureAnalysis.tsx b/src/components/Analysis/ConfigureAnalysis.tsx index 6f6d57e6..01bc49ae 100644 --- a/src/components/Analysis/ConfigureAnalysis.tsx +++ b/src/components/Analysis/ConfigureAnalysis.tsx @@ -63,28 +63,6 @@ export const ConfigureAnalysis: React.FC = ({
-
-
- Show board badges - - Show ? / ?? marker on top human move destination - -
- -
{onAnalyzeEntireGame && (
)} +
+
+ Show board badges + + Show ? / ?? marker on top human move destination + +
+ +
{autoSave && game.type !== 'tournament' && (
diff --git a/src/components/Analysis/Highlight.tsx b/src/components/Analysis/Highlight.tsx index 4a6d68ed..98dbafb9 100644 --- a/src/components/Analysis/Highlight.tsx +++ b/src/components/Analysis/Highlight.tsx @@ -41,6 +41,8 @@ interface Props { currentNode?: GameNode isHomePage?: boolean simplified?: boolean + hideStockfishEvalSummary?: boolean + hideWhiteWinRateSummary?: boolean } export const Highlight: React.FC = ({ @@ -55,6 +57,8 @@ export const Highlight: React.FC = ({ currentNode, isHomePage = false, simplified = false, + hideStockfishEvalSummary = false, + hideWhiteWinRateSummary = false, }: Props) => { const { isMobile } = useContext(WindowSizeContext) @@ -389,14 +393,16 @@ export const Highlight: React.FC = ({ )}
-
-

- White Win % -

-

- {getWhiteWinRate()} -

-
+ {!hideWhiteWinRateSummary && ( +
+

+ White Win % +

+

+ {getWhiteWinRate()} +

+
+ )}
= ({

-
-

- SF Eval{' '} - {moveEvaluation?.stockfish?.depth - ? ` (d${moveEvaluation.stockfish?.depth})` - : ''} -

-

- {isCurrentPositionCheckmate - ? 'Checkmate' - : getStockfishEvalDisplay()} -

-
+ {!hideStockfishEvalSummary && ( +
+

+ SF Eval{' '} + {moveEvaluation?.stockfish?.depth + ? ` (d${moveEvaluation.stockfish?.depth})` + : ''} +

+

+ {isCurrentPositionCheckmate + ? 'Checkmate' + : getStockfishEvalDisplay()} +

+
+ )}
blunderMeterProps: React.ComponentProps analysisEnabled: boolean + hideBlunderMeter?: boolean } export const SimplifiedAnalysisOverview: React.FC< SimplifiedAnalysisOverviewProps -> = ({ highlightProps, blunderMeterProps, analysisEnabled }) => { +> = ({ + highlightProps, + blunderMeterProps, + analysisEnabled, + hideBlunderMeter = false, +}) => { + if (hideBlunderMeter) { + return ( +
+ +
+ ) + } + return (
= ({
{showArrowLegend && ( -
-
- - arrow_outward +
+
+ + + + + Maia +
+
+ + + - Most Human Move + SF
-
- - arrow_outward +
+ + + + - Best Engine Move + Played
)} diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index e191edbc..a452e256 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -3,8 +3,10 @@ import React, { Dispatch, useState, useEffect, + useLayoutEffect, useContext, useCallback, + useRef, SetStateAction, } from 'react' import { @@ -24,10 +26,15 @@ import { import { WindowSizeContext, TreeControllerContext, useTour } from 'src/contexts' import { Loading } from 'src/components' import { AuthenticatedWrapper } from 'src/components/Common/AuthenticatedWrapper' -import { PlayerInfo } from 'src/components/Common/PlayerInfo' import { MoveMap } from 'src/components/Analysis/MoveMap' import { Highlight } from 'src/components/Analysis/Highlight' import { AnalysisSidebar } from 'src/components/Analysis' +import { + AnalysisArrowLegend, + AnalysisCompactBlunderMeter, + AnalysisMaiaWinrateBar, + AnalysisStockfishEvalBar, +} from 'src/components/Analysis/BoardChrome' import { MovesByRating } from 'src/components/Analysis/MovesByRating' import { AnalysisGameList } from 'src/components/Analysis/AnalysisGameList' import { DownloadModelModal } from 'src/components/Common/DownloadModelModal' @@ -55,6 +62,7 @@ import { tourConfigs } from 'src/constants/tours' import type { DrawBrushes, DrawShape } from 'chessground/draw' import { MAIA_MODELS } from 'src/constants/common' import { applyEngineAnalysisData } from 'src/lib/analysis' +import { cpToWinrate } from 'src/lib' const EVAL_BAR_RANGE = 4 const CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY = @@ -65,6 +73,8 @@ const DEFAULT_STOCKFISH_EVAL_BAR = { displayPawns: 0, label: '--', } +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect const PGN_HEADER_LINE_REGEX = /^\s*\[[^\]]+\]\s*$/ @@ -409,8 +419,50 @@ const Analysis: React.FC = ({ router, }: Props) => { - const { width } = useContext(WindowSizeContext) + const { width, height } = useContext(WindowSizeContext) const isMobile = useMemo(() => width > 0 && width <= 670, [width]) + const desktopBoardHeaderStripRef = useRef(null) + const desktopBlunderMeterSectionRef = useRef(null) + const [desktopMiddleMeasuredHeights, setDesktopMiddleMeasuredHeights] = + useState({ + boardHeaderStripPx: 28, + blunderMeterSectionPx: 126, + }) + + useIsomorphicLayoutEffect(() => { + if (isMobile) 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() + // Intentionally avoid observing live content changes here; the blunder-meter + // rows can vary slightly by move, which causes distracting board-size jitter + // during arrow-key paging. Re-measure on width changes only. + }, [isMobile, width]) const [hoverArrow, setHoverArrow] = useState(null) const [currentSquare, setCurrentSquare] = useState(null) const [promotionFromTo, setPromotionFromTo] = useState< @@ -571,7 +623,7 @@ const Analysis: React.FC = ({ [], ) - const mobileBlunderMeterData = useMemo( + const compactBlunderMeterData = useMemo( () => analysisEnabled && !controller.learnFromMistakes.state.isActive ? controller.blunderMeter @@ -584,68 +636,6 @@ const Analysis: React.FC = ({ ], ) - const getTopCategoryMoves = useCallback( - (moves: { move: string; probability: number }[]) => - moves - .filter((entry) => entry.probability >= 5) - .sort((a, b) => b.probability - a.probability) - .slice(0, 2) - .map((entry) => ({ - move: entry.move, - probability: Math.round(entry.probability), - label: controller.colorSanMapping[entry.move]?.san || entry.move, - })), - [controller.colorSanMapping], - ) - - const mobileBlunderSegments = useMemo(() => { - return [ - { - key: 'blunder', - label: 'Blunder', - probability: mobileBlunderMeterData.blunderMoves.probability, - topMoves: getTopCategoryMoves( - mobileBlunderMeterData.blunderMoves.moves, - ), - badge: '??', - bgClass: 'bg-[#d73027]', - badgeClass: 'border-[#7f1813] bg-white/95 text-[#d73027]', - pctClass: 'text-white/95', - moveClass: 'text-[#d73027]', - }, - { - key: 'ok', - label: 'OK', - probability: mobileBlunderMeterData.okMoves.probability, - topMoves: getTopCategoryMoves(mobileBlunderMeterData.okMoves.moves), - badge: '?', - bgClass: 'bg-[#fee08b]', - badgeClass: 'border-[#8f6b00] bg-white/95 text-[#8f6b00]', - pctClass: 'text-black/80', - moveClass: 'text-[#fee08b]', - }, - { - key: 'good', - label: 'Good', - probability: mobileBlunderMeterData.goodMoves.probability, - topMoves: getTopCategoryMoves(mobileBlunderMeterData.goodMoves.moves), - badge: '✓', - bgClass: 'bg-[#1a9850]', - badgeClass: 'border-[#0e5a2f] bg-white/95 text-[#1a9850]', - pctClass: 'text-white/95', - moveClass: 'text-[#1a9850]', - }, - ] - }, [ - mobileBlunderMeterData.blunderMoves.moves, - mobileBlunderMeterData.blunderMoves.probability, - mobileBlunderMeterData.okMoves.moves, - mobileBlunderMeterData.okMoves.probability, - mobileBlunderMeterData.goodMoves.moves, - mobileBlunderMeterData.goodMoves.probability, - getTopCategoryMoves, - ]) - const hover = (move?: string) => { if (move && analysisEnabled) { setHoverArrow({ @@ -871,6 +861,7 @@ const Analysis: React.FC = ({ const rawStockfishEvalBar = useMemo(() => { const stockfish = controller.moveEvaluation?.stockfish + const sideToMove = controller.currentNode?.turn || 'w' if (!stockfish) { return { @@ -881,10 +872,14 @@ const Analysis: React.FC = ({ const mateIn = stockfish.mate_vec?.[stockfish.model_move] if (mateIn !== undefined) { + // Stockfish mate sign is relative to side-to-move; map to white/black perspective for the bar. + const matingColor = + mateIn > 0 ? sideToMove : sideToMove === 'w' ? 'b' : 'w' + const whitePerspectiveSign = matingColor === 'w' ? 1 : -1 return { hasEval: true, - pawns: Math.sign(mateIn) * EVAL_BAR_RANGE, - displayPawns: Math.sign(mateIn) * EVAL_BAR_RANGE, + pawns: whitePerspectiveSign * EVAL_BAR_RANGE, + displayPawns: whitePerspectiveSign * EVAL_BAR_RANGE, label: `M${Math.abs(mateIn)}`, depth: stockfish.depth ?? 0, } @@ -905,13 +900,13 @@ const Analysis: React.FC = ({ label: `${rawPawns > 0 ? '+' : ''}${rawPawns.toFixed(2)}`, depth: stockfish.depth ?? 0, } - }, [controller.moveEvaluation?.stockfish]) + }, [controller.currentNode?.turn, controller.moveEvaluation?.stockfish]) const [displayedStockfishEvalBar, setDisplayedStockfishEvalBar] = useState( DEFAULT_STOCKFISH_EVAL_BAR, ) - useEffect(() => { + useIsomorphicLayoutEffect(() => { if (!rawStockfishEvalBar.hasEval || rawStockfishEvalBar.depth <= 10) { return } @@ -949,6 +944,250 @@ const Analysis: React.FC = ({ displayedStockfishEvalBar.label, ]) + const currentTurnForBars: 'w' | 'b' = controller.currentNode?.turn || 'w' + + const isCurrentPositionCheckmateForBars = useMemo(() => { + if (!controller.currentNode) return false + try { + const chess = new Chess(controller.currentNode.fen) + return chess.inCheckmate() + } catch { + return false + } + }, [controller.currentNode]) + + const isInFirst10PlyForBars = useMemo(() => { + if (!controller.currentNode) return false + const moveNumber = controller.currentNode.moveNumber + const turn = controller.currentNode.turn + const plyFromStart = (moveNumber - 1) * 2 + (turn === 'b' ? 1 : 0) + return plyFromStart < 10 + }, [controller.currentNode]) + + const rawMaiaWhiteWinBar = useMemo(() => { + const stockfishEval = controller.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 (controller.moveEvaluation?.maia) { + const percent = Math.max( + 0, + Math.min(100, controller.moveEvaluation.maia.value * 100), + ) + return { + hasValue: true, + percent, + label: `${(Math.round(percent * 10) / 10).toFixed(1)}%`, + } + } + + return { hasValue: false, percent: 50, label: '--' } + }, [ + controller.moveEvaluation?.maia, + controller.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 smoothedMaiaWhiteWinPosition = useSpring(50, { + stiffness: 520, + damping: 42, + mass: 0.25, + }) + const smoothedMaiaWhiteWinVerticalPositionLabel = useTransform( + smoothedMaiaWhiteWinPosition, + (value) => `${100 - value}%`, + ) + + useIsomorphicLayoutEffect(() => { + smoothedMaiaWhiteWinPosition.set(maiaWhiteWinPositionPercent) + }, [maiaWhiteWinPositionPercent, smoothedMaiaWhiteWinPosition]) + + 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 // gap-1 between the top text strip and the board row + 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})` + + // Never shrink below the prior desktop sizing behavior; only grow when extra room exists. + return `max(${desktopBoardBaselineSizeCss}, ${expandedTargetCss})` + }, [ + desktopBoardBaselineSizeCss, + desktopBoardHeightCapPx, + desktopBoardWidthCapVw, + ]) + const desktopBoardMinSizeCss = useMemo( + () => `calc(max(24rem, ${desktopBoardSizeCss}))`, + [desktopBoardSizeCss], + ) + const desktopConfigPanelHeightCss = '12.5rem' + const desktopSidebarContentHeightCss = `calc(${desktopColumnTargetHeightCss} - ${desktopConfigPanelHeightCss} - 0.75rem)` + const desktopBarChromeSize: 'compact' | 'expanded' = + width >= 1360 ? 'expanded' : 'compact' + const desktopCompactBlunderMaiaHeaderLabel = useMemo(() => { + const ratingLevel = + controller.currentMaiaModel?.replace('maia_kdd_', '') || '----' + return `Maia %\n@ ${ratingLevel}` + }, [controller.currentMaiaModel]) + const desktopLeftPanelTabs = [ + { + id: 'moves', + name: 'Moves', + }, + { + id: 'select-game', + name: 'Select Game', + }, + ] as const + const [desktopLeftPanelTab, setDesktopLeftPanelTab] = useState< + (typeof desktopLeftPanelTabs)[number]['id'] + >(desktopLeftPanelTabs[0].id) + + useEffect(() => { + if (isMobile || desktopLeftPanelTab !== 'select-game') return + + const keyboardNavigationDisabled = + controller.gameAnalysis.progress.isAnalyzing || + controller.learnFromMistakes.state.isActive + + if (keyboardNavigationDisabled) return + + const handleDesktopSelectGamePaging = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null + if ( + target && + (target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable) + ) { + return + } + + if (event.key === 'ArrowLeft' && controller.currentNode?.parent) { + event.preventDefault() + event.stopPropagation() + controller.goToPreviousNode() + setDesktopLeftPanelTab('moves') + return + } + + if (event.key === 'ArrowRight' && controller.currentNode?.mainChild) { + event.preventDefault() + event.stopPropagation() + controller.goToNextNode() + setDesktopLeftPanelTab('moves') + } + } + + window.addEventListener('keydown', handleDesktopSelectGamePaging, true) + return () => + window.removeEventListener('keydown', handleDesktopSelectGamePaging, true) + }, [ + controller.currentNode, + controller.gameAnalysis.progress.isAnalyzing, + controller.goToNextNode, + controller.goToPreviousNode, + controller.learnFromMistakes.state.isActive, + desktopLeftPanelTab, + isMobile, + ]) + const smoothedEvalPosition = useSpring(50, { stiffness: 520, damping: 42, @@ -959,7 +1198,7 @@ const Analysis: React.FC = ({ (value) => `${100 - value}%`, ) - useEffect(() => { + useIsomorphicLayoutEffect(() => { smoothedEvalPosition.set(evalPositionPercent) }, [evalPositionPercent, smoothedEvalPosition]) @@ -1068,21 +1307,21 @@ const Analysis: React.FC = ({ const desktopLayout = ( -
+
-
+
{/* Game info header */} = ({ > - {/* Game list */} -
-
-
+
+
+
+ {desktopLeftPanelTabs.map((tab) => { + const selected = tab.id === desktopLeftPanelTab + return ( +
{ + if (e.key === 'Enter') setDesktopLeftPanelTab(tab.id) + }} + onClick={() => setDesktopLeftPanelTab(tab.id)} + className={`relative flex cursor-pointer select-none flex-row px-3 py-1.5 outline-none transition duration-200 focus:outline-none focus-visible:outline-none ${selected ? 'bg-white/5' : 'hover:bg-white hover:bg-opacity-[0.02]'}`} + > +

+ {tab.name} +

+ {selected ? ( + + ) : null} +
+ ) + })} +
+
+
+ + {/* No spacer here to keep controller tight to moves */} + +
+
@@ -1113,168 +1431,154 @@ const Analysis: React.FC = ({ />
- {/* Moves + controller */} -
-
- - {/* No spacer here to keep controller tight to moves */} - -
-
- -
- { - const baseShapes = [...playedMoveShapes] +
+
+
+
+
+ + White Win % + +
+
+ +
+
+ + SF Eval + +
+
+
+
+ +
+
+ { + const baseShapes = [...playedMoveShapes] - // Add analysis arrows only when analysis is enabled - if (analysisEnabled) { - baseShapes.push(...controller.arrows) - } + // Add analysis arrows only when analysis is enabled + if (analysisEnabled) { + baseShapes.push(...controller.arrows) + } - // Add mistake arrow during learn mode when analysis is disabled - if ( - controller.learnFromMistakes.state.isActive && - !analysisEnabled - ) { - const currentInfo = - controller.learnFromMistakes.getCurrentInfo() - if (currentInfo) { - const mistake = currentInfo.mistake - baseShapes.push({ - brush: 'paleGrey', - orig: mistake.playedMove.slice(0, 2) as Key, - dest: mistake.playedMove.slice(2, 4) as Key, - modifiers: { lineWidth: 8 }, - }) - } - } + // Add mistake arrow during learn mode when analysis is disabled + if ( + controller.learnFromMistakes.state.isActive && + !analysisEnabled + ) { + const currentInfo = + controller.learnFromMistakes.getCurrentInfo() + if (currentInfo) { + const mistake = currentInfo.mistake + baseShapes.push({ + brush: 'paleGrey', + orig: mistake.playedMove.slice(0, 2) as Key, + dest: mistake.playedMove.slice(2, 4) as Key, + modifiers: { lineWidth: 8 }, + }) + } + } - // Add hover arrow if present - if (hoverArrow) { - baseShapes.push(hoverArrow) - } + // Add hover arrow if present + if (hoverArrow) { + baseShapes.push(hoverArrow) + } - return staggerOverlappingArrows(baseShapes) - })()} - currentNode={controller.currentNode as GameNode} - orientation={controller.orientation} - onPlayerMakeMove={onPlayerMakeMove} - goToNode={controller.goToNode} - gameTree={analyzedGame.tree} - destinationBadges={destinationBadges} - brushes={analysisArrowBrushes as unknown as DrawBrushes} + return staggerOverlappingArrows(baseShapes) + })()} + currentNode={controller.currentNode as GameNode} + orientation={controller.orientation} + onPlayerMakeMove={onPlayerMakeMove} + goToNode={controller.goToNode} + gameTree={analyzedGame.tree} + destinationBadges={destinationBadges} + brushes={analysisArrowBrushes as unknown as DrawBrushes} + /> + {promotionFromTo ? ( + + ) : null} +
+
+ +
+
+
+
+
+ - {promotionFromTo ? ( - - ) : null}
-
- = ({ setHoverArrow={setHoverArrow} analysisEnabled={analysisEnabled} handleToggleAnalysis={handleToggleAnalysis} + hideDetailedBlunderMeter={true} + desktopContentHeightCss={desktopSidebarContentHeightCss} + containerStyle={{ + width: 'clamp(23rem, 27vw, 26rem)', + minWidth: '23rem', + flexBasis: 'clamp(23rem, 27vw, 26rem)', + }} + footerContent={ +
+ +
+ } itemVariants={itemVariants} />
@@ -1373,30 +1717,11 @@ const Analysis: React.FC = ({
-
- - - - - - Maia - - - - - - - SF - - - - - - - - Played - -
+
= ({
-
-
-
- {[3, 2, 1, 0, -1, -2, -3].map((value) => ( -
- ))} -
- +4 -
-
- -4 -
-
- - {displayedStockfishEvalText} - -
+
-
- - Maia % - -
- {mobileBlunderSegments.map((segment) => ( - - {segment.probability >= 7 && ( - <> - - {segment.badge} - - - {segment.probability}% - - - )} - - ))} -
-
-
-
- {mobileBlunderSegments.map((segment) => ( -
- {segment.label}:{' '} - {segment.topMoves.length - ? segment.topMoves.map((topMove, index) => ( - - {index > 0 && , } - - - )) - : '-'} -
- ))} -
-
+