Skip to content

Commit a920f6d

Browse files
feat: make board description interactable
1 parent fdefe6d commit a920f6d

5 files changed

Lines changed: 210 additions & 35 deletions

File tree

src/components/Analysis/Highlight.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { MoveTooltip } from './MoveTooltip'
2+
import { InteractiveDescription } from './InteractiveDescription'
23
import { useState, useEffect, useRef } from 'react'
34
import { motion, AnimatePresence } from 'framer-motion'
45
import { MaiaEvaluation, StockfishEvaluation, ColorSanMapping } from 'src/types'
56

7+
type DescriptionSegment =
8+
| { type: 'text'; content: string }
9+
| { type: 'move'; san: string; uci: string }
10+
611
export const MAIA_MODELS = [
712
'maia_kdd_1100',
813
'maia_kdd_1200',
@@ -35,7 +40,7 @@ interface Props {
3540
}
3641
hover: (move?: string) => void
3742
makeMove: (move: string) => void
38-
boardDescription: string
43+
boardDescription: { segments: DescriptionSegment[] }
3944
}
4045

4146
export const Highlight: React.FC<Props> = ({
@@ -113,17 +118,17 @@ export const Highlight: React.FC<Props> = ({
113118
}
114119

115120
// Track whether description exists (not its content)
116-
const hasDescriptionRef = useRef(!!boardDescription)
121+
const hasDescriptionRef = useRef(boardDescription.segments.length > 0)
117122
const [animationKey, setAnimationKey] = useState(0)
118123

119124
useEffect(() => {
120-
const descriptionNowExists = !!boardDescription
125+
const descriptionNowExists = boardDescription.segments.length > 0
121126
// Only trigger animation when presence changes (exists vs doesn't exist)
122127
if (hasDescriptionRef.current !== descriptionNowExists) {
123128
hasDescriptionRef.current = descriptionNowExists
124129
setAnimationKey((prev) => prev + 1)
125130
}
126-
}, [boardDescription])
131+
}, [boardDescription.segments.length])
127132

128133
return (
129134
<div
@@ -267,7 +272,7 @@ export const Highlight: React.FC<Props> = ({
267272
</div>
268273
<div className="flex flex-col items-start justify-start bg-background-1/80 p-2 text-sm">
269274
<AnimatePresence mode="wait">
270-
{boardDescription ? (
275+
{boardDescription.segments.length > 0 ? (
271276
<motion.div
272277
key={animationKey}
273278
initial={{ opacity: 0, y: 10 }}
@@ -276,9 +281,13 @@ export const Highlight: React.FC<Props> = ({
276281
transition={{ duration: 0.075 }}
277282
className="w-full"
278283
>
279-
<p className="w-full whitespace-normal break-words text-[10px] leading-tight text-secondary xl:text-xs xl:leading-tight">
280-
{boardDescription}
281-
</p>
284+
<InteractiveDescription
285+
description={boardDescription}
286+
colorSanMapping={colorSanMapping}
287+
moveEvaluation={moveEvaluation}
288+
hover={hover}
289+
makeMove={makeMove}
290+
/>
282291
</motion.div>
283292
) : null}
284293
</AnimatePresence>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React, { useState } from 'react'
2+
import { MoveTooltip } from './MoveTooltip'
3+
import { ColorSanMapping } from 'src/types'
4+
5+
type DescriptionSegment =
6+
| { type: 'text'; content: string }
7+
| { type: 'move'; san: string; uci: string }
8+
9+
interface Props {
10+
description: { segments: DescriptionSegment[] }
11+
colorSanMapping: ColorSanMapping
12+
moveEvaluation?: {
13+
maia?: { policy: { [key: string]: number } }
14+
stockfish?: {
15+
cp_vec: { [key: string]: number }
16+
winrate_vec?: { [key: string]: number }
17+
winrate_loss_vec?: { [key: string]: number }
18+
}
19+
} | null
20+
hover: (move?: string) => void
21+
makeMove: (move: string) => void
22+
}
23+
24+
export const InteractiveDescription: React.FC<Props> = ({
25+
description,
26+
colorSanMapping,
27+
moveEvaluation,
28+
hover,
29+
makeMove,
30+
}) => {
31+
const [tooltipData, setTooltipData] = useState<{
32+
move: string
33+
position: { x: number; y: number }
34+
} | null>(null)
35+
36+
const handleMouseEnter = (move: string, event: React.MouseEvent) => {
37+
hover(move)
38+
setTooltipData({
39+
move,
40+
position: { x: event.clientX, y: event.clientY },
41+
})
42+
}
43+
44+
const handleMouseLeave = () => {
45+
hover()
46+
setTooltipData(null)
47+
}
48+
49+
const renderSegments = () => {
50+
return description.segments.map((segment, index) => {
51+
if (segment.type === 'text') {
52+
return <span key={index}>{segment.content}</span>
53+
} else {
54+
return (
55+
<button
56+
key={index}
57+
className="cursor-pointer text-primary hover:underline"
58+
style={{
59+
color: colorSanMapping[segment.uci]?.color ?? '#fff',
60+
}}
61+
onMouseEnter={(e) => handleMouseEnter(segment.uci, e)}
62+
onMouseLeave={handleMouseLeave}
63+
onClick={() => makeMove(segment.uci)}
64+
>
65+
{segment.san}
66+
</button>
67+
)
68+
}
69+
})
70+
}
71+
72+
return (
73+
<div className="w-full">
74+
<p className="w-full whitespace-normal break-words text-[10px] leading-tight text-secondary xl:text-xs xl:leading-tight">
75+
{renderSegments()}
76+
</p>
77+
78+
{/* Tooltip */}
79+
{tooltipData && moveEvaluation && (
80+
<MoveTooltip
81+
move={tooltipData.move}
82+
colorSanMapping={colorSanMapping}
83+
maiaProb={moveEvaluation.maia?.policy[tooltipData.move]}
84+
stockfishCp={moveEvaluation.stockfish?.cp_vec[tooltipData.move]}
85+
stockfishWinrate={
86+
moveEvaluation.stockfish?.winrate_vec?.[tooltipData.move]
87+
}
88+
stockfishLoss={
89+
moveEvaluation.stockfish?.winrate_loss_vec?.[tooltipData.move]
90+
}
91+
position={tooltipData.position}
92+
/>
93+
)}
94+
</div>
95+
)
96+
}

src/components/Analysis/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from './MoveMap'
22
export * from './MovePlot'
33
export * from './MoveTooltip'
44
export * from './Highlight'
5+
export * from './InteractiveDescription'
56
export * from './Tournament'
67
export * from './BlunderMeter'
78
export * from './MovesByRating'

src/hooks/useAnalysisController/useBoardDescription.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const useBoardDescription = (
1717
!moveEvaluation?.maia ||
1818
moveEvaluation.stockfish.depth < 12
1919
) {
20-
return ''
20+
return { segments: [] }
2121
}
2222

2323
const fen = currentNode.fen

src/hooks/useAnalysisController/useDescriptionGenerator.ts

Lines changed: 95 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { Chess, PieceSymbol } from 'chess.ts'
44
type StockfishEvals = Record<string, number>
55
type MaiaEvals = Record<string, number[]>
66

7+
type DescriptionSegment =
8+
| { type: 'text'; content: string }
9+
| { type: 'move'; san: string; uci: string }
10+
711
/* ---------------- phrase banks ---------------- */
812

913
const pick = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)]
@@ -53,7 +57,7 @@ const TEMPT_WORD = ['tempting', 'enticing', 'natural-looking']
5357

5458
/* ---------------- constants & helpers ---------------- */
5559

56-
const EPS = 0.08 // good move window
60+
const EPS = 0.08 // "good move" window
5761
const BLUNDER_GAP = 0.1 // win-rate drop that defines a blunder
5862

5963
const winRate = (p: number) => 1 / (1 + Math.exp(-(p - 1) / 0.8))
@@ -70,7 +74,7 @@ export function describePosition(
7074
sf: StockfishEvals,
7175
maia: MaiaEvals,
7276
whiteToMove: boolean,
73-
): string {
77+
): { segments: DescriptionSegment[] } {
7478
/* ---------- board ---------- */
7579
const chess = new Chess(fen)
7680
const legal = new Set<string>()
@@ -79,7 +83,10 @@ export function describePosition(
7983
.forEach((m) => legal.add(m.from + m.to + (m.promotion ?? '')))
8084

8185
const moves = Object.keys(sf).filter((m) => legal.has(m))
82-
if (!moves.length) return 'No legal moves available.'
86+
if (!moves.length)
87+
return {
88+
segments: [{ type: 'text', content: 'No legal moves available.' }],
89+
}
8390

8491
if (!whiteToMove) {
8592
sf = Object.fromEntries(Object.entries(sf).map(([m, v]) => [m, -v]))
@@ -130,12 +137,13 @@ export function describePosition(
130137
Math.abs(wOpt - w2) <= EPS / 2 && Math.abs(dOpt - d2) <= EPS / 2
131138
}
132139

133-
const listWithOpt = sortedGood.slice(0, 3).map(uciToSan).join(', ')
134-
const listWithoutOpt = sortedGood
140+
const goodMovesList = sortedGood
141+
.slice(0, 3)
142+
.map((uci) => ({ san: uciToSan(uci), uci }))
143+
const goodMovesWithoutOpt = sortedGood
135144
.filter((m) => m !== opt)
136145
.slice(0, 3)
137-
.map(uciToSan)
138-
.join(', ')
146+
.map((uci) => ({ san: uciToSan(uci), uci }))
139147

140148
/* ---------- outcome wording ---------- */
141149
const avgGood = sortedGood.reduce((s, m) => s + seval[m], 0) / nGood
@@ -242,15 +250,25 @@ export function describePosition(
242250
const prefix = setT === 2 && !bestHarder ? ', however' : ''
243251
const temptW = pick(TEMPT_WORD)
244252

245-
let tail = ''
253+
let tailSegments: DescriptionSegment[] = []
246254
if (blunderMove && treach) {
247-
tail = ` ${pick(CAREFUL)}${prefix}, this position is highly treacherous! It is easy to go astray with ${temptW} blunders like ${uciToSan(
248-
blunderMove,
249-
)}.`
255+
tailSegments = [
256+
{
257+
type: 'text',
258+
content: ` ${pick(CAREFUL)}${prefix}, this position is highly treacherous! It is easy to go astray with ${temptW} blunders like `,
259+
},
260+
{ type: 'move', san: uciToSan(blunderMove), uci: blunderMove },
261+
{ type: 'text', content: '.' },
262+
]
250263
} else if (blunderMove) {
251-
tail = ` ${pick(CAREFUL)}${prefix}! There is a ${temptW} blunder in this position: ${uciToSan(
252-
blunderMove,
253-
)}.`
264+
tailSegments = [
265+
{
266+
type: 'text',
267+
content: ` ${pick(CAREFUL)}${prefix}! There is a ${temptW} blunder in this position: `,
268+
},
269+
{ type: 'move', san: uciToSan(blunderMove), uci: blunderMove },
270+
{ type: 'text', content: '.' },
271+
]
254272
} else {
255273
const showTempt = setT < 2 || (setT === 2 && temptLv > 4)
256274
if (showTempt) {
@@ -261,22 +279,73 @@ export function describePosition(
261279
Object.entries(aggProb)
262280
.filter(([m]) => !good.includes(m))
263281
.sort((a, b) => b[1] - a[1])[0]?.[0] ?? ''
264-
const temptSan = temptUci ? uciToSan(temptUci) : ''
282+
265283
const intro = pick(TEMPTING_INTRO)
266-
tail = temptSan
267-
? ` ${intro}${prefix} are also ${temptW} alternatives, such as ${temptSan}.`
268-
: ` ${intro}${prefix} are also ${temptW} alternatives.`
284+
if (temptUci) {
285+
tailSegments = [
286+
{
287+
type: 'text',
288+
content: ` ${intro}${prefix} are also ${temptW} alternatives, such as `,
289+
},
290+
{ type: 'move', san: uciToSan(temptUci), uci: temptUci },
291+
{ type: 'text', content: '.' },
292+
]
293+
} else {
294+
tailSegments = [
295+
{
296+
type: 'text',
297+
content: ` ${intro}${prefix} are also ${temptW} alternatives.`,
298+
},
299+
]
300+
}
269301
}
270302
}
271303

272304
/* ---------- assemble ---------- */
273-
const moveList = bestHarder ? listWithoutOpt : listWithOpt
274-
const result =
275-
nGood === 1
276-
? `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}.${tail}`
277-
: bestHarder
278-
? `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}, but the best move (${bestMoveSan}) is ${phrBest}.${tail}`
279-
: `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}.${tail}`
305+
const segments: DescriptionSegment[] = []
306+
307+
// Helper function to create move list segments
308+
const createMoveListSegments = (moveList: { san: string; uci: string }[]) => {
309+
const moveSegments: DescriptionSegment[] = []
310+
moveList.forEach((move, index) => {
311+
moveSegments.push({ type: 'move', san: move.san, uci: move.uci })
312+
if (index < moveList.length - 1) {
313+
moveSegments.push({ type: 'text', content: ', ' })
314+
}
315+
})
316+
return moveSegments
317+
}
318+
319+
// Main description
320+
const moveList = bestHarder ? goodMovesWithoutOpt : goodMovesList
321+
322+
if (nGood === 1) {
323+
segments.push(
324+
{ type: 'text', content: `There ${verb} ${abundance} (` },
325+
...createMoveListSegments(moveList),
326+
{ type: 'text', content: `) ${outcome}, and ${pron} ${phrSet}.` },
327+
)
328+
} else if (bestHarder) {
329+
segments.push(
330+
{ type: 'text', content: `There ${verb} ${abundance} (` },
331+
...createMoveListSegments(moveList),
332+
{
333+
type: 'text',
334+
content: `) ${outcome}, and ${pron} ${phrSet}, but the best move (`,
335+
},
336+
{ type: 'move', san: bestMoveSan, uci: opt },
337+
{ type: 'text', content: `) is ${phrBest}.` },
338+
)
339+
} else {
340+
segments.push(
341+
{ type: 'text', content: `There ${verb} ${abundance} (` },
342+
...createMoveListSegments(moveList),
343+
{ type: 'text', content: `) ${outcome}, and ${pron} ${phrSet}.` },
344+
)
345+
}
346+
347+
// Add tail segments
348+
segments.push(...tailSegments)
280349

281-
return result
350+
return { segments }
282351
}

0 commit comments

Comments
 (0)