Add live move annotations using local eval

- Adds live annotations derived from local engine eval
- Adds a UI toggle to enable/disable live annotations
- Renders live glyphs on the board and in the move list
- Rebuilds and caches glyphs when toggling or navigating
This commit is contained in:
Richard Weber
2026-05-12 22:44:55 +03:00
committed by Thibault Duplessis
parent e658a5e678
commit 4b07c3cdcd
6 changed files with 92 additions and 8 deletions
+8 -1
View File
@@ -176,7 +176,14 @@ export function compute(ctrl: AnalyseCtrl): DrawShape[] {
}
});
}
if (ctrl.showMoveAnnotationsOnBoard()) shapes = shapes.concat(annotationShapes(ctrl.node));
if (ctrl.showMoveAnnotationsOnBoard()) {
const liveGlyphs = ctrl.liveGlyphsOf(ctrl.path);
const glyphs = liveGlyphs ?? ctrl.node.glyphs;
shapes = shapes.concat(
// Override server analysis glyphs as local eval also overrides the eval score
annotationShapes(liveGlyphs ? { ...ctrl.node, glyphs } : ctrl.node),
);
}
if (ctrl.showVariationArrows()) hiliteVariations(ctrl, shapes);
if (ctrl.isCevalAllowed()) {
+41 -2
View File
@@ -39,7 +39,7 @@ import { pubsub } from 'lib/pubsub';
import { storedBooleanProp, storedBooleanPropWithEffect } from 'lib/storage';
import { makeTree, treePath, treeOps, type TreeWrapper } from 'lib/tree';
import { completeNode } from 'lib/tree/node';
import type { ClientEval, LocalEval, ServerEval, TreeNode, TreePath } from 'lib/tree/types';
import type { ClientEval, Glyph, LocalEval, ServerEval, TreeNode, TreePath } from 'lib/tree/types';
import { confirm } from 'lib/view';
import { Autoplay, type AutoplayDelay } from './autoplay';
@@ -52,6 +52,7 @@ import { ForkCtrl } from './fork';
import { IdbTree } from './idbTree';
import type { AnalyseOpts, AnalyseData, ServerEvalData, JustCaptured, NvuiPlugin } from './interfaces';
import * as keyboard from './keyboard';
import { isLocalEval, liveNodeGlyphs } from './liveAnnotate';
import MotifCtrl from './motif/motifCtrl';
import Navigate from './navigate';
import { nextGlyphSymbol, add3or5FoldGlyphs } from './nodeFinder';
@@ -122,6 +123,8 @@ export default class AnalyseCtrl implements CevalHandler {
);
showFishnetAnalysis = storedBooleanProp('analyse.show-computer', true);
possiblyShowMoveAnnotationsOnBoard = storedBooleanProp('analyse.show-move-annotation', true);
liveAnnotationsProp = storedBooleanProp('analyse.live-annotations', false);
liveGlyphs = new Map<TreePath, Glyph[]>();
keyboardHelp: boolean = location.hash === '#keyboard';
threatMode: Prop<boolean> = prop(false);
disclosureMode = storedBooleanProp('analyse.disclosure.enabled', false);
@@ -738,6 +741,11 @@ export default class AnalyseCtrl implements CevalHandler {
if (node.ceval?.cloud && this.ceval.isDeeper()) node.ceval = ev;
}
if (this.liveAnnotationsProp() && !isThreat && isLocalEval(ev)) {
this.annotateLivePath(path);
this.annotateLiveChildren(path, node);
}
if (path === this.path) {
this.setAutoShapes();
if (!isThreat) {
@@ -752,6 +760,28 @@ export default class AnalyseCtrl implements CevalHandler {
});
};
private annotateLivePath(path: TreePath): void {
if (path.length < 2) return;
const glyphs = liveNodeGlyphs(this.tree.nodeAtPath(path), this.tree.parentNode(path));
if (glyphs) this.liveGlyphs.set(path, glyphs);
else this.liveGlyphs.delete(path);
}
private annotateLiveChildren(path: TreePath, node: TreeNode): void {
node.children.forEach(child => this.annotateLivePath(path + child.id));
}
private rebuildLiveGlyphs(node = this.tree.root, path: TreePath = treePath.root): void {
node.children.forEach(child => {
const childPath = path + child.id;
this.annotateLivePath(childPath);
this.rebuildLiveGlyphs(child, childPath);
});
}
liveGlyphsOf = (path: TreePath): Glyph[] | undefined =>
this.liveAnnotationsProp() ? this.liveGlyphs.get(path) : undefined;
private initCeval(): void {
const opts: CevalOpts = {
variant: this.data.game.variant,
@@ -825,7 +855,8 @@ export default class AnalyseCtrl implements CevalHandler {
return this.showFishnetAnalysis() || (this.cevalEnabled() && this.isCevalAllowed());
}
showMoveGlyphs = (): boolean => (this.study && !this.study.relay) || this.showFishnetAnalysis();
showMoveGlyphs = (): boolean =>
(this.study && !this.study.relay) || this.showFishnetAnalysis() || this.liveAnnotationsProp();
showMoveAnnotationsOnBoard = (): boolean =>
this.possiblyShowMoveAnnotationsOnBoard() && this.showMoveGlyphs();
@@ -900,6 +931,14 @@ export default class AnalyseCtrl implements CevalHandler {
pubsub.emit('analysis.comp.toggle', this.showFishnetAnalysis());
};
toggleLiveAnnotations = () => {
this.liveAnnotationsProp(!this.liveAnnotationsProp());
this.liveGlyphs.clear();
if (this.liveAnnotationsProp()) this.rebuildLiveGlyphs();
this.resetAutoShapes();
this.redraw();
};
toggleActionMenu = () => {
if (!this.actionMenu() && this.explorer.enabled()) this.explorer.toggle();
this.actionMenu.toggle();
+25
View File
@@ -0,0 +1,25 @@
import { povChances } from 'lib/ceval/winningChances';
import type { ClientEval, Glyph, LocalEval, TreeNode } from 'lib/tree/types';
const glyphs = {
inaccuracy: { id: 6, symbol: '?!', name: 'Inaccuracy' } as Glyph,
mistake: { id: 2, symbol: '?', name: 'Mistake' } as Glyph,
blunder: { id: 4, symbol: '??', name: 'Blunder' } as Glyph,
};
export const isLocalEval = (ev?: ClientEval): ev is LocalEval => !!ev && !ev.cloud;
export function liveGlyph(parentEval: EvalScore, currentEval: EvalScore, ply: Ply): Glyph | undefined {
const color: Color = ply % 2 === 1 ? 'white' : 'black';
const loss = povChances(color, parentEval) - povChances(color, currentEval);
if (loss > 0.3) return glyphs.blunder;
if (loss > 0.2) return glyphs.mistake;
if (loss > 0.1) return glyphs.inaccuracy;
return undefined;
}
export function liveNodeGlyphs(node: TreeNode, parentNode: TreeNode): Glyph[] | undefined {
if (!isLocalEval(parentNode.ceval) || !isLocalEval(node.ceval)) return;
const glyph = liveGlyph(parentNode.ceval, node.ceval, node.ply);
return glyph ? [glyph] : [];
}
+5 -2
View File
@@ -164,11 +164,13 @@ export class InlineView {
'pending-deletion': path.startsWith(ctrl.pendingDeletionPath() || ' '),
'pending-copy': !!ctrl.pendingCopyPath()?.startsWith(path),
};
if (ctrl.showMoveGlyphs())
node.glyphs
const glyphs = ctrl.liveGlyphsOf(path) ?? node.glyphs;
if (ctrl.showMoveGlyphs()) {
glyphs
?.map(g => this.glyphs[g.id - 1])
.filter(Boolean)
.forEach(cls => (classes[cls] = true));
}
return hl('move', { attrs: { p: path }, class: classes }, [
parentDisclose && this.disclosureBtn(parentNode, parentPath),
withIndex && renderIndex(node.ply, true),
@@ -177,6 +179,7 @@ export class InlineView {
isMainline && !this.inline,
ctrl.showMoveGlyphs(),
ctrl.allowedEval(node) || false,
glyphs,
),
]);
}
+8
View File
@@ -184,6 +184,14 @@ export function view(ctrl: AnalyseCtrl): VNode {
prop: ctrl.showManeuverMoveArrowsProp,
redraw: ctrl.redraw,
}),
cmnToggleWrap({
id: 'live-annotations',
name: 'Live move annotations',
title: 'Show inaccuracy/mistake/blunder from local engine eval',
checked: ctrl.liveAnnotationsProp(),
change: ctrl.toggleLiveAnnotations,
redraw: ctrl.redraw,
}),
displayColumns() > 1 &&
cmnToggleWrapProp({
id: 'gauge',
+5 -3
View File
@@ -12,7 +12,7 @@ import * as licon from 'lib/licon';
import * as Prefs from 'lib/prefs';
import { storage } from 'lib/storage';
import { path as treePath } from 'lib/tree/tree';
import type { ClientEval, ServerEval, TreeNode, TreePath } from 'lib/tree/types';
import type { ClientEval, Glyph, ServerEval, TreeNode, TreePath } from 'lib/tree/types';
import {
type VNode,
type LooseVNodes,
@@ -276,6 +276,7 @@ export function renderMoveNodes(
withEval: boolean,
withGlyphs: boolean,
ev?: ClientEval | ServerEval | false,
glyphs?: Glyph[],
): VNode[] {
ev ??= node.ceval ?? node.eval; // ev = false will override withEval
const evalText = !ev
@@ -286,8 +287,9 @@ export function renderMoveNodes(
? `#${ev.mate}`
: '';
const nodes = [h('san', fixCrazySan(node.san!))];
if (withGlyphs && node.glyphs)
node.glyphs.forEach(g => nodes.push(h('glyph', { attrs: { title: g.name } }, g.symbol)));
const relevantGlyphs = glyphs ?? node.glyphs;
if (withGlyphs && relevantGlyphs)
relevantGlyphs.forEach(g => nodes.push(h('glyph', { attrs: { title: g.name } }, g.symbol)));
if (withEval && node.shapes?.length) nodes.push(h('shapes'));
if (withEval && evalText) nodes.push(h('eval', evalText.replace('-', '')));
return nodes;