lila/ui/analyse/src/explorer/explorerView.ts

456 lines
14 KiB
TypeScript

import { h, VNode } from 'snabbdom';
import { numberFormat } from 'common/number';
import { bind, dataIcon } from 'common/snabbdom';
import { defined } from 'common';
import { view as renderConfig } from './explorerConfig';
import { winnerOf } from './explorerUtil';
import { MaybeVNode } from '../interfaces';
import AnalyseCtrl from '../ctrl';
import {
isOpening,
isTablebase,
TablebaseCategory,
TablebaseMoveStats,
OpeningData,
OpeningMoveStats,
OpeningGame,
Opening,
} from './interfaces';
function resultBar(move: OpeningMoveStats): VNode {
const sum = move.white + move.draws + move.black;
function section(key: 'white' | 'black' | 'draws') {
const percent = (move[key] * 100) / sum;
return percent === 0
? null
: h(
'span.' + key,
{
attrs: { style: 'width: ' + Math.round((move[key] * 1000) / sum) / 10 + '%' },
},
percent > 12 ? Math.round(percent) + (percent > 20 ? '%' : '') : ''
);
}
return h('div.bar', ['white', 'draws', 'black'].map(section));
}
function moveTableAttributes(ctrl: AnalyseCtrl, fen: Fen) {
return {
attrs: { 'data-fen': fen },
hook: {
insert(vnode: VNode) {
const el = vnode.elm as HTMLElement;
el.addEventListener('mouseover', e => {
ctrl.explorer.setHovering(
$(el).attr('data-fen')!,
$(e.target as HTMLElement)
.parents('tr')
.attr('data-uci')
);
});
el.addEventListener('mouseout', _ => {
ctrl.explorer.setHovering($(el).attr('data-fen')!, null);
});
el.addEventListener('mousedown', e => {
const uci = $(e.target as HTMLElement)
.parents('tr')
.attr('data-uci');
if (uci) ctrl.explorerMove(uci);
});
},
postpatch(old: VNode) {
setTimeout(() => {
const el = old.elm as HTMLElement;
ctrl.explorer.setHovering($(el).attr('data-fen')!, $(el).find('tr:hover').attr('data-uci'));
}, 100);
},
},
};
}
function showMoveTable(ctrl: AnalyseCtrl, data: OpeningData): VNode | null {
if (!data.moves.length) return null;
const trans = ctrl.trans.noarg;
const movesWithCurrent =
data.moves.length > 1 && [data.white, data.black, data.draws].every(defined)
? [
...data.moves,
{
...data,
uci: '',
san: 'Σ',
} as OpeningMoveStats,
]
: data.moves;
return h('table.moves', [
h('thead', [
h('tr', [h('th.title', trans('move')), h('th.title', trans('games')), h('th.title', trans('whiteDrawBlack'))]),
]),
h(
'tbody',
moveTableAttributes(ctrl, data.fen),
movesWithCurrent.map(move =>
h(
`tr.expl-uci-${move.uci}`,
{
attrs: {
'data-uci': move.uci,
title: move.uci ? ctrl.trans('averageRatingX', move.averageRating) : 'Total',
},
},
[
h('td', move.san[0] === 'P' ? move.san.slice(1) : move.san),
h('td', numberFormat(move.white + move.draws + move.black)),
h('td', resultBar(move)),
]
)
)
),
]);
}
function showResult(winner?: Color): VNode {
if (winner === 'white') return h('result.white', '1-0');
if (winner === 'black') return h('result.black', '0-1');
return h('result.draws', '½-½');
}
function showGameTable(ctrl: AnalyseCtrl, title: string, games: OpeningGame[]): VNode | null {
if (!ctrl.explorer.withGames || !games.length) return null;
const openedId = ctrl.explorer.gameMenu();
return h('table.games', [
h('thead', [h('tr', [h('th.title', { attrs: { colspan: 4 } }, title)])]),
h(
'tbody',
{
hook: bind('click', e => {
const $tr = $(e.target as HTMLElement).parents('tr');
if (!$tr.length) return;
const id = $tr.data('id');
if (ctrl.study && ctrl.study.members.canContribute()) {
ctrl.explorer.gameMenu(id);
ctrl.redraw();
} else openGame(ctrl, id);
}),
},
games.map(game => {
return openedId === game.id
? gameActions(ctrl, game)
: h(
'tr',
{
key: game.id,
attrs: { 'data-id': game.id },
},
[
h(
'td',
[game.white, game.black].map(p => h('span', '' + p.rating))
),
h(
'td',
[game.white, game.black].map(p => h('span', p.name))
),
h('td', showResult(game.winner)),
h('td', [game.year]),
]
);
})
),
]);
}
function openGame(ctrl: AnalyseCtrl, gameId: string) {
const orientation = ctrl.chessground.state.orientation,
fenParam = ctrl.node.ply > 0 ? '?fen=' + ctrl.node.fen : '';
let url = '/' + gameId + '/' + orientation + fenParam;
if (ctrl.explorer.config.data.db.selected() === 'masters') url = '/import/master' + url;
window.open(url, '_blank', 'noopener');
}
function gameActions(ctrl: AnalyseCtrl, game: OpeningGame): VNode {
function send(insert: boolean) {
ctrl.study!.explorerGame(game.id, insert);
ctrl.explorer.gameMenu(null);
ctrl.redraw();
}
return h(
'tr',
{
key: game.id + '-m',
},
[
h(
'td.game_menu',
{
attrs: { colspan: 4 },
},
[
h('div.game_title', `${game.white.name} - ${game.black.name}, ${showResult(game.winner).text}, ${game.year}`),
h('div.menu', [
h(
'a.text',
{
attrs: dataIcon(''),
hook: bind('click', _ => openGame(ctrl, game.id)),
},
'View'
),
...(ctrl.study
? [
h(
'a.text',
{
attrs: dataIcon(''),
hook: bind('click', _ => send(false), ctrl.redraw),
},
'Cite'
),
h(
'a.text',
{
attrs: dataIcon(''),
hook: bind('click', _ => send(true), ctrl.redraw),
},
'Insert'
),
]
: []),
h(
'a.text',
{
attrs: dataIcon(''),
hook: bind('click', _ => ctrl.explorer.gameMenu(null), ctrl.redraw),
},
'Close'
),
]),
]
),
]
);
}
function showTablebase(ctrl: AnalyseCtrl, fen: Fen, title: string, moves: TablebaseMoveStats[]): VNode[] {
if (!moves.length) return [];
return [
h('div.title', title),
h('table.tablebase', [
h(
'tbody',
moveTableAttributes(ctrl, fen),
moves.map(move => {
return h(
'tr',
{
key: move.uci,
attrs: { 'data-uci': move.uci },
},
[h('td', move.san), h('td', [showDtz(ctrl, fen, move), showDtm(ctrl, fen, move)])]
);
})
),
]),
];
}
function showDtm(ctrl: AnalyseCtrl, fen: Fen, move: TablebaseMoveStats) {
if (move.dtm)
return h(
'result.' + winnerOf(fen, move),
{
attrs: {
title: ctrl.trans.plural('mateInXHalfMoves', Math.abs(move.dtm)) + ' (Depth To Mate)',
},
},
'DTM ' + Math.abs(move.dtm)
);
return undefined;
}
function showDtz(ctrl: AnalyseCtrl, fen: Fen, move: TablebaseMoveStats): VNode | null {
const trans = ctrl.trans.noarg;
if (move.checkmate) return h('result.' + winnerOf(fen, move), trans('checkmate'));
else if (move.variant_win) return h('result.' + winnerOf(fen, move), trans('variantLoss'));
else if (move.variant_loss) return h('result.' + winnerOf(fen, move), trans('variantWin'));
else if (move.stalemate) return h('result.draws', trans('stalemate'));
else if (move.insufficient_material) return h('result.draws', trans('insufficientMaterial'));
else if (move.dtz === null) return null;
else if (move.dtz === 0) return h('result.draws', trans('draw'));
else if (move.zeroing)
return move.san.includes('x')
? h('result.' + winnerOf(fen, move), trans('capture'))
: h('result.' + winnerOf(fen, move), trans('pawnMove'));
return h(
'result.' + winnerOf(fen, move),
{
attrs: {
title: ctrl.trans.plural('nextCaptureOrPawnMoveInXHalfMoves', Math.abs(move.dtz)),
},
},
'DTZ ' + Math.abs(move.dtz)
);
}
function closeButton(ctrl: AnalyseCtrl): VNode {
return h(
'button.button.button-empty.text',
{
attrs: dataIcon(''),
hook: bind('click', ctrl.toggleExplorer, ctrl.redraw),
},
ctrl.trans.noarg('close')
);
}
function showEmpty(ctrl: AnalyseCtrl, opening?: Opening): VNode {
return h('div.data.empty', [
h(
'div.title',
h(
'span',
{
attrs: opening ? { title: opening && `${opening.eco} ${opening.name}` } : {},
},
opening ? [h('strong', opening.eco), ' ', opening.name] : [showTitle(ctrl, ctrl.data.game.variant)]
)
),
h('div.message', [
h('strong', ctrl.trans.noarg('noGameFound')),
ctrl.explorer.config.fullHouse()
? null
: h('p.explanation', ctrl.trans.noarg('maybeIncludeMoreGamesFromThePreferencesMenu')),
closeButton(ctrl),
]),
]);
}
function showGameEnd(ctrl: AnalyseCtrl, title: string): VNode {
return h('div.data.empty', [
h('div.title', ctrl.trans.noarg('gameOver')),
h('div.message', [h('i', { attrs: dataIcon('') }), h('h3', title), closeButton(ctrl)]),
]);
}
let lastShow: MaybeVNode;
function show(ctrl: AnalyseCtrl): MaybeVNode {
const trans = ctrl.trans.noarg,
data = ctrl.explorer.current();
if (data && isOpening(data)) {
const moveTable = showMoveTable(ctrl, data),
recentTable = showGameTable(ctrl, trans('recentGames'), data.recentGames || []),
topTable = showGameTable(ctrl, trans('topGames'), data.topGames || []);
if (moveTable || recentTable || topTable)
lastShow = h('div.data', [
data &&
data.opening &&
h(
'div.title',
h(
'span',
{
attrs: data.opening ? { title: data.opening && `${data.opening.eco} ${data.opening.name}` } : {},
},
[h('strong', data.opening.eco), ' ', data.opening.name]
)
),
moveTable,
topTable,
recentTable,
]);
else lastShow = showEmpty(ctrl, data.opening);
} else if (data && isTablebase(data)) {
const row = (category: TablebaseCategory, title: string) =>
showTablebase(
ctrl,
data.fen,
title,
data.moves.filter(m => m.category == category)
);
if (data.moves.length)
lastShow = h('div.data', [
...row('loss', trans('winning')),
...row('unknown', trans('unknown')),
...row('maybe-loss', 'Winning or 50 moves by prior mistake'),
...row('blessed-loss', trans('winPreventedBy50MoveRule')),
...row('draw', trans('drawn')),
...row('cursed-win', trans('lossSavedBy50MoveRule')),
...row('maybe-win', 'Losing or 50 moves by prior mistake'),
...row('win', trans('losing')),
]);
else if (data.checkmate) lastShow = showGameEnd(ctrl, trans('checkmate'));
else if (data.stalemate) lastShow = showGameEnd(ctrl, trans('stalemate'));
else if (data.variant_win || data.variant_loss) lastShow = showGameEnd(ctrl, trans('variantEnding'));
else lastShow = showEmpty(ctrl);
}
return lastShow;
}
function showTitle(ctrl: AnalyseCtrl, variant: Variant) {
if (variant.key === 'standard' || variant.key === 'fromPosition') return ctrl.trans.noarg('openingExplorer');
return ctrl.trans('xOpeningExplorer', variant.name);
}
function showConfig(ctrl: AnalyseCtrl): VNode {
return h(
'div.config',
[h('div.title', showTitle(ctrl, ctrl.data.game.variant))].concat(renderConfig(ctrl.explorer.config))
);
}
function showFailing(ctrl: AnalyseCtrl) {
return h('div.data.empty', [
h('div.title', showTitle(ctrl, ctrl.data.game.variant)),
h('div.failing.message', [
h('h3', 'Oops, sorry!'),
h('p.explanation', ctrl.explorer.failing()?.toString()),
closeButton(ctrl),
]),
]);
}
let lastFen: Fen = '';
export default function (ctrl: AnalyseCtrl): VNode | undefined {
const explorer = ctrl.explorer;
if (!explorer.enabled()) return;
const data = explorer.current(),
config = explorer.config,
configOpened = config.data.open(),
loading = !configOpened && (explorer.loading() || (!data && !explorer.failing())),
content = configOpened ? showConfig(ctrl) : explorer.failing() ? showFailing(ctrl) : show(ctrl);
return h(
'section.explorer-box.sub-box',
{
class: {
loading,
config: configOpened,
reduced: !configOpened && (!!explorer.failing() || explorer.movesAway() > 2),
},
hook: {
insert: vnode => ((vnode.elm as HTMLElement).scrollTop = 0),
postpatch(_, vnode) {
if (!data || lastFen === data.fen) return;
(vnode.elm as HTMLElement).scrollTop = 0;
lastFen = data.fen;
},
},
},
[
h('div.overlay'),
content,
!content || explorer.failing()
? null
: h('button.fbt.toconf', {
attrs: {
'aria-label': configOpened ? 'Close configuration' : 'Open configuration',
...dataIcon(configOpened ? '' : ''),
},
hook: bind('click', () => ctrl.explorer.config.toggleOpen(), ctrl.redraw),
}),
]
);
}