Skip to content

Commit 32e989f

Browse files
bmariniclaude
andcommitted
feat(web): eval graph — clickable sparkline of game evaluation
SVG eval graph showing position evaluation across the whole game: - White area = white advantage, dark area = black advantage - Blue cursor line tracks current move - Click anywhere on the graph to jump to that position - Shown on both mobile (below board) and desktop (right panel) - Only renders when eval metadata exists (after Analyze Game) - Evals clamped to ±5 pawns, mate scores shown as ±10 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cb9a874 commit 32e989f

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

web/components/ChessApp.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import PGNInput from './PGNInput'
99
import GameInfo from './GameInfo'
1010
import Icon from './Icon'
1111
import EvalBar from './EvalBar'
12+
import EvalGraph from './EvalGraph'
1213
import { Position } from '@chess/board'
1314
import { useChessGame } from '@/hooks/useChessGame'
1415
import { useEngine } from '@/hooks/useEngine'
@@ -392,6 +393,15 @@ export default function ChessApp() {
392393
</div>
393394
</div>
394395

396+
{/* Mobile eval graph */}
397+
<div className="w-full lg:hidden">
398+
<EvalGraph
399+
transitions={chess.mainTransitions}
400+
halfmove={chess.isInVariation ? 0 : chess.halfmove}
401+
onJump={chess.jumpTo}
402+
/>
403+
</div>
404+
395405
{/* Mobile move strip + annotation */}
396406
<div className="w-full lg:hidden">
397407
<div className="border-y border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900">
@@ -526,6 +536,11 @@ export default function ChessApp() {
526536
</button>
527537
</div>
528538
<GameInfo game={activeGame.game} detectedOpening={detectedOpening} />
539+
<EvalGraph
540+
transitions={chess.mainTransitions}
541+
halfmove={chess.isInVariation ? 0 : chess.halfmove}
542+
onJump={chess.jumpTo}
543+
/>
529544
<div className="flex-1 overflow-hidden p-3">
530545
<MoveList
531546
transitions={chess.mainTransitions}

web/components/EvalGraph.tsx

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
'use client'
2+
3+
import { useMemo } from 'react'
4+
import type { Transition } from '@chess/types'
5+
6+
type Props = {
7+
transitions: Transition[]
8+
halfmove: number
9+
onJump: (n: number) => void
10+
}
11+
12+
function parseEval(evalStr: string | undefined): number | null {
13+
if (!evalStr) return null
14+
if (evalStr.startsWith('#')) {
15+
const mate = parseInt(evalStr.slice(1), 10)
16+
return mate > 0 ? 10 : -10 // cap mate scores at ±10 pawns
17+
}
18+
const n = parseFloat(evalStr)
19+
return isNaN(n) ? null : n
20+
}
21+
22+
/**
23+
* Eval graph showing position evaluation across the whole game.
24+
* Click to jump to a specific move.
25+
*/
26+
export default function EvalGraph({ transitions, halfmove, onJump }: Props) {
27+
const evals = useMemo(() => {
28+
return transitions.map(t => parseEval(t.metadata?.eval))
29+
}, [transitions])
30+
31+
// Only render if we have at least some eval data
32+
const hasData = evals.some(e => e !== null)
33+
if (!hasData) return null
34+
35+
const n = evals.length
36+
const viewW = 400
37+
const viewH = 60
38+
const midY = viewH / 2
39+
40+
// Clamp evals to ±5 pawns for display
41+
const clamp = 5
42+
const toY = (val: number | null) => {
43+
if (val === null) return midY
44+
const clamped = Math.max(-clamp, Math.min(clamp, val))
45+
return midY - (clamped / clamp) * (midY - 2)
46+
}
47+
48+
// Build the area path — fill white advantage area
49+
const points = evals.map((e, i) => {
50+
const x = (i / Math.max(n - 1, 1)) * viewW
51+
const y = toY(e)
52+
return { x, y }
53+
})
54+
55+
// SVG path: top edge (evals), then bottom edge (midline)
56+
const areaPath = [
57+
`M 0 ${midY}`,
58+
...points.map(p => `L ${p.x} ${p.y}`),
59+
`L ${viewW} ${midY}`,
60+
'Z',
61+
].join(' ')
62+
63+
// Cursor position
64+
const cursorX = halfmove > 0 && halfmove <= n
65+
? ((halfmove - 1) / Math.max(n - 1, 1)) * viewW
66+
: null
67+
68+
return (
69+
<div className="w-full">
70+
<svg
71+
viewBox={`0 0 ${viewW} ${viewH}`}
72+
className="w-full h-10 lg:h-14 cursor-pointer"
73+
preserveAspectRatio="none"
74+
onClick={(e) => {
75+
const rect = e.currentTarget.getBoundingClientRect()
76+
const x = e.clientX - rect.left
77+
const pct = x / rect.width
78+
const move = Math.round(pct * (n - 1)) + 1
79+
onJump(Math.max(1, Math.min(n, move)))
80+
}}
81+
>
82+
{/* Background */}
83+
<rect x="0" y="0" width={viewW} height={midY} fill="rgba(0,0,0,0.06)" />
84+
<rect x="0" y={midY} width={viewW} height={midY} fill="rgba(255,255,255,0.06)" />
85+
86+
{/* Center line */}
87+
<line x1="0" y1={midY} x2={viewW} y2={midY}
88+
stroke="currentColor" strokeWidth="0.5" opacity="0.2" />
89+
90+
{/* Eval area — white advantage is below midline (positive eval = white winning) */}
91+
<path d={areaPath} fill="rgba(255,255,255,0.5)" />
92+
93+
{/* Eval line */}
94+
<polyline
95+
points={points.map(p => `${p.x},${p.y}`).join(' ')}
96+
fill="none"
97+
stroke="rgba(100,100,100,0.6)"
98+
strokeWidth="1"
99+
vectorEffect="non-scaling-stroke"
100+
/>
101+
102+
{/* Move cursor */}
103+
{cursorX !== null && (
104+
<line x1={cursorX} y1="0" x2={cursorX} y2={viewH}
105+
stroke="rgba(59,130,246,0.8)" strokeWidth="1.5"
106+
vectorEffect="non-scaling-stroke" />
107+
)}
108+
</svg>
109+
</div>
110+
)
111+
}

0 commit comments

Comments
 (0)