diff --git a/lib/src/model/common/eval.dart b/lib/src/model/common/eval.dart index 7341e384d3..9bfd005a39 100644 --- a/lib/src/model/common/eval.dart +++ b/lib/src/model/common/eval.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import 'package:collection/collection.dart'; import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -47,6 +48,16 @@ class ExternalEval with _$ExternalEval implements Eval { } } +double _toWhiteWinningChanges(int? cp, int? mate) { + if (mate != null) { + return mateWinningChances(mate); + } else if (cp != null) { + return cpWinningChances(cp); + } else { + return 0; + } +} + /// The eval from the client's own engine, typically stockfish. @freezed class ClientEval with _$ClientEval implements Eval { @@ -71,10 +82,11 @@ class ClientEval with _$ClientEval implements Eval { return Move.fromUci(uci); } - IList get bestMoves { + IList bestMoves() { return pvs .where((e) => e.moves.isNotEmpty) - .map((e) => Move.fromUci(e.moves.first)) + .map((e) => e.firstMoveWithWinningChances(position.turn)) + .whereNotNull() .toIList(); } @@ -89,13 +101,7 @@ class ClientEval with _$ClientEval implements Eval { double winningChances(Side side) => _toPov(side, _whiteWinningChances); double get _whiteWinningChances { - if (mate != null) { - return mateWinningChances(mate!); - } else if (cp != null) { - return cpWinningChances(cp!); - } else { - return 0; - } + return _toWhiteWinningChanges(cp, mate); } } @@ -136,6 +142,25 @@ class PvData with _$PvData { } return res; } + + MoveWithWinningChanges? firstMoveWithWinningChances(Side sideToMove) { + final uciMove = (moves.isNotEmpty) ? Move.fromUci(moves.first) : null; + return (uciMove != null) + ? MoveWithWinningChanges( + move: uciMove, + winningChances: _toPov(sideToMove, _toWhiteWinningChanges(cp, mate)), + ) + : null; + } +} + +@freezed +class MoveWithWinningChanges with _$MoveWithWinningChanges { + const MoveWithWinningChanges._(); + const factory MoveWithWinningChanges({ + required Move move, + required double winningChances, + }) = _MoveWithWinningChanges; } double cpToPawns(int cp) => cp / 100; diff --git a/lib/src/view/analysis/analysis_screen.dart b/lib/src/view/analysis/analysis_screen.dart index 9c20e224aa..7c7a9dc601 100644 --- a/lib/src/view/analysis/analysis_screen.dart +++ b/lib/src/view/analysis/analysis_screen.dart @@ -377,6 +377,65 @@ class _Board extends ConsumerStatefulWidget { class _BoardState extends ConsumerState<_Board> { ISet userShapes = ISet(); + ISet _computeBestMoveShapes(IList moves) { + // Scale down all moves with index > 0 based on how much worse their winning chances are compared to the best move + // (assume moves are ordered by their winning chances, so index==0 is the best move) + double scaleArrowAgainstBestMove(int index) { + final bestMove = moves[0]; + final winningDiffComparedToBestMove = + bestMove.winningChances - moves[index].winningChances; + const double minScale = 0.25; + // Force minimum scale if... + // 1) The best move is significantly better than this move + // 2) The signs differ, e.g. the best move is winning while this move draws (or even looses) + if (winningDiffComparedToBestMove > 0.3 || + bestMove.winningChances.sign != moves[index].winningChances.sign) { + return minScale; + } + return math.max( + minScale, + 1.4 - 5.0 * winningDiffComparedToBestMove, + ); + } + + return ISet( + moves.mapIndexed( + (i, m) { + final move = m.move; + // Like in the Web UI, the best move has a slightly higher opacity, + // all other moves have the same lower opacity + final color = + const Color(0x40003088).withOpacity((i == 0) ? 0.4 : 0.25); + switch (move) { + case NormalMove(from: _, to: _, promotion: final promRole): + return [ + cg.Arrow( + color: color, + orig: move.cg.from, + dest: move.cg.to, + scale: scaleArrowAgainstBestMove(i), + ), + if (promRole != null) + cg.PieceShape( + color: color, + orig: move.cg.to, + role: promRole.cg, + ), + ]; + case DropMove(role: final role, to: _): + return [ + cg.PieceShape( + color: color, + orig: move.cg.to, + role: role.cg, + ), + ]; + } + }, + ).expand((e) => e), + ); + } + @override Widget build(BuildContext context) { final ctrlProvider = analysisControllerProvider(widget.pgn, widget.options); @@ -389,52 +448,20 @@ class _BoardState extends ConsumerState<_Board> { ); final evalBestMoves = ref.watch( - engineEvaluationProvider.select((s) => s.eval?.bestMoves), + engineEvaluationProvider.select((s) => s.eval?.bestMoves()), ); final currentNode = analysisState.currentNode; final annotation = makeAnnotation(currentNode.nags); - final bestMoves = evalBestMoves ?? currentNode.eval?.bestMoves; + final bestMoves = evalBestMoves ?? currentNode.eval?.bestMoves(); final sanMove = currentNode.sanMove; final ISet bestMoveShapes = showBestMoveArrow && analysisState.isEngineAvailable && bestMoves != null - ? ISet( - bestMoves.where((move) => move != null).mapIndexed( - (i, move) { - switch (move!) { - case NormalMove(from: _, to: _, promotion: final promRole): - return [ - cg.Arrow( - color: - const Color(0x40003088).withOpacity(0.4 - 0.15 * i), - orig: move.cg.from, - dest: move.cg.to, - ), - if (promRole != null) - cg.PieceShape( - color: const Color(0x40003088) - .withOpacity(0.4 - 0.15 * i), - orig: move.cg.to, - role: promRole.cg, - ), - ]; - case DropMove(role: final role, to: _): - return [ - cg.PieceShape( - color: - const Color(0x40003088).withOpacity(0.4 - 0.15 * i), - orig: move.cg.to, - role: role.cg, - ), - ]; - } - }, - ).expand((e) => e), - ) + ? _computeBestMoveShapes(bestMoves) : ISet(); return cg.Board(