@kolkrabbi/kol-component 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/chess/ChessPiece.jsx +191 -0
- package/src/chess/apparatus/AlternativeControlsMock.jsx +460 -0
- package/src/chess/apparatus/ChessAnalysisLayout.jsx +24 -0
- package/src/chess/apparatus/ChessBoard.jsx +210 -0
- package/src/chess/apparatus/ChessBoardFullscreen.jsx +49 -0
- package/src/chess/apparatus/ChessBoardWithControls.jsx +41 -0
- package/src/chess/apparatus/ChessBoardWithSidebar.jsx +122 -0
- package/src/chess/apparatus/ChessSidebar.jsx +131 -0
- package/src/chess/apparatus/GameArchiveTable.jsx +493 -0
- package/src/chess/apparatus/GameSelector.jsx +47 -0
- package/src/chess/apparatus/NotationPanel.jsx +70 -0
- package/src/chess/apparatus/PlaybackControls.jsx +52 -0
- package/src/chess/apparatus/VariationTree.jsx +137 -0
- package/src/chess/assets/chess-extra-set/set-1/bishop-1.svg +6 -0
- package/src/chess/assets/chess-extra-set/set-1/bishop.svg +6 -0
- package/src/chess/assets/chess-extra-set/set-1/king-1.svg +9 -0
- package/src/chess/assets/chess-extra-set/set-1/king.svg +7 -0
- package/src/chess/assets/chess-extra-set/set-1/knight-1.svg +8 -0
- package/src/chess/assets/chess-extra-set/set-1/knight.svg +7 -0
- package/src/chess/assets/chess-extra-set/set-1/pawn-1.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-1/pawn.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-1/queen-1.svg +11 -0
- package/src/chess/assets/chess-extra-set/set-1/queen.svg +6 -0
- package/src/chess/assets/chess-extra-set/set-1/rook-1.svg +7 -0
- package/src/chess/assets/chess-extra-set/set-1/rook.svg +11 -0
- package/src/chess/assets/chess-extra-set/set-2/bishop-1.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/bishop.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/king-1.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/king.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/knight-1.svg +5 -0
- package/src/chess/assets/chess-extra-set/set-2/knight.svg +5 -0
- package/src/chess/assets/chess-extra-set/set-2/pawn-1.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/pawn.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/queen-1.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/queen.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/rook-1.svg +5 -0
- package/src/chess/assets/chess-extra-set/set-2/rook.svg +5 -0
- package/src/chess/assets/chess-extra-set/set-3/bishop-1.svg +10 -0
- package/src/chess/assets/chess-extra-set/set-3/bishop.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-3/king-1.svg +12 -0
- package/src/chess/assets/chess-extra-set/set-3/king.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-3/knight-1.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-3/knight.svg +10 -0
- package/src/chess/assets/chess-extra-set/set-3/pawn-1.svg +10 -0
- package/src/chess/assets/chess-extra-set/set-3/pawn.svg +10 -0
- package/src/chess/assets/chess-extra-set/set-3/queen-1.svg +10 -0
- package/src/chess/assets/chess-extra-set/set-3/queen.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-3/rook-1.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-3/rook.svg +11 -0
- package/src/chess/assets/chess-vector-set/ABoard.svg +164 -0
- package/src/chess/assets/chess-vector-set/BBiskup.svg +12 -0
- package/src/chess/assets/chess-vector-set/BKing.svg +15 -0
- package/src/chess/assets/chess-vector-set/BKnight.svg +13 -0
- package/src/chess/assets/chess-vector-set/BPawn.svg +7 -0
- package/src/chess/assets/chess-vector-set/BQueen.svg +6 -0
- package/src/chess/assets/chess-vector-set/BRook.svg +12 -0
- package/src/chess/assets/chess-vector-set/WBiskup.svg +14 -0
- package/src/chess/assets/chess-vector-set/WKing.svg +13 -0
- package/src/chess/assets/chess-vector-set/WKnight.svg +10 -0
- package/src/chess/assets/chess-vector-set/WPawn.svg +8 -0
- package/src/chess/assets/chess-vector-set/WQueen.svg +17 -0
- package/src/chess/assets/chess-vector-set/WRook.svg +13 -0
- package/src/chess/context/ChessControlsContext.jsx +379 -0
- package/src/chess/dashboards/ChessHero.jsx +303 -0
- package/src/chess/index.js +32 -0
- package/src/chess/utils/parsePgnTree.js +97 -0
- package/src/dashboards/cards/DashAlertCard.jsx +44 -0
- package/src/dashboards/cards/DashChartCard.jsx +44 -0
- package/src/dashboards/cards/DashFeaturedCard.jsx +42 -0
- package/src/dashboards/cards/DashListCard.jsx +87 -0
- package/src/dashboards/cards/DashMetricCard.jsx +59 -0
- package/src/dashboards/cards/DashSlotCard.jsx +45 -0
- package/src/dashboards/cards/DashStackedBarCard.jsx +70 -0
- package/src/dashboards/cards/DashTableCard.jsx +31 -0
- package/src/dashboards/cards/_shared/CardHeader.jsx +15 -0
- package/src/dashboards/cards/_shared/LegendRow.jsx +25 -0
- package/src/dashboards/charts/Candlestick.jsx +79 -0
- package/src/dashboards/charts/DonutChart.jsx +87 -0
- package/src/dashboards/charts/Heatmap.jsx +79 -0
- package/src/dashboards/charts/Histogram.jsx +56 -0
- package/src/dashboards/charts/LineChart.jsx +157 -0
- package/src/dashboards/charts/ScatterPlot.jsx +93 -0
- package/src/dashboards/charts/Sparkline.jsx +63 -0
- package/src/dashboards/index.js +28 -0
- package/src/dashboards/layout/DashboardGrid.jsx +30 -0
- package/src/dashboards/layout/GridCard.jsx +44 -0
- package/src/dashboards/shared/DashTooltip.jsx +6 -0
- package/src/dashboards/shared/formatMetric.js +26 -0
- package/src/dashboards/shared/useChartTooltip.js +25 -0
- package/src/dashboards/shared/useCountUp.js +107 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// Sample games are consumer-provided via the `chessData` prop (no bundled dataset).
|
|
2
|
+
// Required: chessData.getSampleGames() → array of games (each with a .pgn).
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { Chess } from 'chess.js'
|
|
5
|
+
import buildMoveTree from '../utils/parsePgnTree'
|
|
6
|
+
|
|
7
|
+
const ChessControlsContext = createContext(null)
|
|
8
|
+
|
|
9
|
+
const calculateCapturedPieces = (fen) => {
|
|
10
|
+
if (!fen) return { white: [], black: [] }
|
|
11
|
+
|
|
12
|
+
const startingPieces = {
|
|
13
|
+
p: 8, r: 2, n: 2, b: 2, q: 1, k: 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const currentPieces = { w: { p: 0, r: 0, n: 0, b: 0, q: 0, k: 0 }, b: { p: 0, r: 0, n: 0, b: 0, q: 0, k: 0 } }
|
|
17
|
+
|
|
18
|
+
// Parse FEN board portion
|
|
19
|
+
const boardPortion = fen.split(' ')[0]
|
|
20
|
+
for (const char of boardPortion) {
|
|
21
|
+
if (char === '/') continue
|
|
22
|
+
if (!isNaN(char)) continue
|
|
23
|
+
|
|
24
|
+
const color = char === char.toLowerCase() ? 'b' : 'w'
|
|
25
|
+
const piece = char.toLowerCase()
|
|
26
|
+
if (currentPieces[color][piece] !== undefined) {
|
|
27
|
+
currentPieces[color][piece]++
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Calculate captured pieces (what's missing from starting position)
|
|
32
|
+
const captured = { white: [], black: [] }
|
|
33
|
+
|
|
34
|
+
// Black captured white pieces (white pieces missing)
|
|
35
|
+
for (const [piece, count] of Object.entries(startingPieces)) {
|
|
36
|
+
const missing = count - currentPieces.w[piece]
|
|
37
|
+
for (let i = 0; i < missing; i++) {
|
|
38
|
+
captured.black.push(piece)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// White captured black pieces (black pieces missing)
|
|
43
|
+
for (const [piece, count] of Object.entries(startingPieces)) {
|
|
44
|
+
const missing = count - currentPieces.b[piece]
|
|
45
|
+
for (let i = 0; i < missing; i++) {
|
|
46
|
+
captured.white.push(piece)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return captured
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const createSnapshotsFromPgn = (pgn) => {
|
|
54
|
+
const bootstrap = new Chess()
|
|
55
|
+
const positions = [
|
|
56
|
+
{
|
|
57
|
+
fen: bootstrap.fen(),
|
|
58
|
+
move: null,
|
|
59
|
+
ply: 0
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
if (!pgn) {
|
|
64
|
+
return positions
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const mainline = buildMoveTree(pgn)
|
|
68
|
+
if (!mainline.length) {
|
|
69
|
+
console.warn('[ChessControlsContext] Failed to parse PGN, reverting to start position.')
|
|
70
|
+
return positions
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const replay = new Chess()
|
|
74
|
+
mainline.forEach((node, index) => {
|
|
75
|
+
const move = replay.move(node.san, { sloppy: true })
|
|
76
|
+
if (!move) {
|
|
77
|
+
console.warn('[ChessControlsContext] Invalid SAN in PGN, stopping replay.', node.san)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
positions.push({
|
|
81
|
+
fen: replay.fen(),
|
|
82
|
+
move: {
|
|
83
|
+
san: move.san,
|
|
84
|
+
color: move.color,
|
|
85
|
+
moveNumber: Math.ceil((index + 1) / 2)
|
|
86
|
+
},
|
|
87
|
+
ply: index + 1
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return positions
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const ChessControlsProvider = ({ children, externalGame = null, chessData }) => {
|
|
95
|
+
const getSampleGames = chessData?.getSampleGames ?? (() => [])
|
|
96
|
+
const defaultGames = useMemo(
|
|
97
|
+
() => getSampleGames().filter((game) => Boolean(game?.pgn)),
|
|
98
|
+
[getSampleGames]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const gamesWithExternal = useMemo(() => {
|
|
102
|
+
if (!externalGame?.pgn) {
|
|
103
|
+
return defaultGames
|
|
104
|
+
}
|
|
105
|
+
const remaining = defaultGames.filter((game) => game.id !== externalGame.id)
|
|
106
|
+
return [externalGame, ...remaining]
|
|
107
|
+
}, [defaultGames, externalGame])
|
|
108
|
+
|
|
109
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
110
|
+
const filteredGames = useMemo(() => {
|
|
111
|
+
const query = searchQuery.trim().toLowerCase()
|
|
112
|
+
if (!query) {
|
|
113
|
+
return gamesWithExternal
|
|
114
|
+
}
|
|
115
|
+
return gamesWithExternal.filter((game) => {
|
|
116
|
+
const haystack = [
|
|
117
|
+
game.player?.username,
|
|
118
|
+
game.opponent?.username,
|
|
119
|
+
game.timeClass,
|
|
120
|
+
game.timeControl,
|
|
121
|
+
game.opening?.name
|
|
122
|
+
]
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.join(' ')
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
return haystack.includes(query)
|
|
127
|
+
})
|
|
128
|
+
}, [gamesWithExternal, searchQuery])
|
|
129
|
+
|
|
130
|
+
const [selectedGameId, setSelectedGameId] = useState(() => filteredGames[0]?.id ?? null)
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
setSelectedGameId((prev) => {
|
|
134
|
+
if (prev && filteredGames.some((game) => game.id === prev)) {
|
|
135
|
+
return prev
|
|
136
|
+
}
|
|
137
|
+
return filteredGames[0]?.id ?? null
|
|
138
|
+
})
|
|
139
|
+
}, [filteredGames])
|
|
140
|
+
|
|
141
|
+
const selectedGame = useMemo(() => {
|
|
142
|
+
if (!selectedGameId) {
|
|
143
|
+
return filteredGames[0] ?? null
|
|
144
|
+
}
|
|
145
|
+
return filteredGames.find((game) => game.id === selectedGameId) ?? filteredGames[0] ?? null
|
|
146
|
+
}, [filteredGames, selectedGameId])
|
|
147
|
+
|
|
148
|
+
const [snapshots, setSnapshots] = useState(() =>
|
|
149
|
+
createSnapshotsFromPgn(selectedGame?.pgn ?? null)
|
|
150
|
+
)
|
|
151
|
+
const [moveTree, setMoveTree] = useState(() => buildMoveTree(selectedGame?.pgn ?? null))
|
|
152
|
+
const [moveIndex, setMoveIndex] = useState(0)
|
|
153
|
+
const [isPlaying, setIsPlaying] = useState(false)
|
|
154
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
155
|
+
const [orientation, setOrientation] = useState(
|
|
156
|
+
selectedGame?.playerColor === 'black' ? 'black' : 'white'
|
|
157
|
+
)
|
|
158
|
+
const [isEditMode, setIsEditMode] = useState(false)
|
|
159
|
+
const [userVariations, setUserVariations] = useState([])
|
|
160
|
+
const [pieceSet, setPieceSet] = useState('default')
|
|
161
|
+
const [boardTheme, setBoardTheme] = useState('green-white') // Current default
|
|
162
|
+
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
setOrientation(selectedGame?.playerColor === 'black' ? 'black' : 'white')
|
|
165
|
+
}, [selectedGame?.playerColor])
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
setIsLoading(true)
|
|
169
|
+
const nextSnapshots = createSnapshotsFromPgn(selectedGame?.pgn ?? null)
|
|
170
|
+
const nextMoveTree = buildMoveTree(selectedGame?.pgn ?? null)
|
|
171
|
+
setSnapshots(nextSnapshots)
|
|
172
|
+
setMoveTree(nextMoveTree)
|
|
173
|
+
setMoveIndex(0)
|
|
174
|
+
setIsPlaying(false)
|
|
175
|
+
setIsLoading(false)
|
|
176
|
+
}, [selectedGame?.pgn])
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (!isPlaying) return
|
|
180
|
+
if (moveIndex >= snapshots.length - 1) {
|
|
181
|
+
setIsPlaying(false)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const timer = setTimeout(() => {
|
|
185
|
+
setMoveIndex((index) => Math.min(index + 1, snapshots.length - 1))
|
|
186
|
+
}, 900)
|
|
187
|
+
|
|
188
|
+
return () => clearTimeout(timer)
|
|
189
|
+
}, [isPlaying, moveIndex, snapshots.length])
|
|
190
|
+
|
|
191
|
+
const goToStart = useCallback(() => setMoveIndex(0), [])
|
|
192
|
+
const goToEnd = useCallback(() => setMoveIndex(snapshots.length - 1), [snapshots.length])
|
|
193
|
+
const stepBackward = useCallback(
|
|
194
|
+
() => setMoveIndex((index) => Math.max(index - 1, 0)),
|
|
195
|
+
[]
|
|
196
|
+
)
|
|
197
|
+
const stepForward = useCallback(
|
|
198
|
+
() => setMoveIndex((index) => Math.min(index + 1, snapshots.length - 1)),
|
|
199
|
+
[snapshots.length]
|
|
200
|
+
)
|
|
201
|
+
const togglePlayback = useCallback(() => {
|
|
202
|
+
if (moveIndex >= snapshots.length - 1) {
|
|
203
|
+
setMoveIndex(0)
|
|
204
|
+
}
|
|
205
|
+
setIsPlaying((value) => !value)
|
|
206
|
+
}, [moveIndex, snapshots.length])
|
|
207
|
+
|
|
208
|
+
const toggleOrientation = useCallback(
|
|
209
|
+
() => setOrientation((value) => (value === 'white' ? 'black' : 'white')),
|
|
210
|
+
[]
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const toggleEditMode = useCallback(() => {
|
|
214
|
+
setIsEditMode((value) => !value)
|
|
215
|
+
}, [])
|
|
216
|
+
|
|
217
|
+
const handleSelectGame = useCallback((gameId) => {
|
|
218
|
+
setSelectedGameId(gameId || null)
|
|
219
|
+
}, [])
|
|
220
|
+
|
|
221
|
+
const loadEmptyPosition = useCallback(() => {
|
|
222
|
+
const emptyBoard = new Chess()
|
|
223
|
+
emptyBoard.clear()
|
|
224
|
+
setSnapshots([
|
|
225
|
+
{
|
|
226
|
+
fen: emptyBoard.fen(),
|
|
227
|
+
move: null,
|
|
228
|
+
ply: 0
|
|
229
|
+
}
|
|
230
|
+
])
|
|
231
|
+
setMoveTree([])
|
|
232
|
+
setMoveIndex(0)
|
|
233
|
+
setIsPlaying(false)
|
|
234
|
+
setSelectedGameId(null)
|
|
235
|
+
setUserVariations([])
|
|
236
|
+
}, [])
|
|
237
|
+
|
|
238
|
+
const addUserVariation = useCallback((label, sanSequence = []) => {
|
|
239
|
+
if (!sanSequence.length) return
|
|
240
|
+
const sanitized = sanSequence
|
|
241
|
+
.map((san) => san.trim())
|
|
242
|
+
.filter(Boolean)
|
|
243
|
+
.map((san, index) => ({
|
|
244
|
+
san,
|
|
245
|
+
ply: moveIndex + index + 1
|
|
246
|
+
}))
|
|
247
|
+
if (!sanitized.length) return
|
|
248
|
+
setUserVariations((prev) => [
|
|
249
|
+
...prev,
|
|
250
|
+
{
|
|
251
|
+
id: crypto.randomUUID(),
|
|
252
|
+
label: label || `Variation ${prev.length + 1}`,
|
|
253
|
+
moves: sanitized
|
|
254
|
+
}
|
|
255
|
+
])
|
|
256
|
+
}, [moveIndex])
|
|
257
|
+
|
|
258
|
+
const removeUserVariation = useCallback((id) => {
|
|
259
|
+
setUserVariations((prev) => prev.filter((variation) => variation.id !== id))
|
|
260
|
+
}, [])
|
|
261
|
+
|
|
262
|
+
const getPgnWithUserVariations = useCallback(() => {
|
|
263
|
+
const base = selectedGame?.pgn ?? ''
|
|
264
|
+
if (!userVariations.length) return base
|
|
265
|
+
const variationText = userVariations
|
|
266
|
+
.map((variation) => `; ${variation.label}: ${variation.moves.map((move) => move.san).join(' ')}`)
|
|
267
|
+
.join('\n')
|
|
268
|
+
return `${base}\n\n${variationText}`.trim()
|
|
269
|
+
}, [selectedGame?.pgn, userVariations])
|
|
270
|
+
|
|
271
|
+
const notationPairs = useMemo(() => {
|
|
272
|
+
const pairs = []
|
|
273
|
+
snapshots.forEach((snapshot) => {
|
|
274
|
+
if (!snapshot.move) return
|
|
275
|
+
const moveNumber = Math.ceil(snapshot.ply / 2)
|
|
276
|
+
const isWhiteMove = snapshot.move.color === 'w'
|
|
277
|
+
if (!pairs[moveNumber - 1]) {
|
|
278
|
+
pairs[moveNumber - 1] = {
|
|
279
|
+
moveNumber,
|
|
280
|
+
white: null,
|
|
281
|
+
black: null
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
pairs[moveNumber - 1][isWhiteMove ? 'white' : 'black'] = {
|
|
285
|
+
san: snapshot.move.san,
|
|
286
|
+
ply: snapshot.ply
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
return pairs || []
|
|
290
|
+
}, [snapshots])
|
|
291
|
+
|
|
292
|
+
const activeFen = snapshots[moveIndex]?.fen
|
|
293
|
+
const activeColor = useMemo(() => {
|
|
294
|
+
if (!activeFen) return 'white'
|
|
295
|
+
const parts = activeFen.split(' ')
|
|
296
|
+
return parts[1] === 'b' ? 'black' : 'white'
|
|
297
|
+
}, [activeFen])
|
|
298
|
+
|
|
299
|
+
const lastMove = useMemo(() => {
|
|
300
|
+
if (moveIndex === 0) return null
|
|
301
|
+
const snapshot = snapshots[moveIndex]
|
|
302
|
+
if (!snapshot?.move) return null
|
|
303
|
+
|
|
304
|
+
// Parse the move to extract from/to squares
|
|
305
|
+
// We need to use chess.js to get the move details
|
|
306
|
+
const chess = new Chess()
|
|
307
|
+
|
|
308
|
+
// Replay up to the previous position
|
|
309
|
+
for (let i = 1; i < moveIndex; i++) {
|
|
310
|
+
const move = snapshots[i]?.move
|
|
311
|
+
if (move) {
|
|
312
|
+
chess.move(move.san, { sloppy: true })
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Make the last move to get from/to
|
|
317
|
+
const moveObj = chess.move(snapshot.move.san, { sloppy: true })
|
|
318
|
+
|
|
319
|
+
return moveObj ? { from: moveObj.from, to: moveObj.to } : null
|
|
320
|
+
}, [snapshots, moveIndex])
|
|
321
|
+
|
|
322
|
+
const capturedPieces = useMemo(() => {
|
|
323
|
+
return calculateCapturedPieces(activeFen)
|
|
324
|
+
}, [activeFen])
|
|
325
|
+
|
|
326
|
+
const contextValue = {
|
|
327
|
+
searchQuery,
|
|
328
|
+
setSearchQuery,
|
|
329
|
+
filteredGames,
|
|
330
|
+
selectedGame,
|
|
331
|
+
selectedGameId,
|
|
332
|
+
setSelectedGameId: handleSelectGame,
|
|
333
|
+
snapshots,
|
|
334
|
+
moveIndex,
|
|
335
|
+
setMoveIndex,
|
|
336
|
+
notationPairs,
|
|
337
|
+
goToStart,
|
|
338
|
+
goToEnd,
|
|
339
|
+
stepBackward,
|
|
340
|
+
stepForward,
|
|
341
|
+
togglePlayback,
|
|
342
|
+
isPlaying,
|
|
343
|
+
isLoading,
|
|
344
|
+
orientation,
|
|
345
|
+
setOrientation,
|
|
346
|
+
toggleOrientation,
|
|
347
|
+
activeColor,
|
|
348
|
+
loadEmptyPosition,
|
|
349
|
+
userVariations,
|
|
350
|
+
addUserVariation,
|
|
351
|
+
removeUserVariation,
|
|
352
|
+
getPgnWithUserVariations,
|
|
353
|
+
moveTree,
|
|
354
|
+
isEditMode,
|
|
355
|
+
toggleEditMode,
|
|
356
|
+
lastMove,
|
|
357
|
+
capturedPieces,
|
|
358
|
+
pieceSet,
|
|
359
|
+
setPieceSet,
|
|
360
|
+
boardTheme,
|
|
361
|
+
setBoardTheme
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<ChessControlsContext.Provider value={contextValue}>
|
|
366
|
+
{children}
|
|
367
|
+
</ChessControlsContext.Provider>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export const useChessControls = () => {
|
|
372
|
+
const context = useContext(ChessControlsContext)
|
|
373
|
+
if (!context) {
|
|
374
|
+
throw new Error('useChessControls must be used within a ChessControlsProvider')
|
|
375
|
+
}
|
|
376
|
+
return context
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export { createSnapshotsFromPgn }
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Data is consumer-provided via the `chessData` prop (no bundled dataset).
|
|
2
|
+
// Required: chessData.getManifest(), chessData.getMonthlySummary().
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
|
|
5
|
+
const MetricCard = ({ icon, label, value, delta, trend = 'up' }) => {
|
|
6
|
+
return (
|
|
7
|
+
<div className="chess-hero-metric">
|
|
8
|
+
<div className="chess-hero-metric__header">
|
|
9
|
+
<div className="chess-hero-metric__icon">{icon}</div>
|
|
10
|
+
<button type="button" className="chess-hero-metric__menu">⋯</button>
|
|
11
|
+
</div>
|
|
12
|
+
<div className="chess-hero-metric__label">{label}</div>
|
|
13
|
+
<div className="chess-hero-metric__value">{value}</div>
|
|
14
|
+
<div className={`chess-hero-metric__delta chess-hero-metric__delta--${trend}`}>
|
|
15
|
+
{delta}
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ChessHero = ({ chessData }) => {
|
|
22
|
+
const getManifest = chessData?.getManifest ?? (() => ({}))
|
|
23
|
+
const getMonthlySummary = chessData?.getMonthlySummary ?? (() => [])
|
|
24
|
+
const manifest = useMemo(() => getManifest(), [getManifest])
|
|
25
|
+
const monthlySummary = useMemo(() => getMonthlySummary(), [getMonthlySummary])
|
|
26
|
+
|
|
27
|
+
const metrics = useMemo(() => {
|
|
28
|
+
const totals = monthlySummary.reduce(
|
|
29
|
+
(acc, month) => {
|
|
30
|
+
acc.win += month.results?.win ?? 0
|
|
31
|
+
acc.draw += month.results?.draw ?? 0
|
|
32
|
+
acc.loss += month.results?.loss ?? 0
|
|
33
|
+
return acc
|
|
34
|
+
},
|
|
35
|
+
{ win: 0, draw: 0, loss: 0 }
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const totalGames = manifest?.totalGames ?? 0
|
|
39
|
+
const winRate = totalGames > 0 ? (totals.win / totalGames) * 100 : 0
|
|
40
|
+
const ratedGames = manifest?.ratedGames ?? 0
|
|
41
|
+
const ratedPct = totalGames > 0 ? (ratedGames / totalGames) * 100 : 0
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
totalGames,
|
|
45
|
+
winRate,
|
|
46
|
+
ratedGames,
|
|
47
|
+
ratedPct,
|
|
48
|
+
wins: totals.win,
|
|
49
|
+
draws: totals.draw,
|
|
50
|
+
losses: totals.loss
|
|
51
|
+
}
|
|
52
|
+
}, [manifest, monthlySummary])
|
|
53
|
+
|
|
54
|
+
const monthlyData = useMemo(() => {
|
|
55
|
+
return monthlySummary.slice(-12).map((month) => {
|
|
56
|
+
const total = month.total ?? 0
|
|
57
|
+
const wins = month.results?.win ?? 0
|
|
58
|
+
const losses = month.results?.loss ?? 0
|
|
59
|
+
const winRate = total > 0 ? (wins / total) * 100 : 0
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
month: month.month,
|
|
63
|
+
total,
|
|
64
|
+
wins,
|
|
65
|
+
losses,
|
|
66
|
+
winRate,
|
|
67
|
+
netScore: wins - losses
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}, [monthlySummary])
|
|
71
|
+
|
|
72
|
+
const chartPath = useMemo(() => {
|
|
73
|
+
if (!monthlyData.length) return ''
|
|
74
|
+
|
|
75
|
+
const maxWinRate = Math.max(...monthlyData.map(d => d.winRate), 100)
|
|
76
|
+
const points = monthlyData.map((d, i) => {
|
|
77
|
+
const x = (i / Math.max(monthlyData.length - 1, 1)) * 100
|
|
78
|
+
const y = 100 - (d.winRate / maxWinRate) * 80
|
|
79
|
+
return { x, y, winRate: d.winRate }
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return points
|
|
83
|
+
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
|
|
84
|
+
.join(' ')
|
|
85
|
+
}, [monthlyData])
|
|
86
|
+
|
|
87
|
+
const volumePath = useMemo(() => {
|
|
88
|
+
if (!monthlyData.length) return ''
|
|
89
|
+
|
|
90
|
+
const maxTotal = Math.max(...monthlyData.map(d => d.total), 1)
|
|
91
|
+
const points = monthlyData.map((d, i) => {
|
|
92
|
+
const x = (i / Math.max(monthlyData.length - 1, 1)) * 100
|
|
93
|
+
const y = 100 - (d.total / maxTotal) * 80
|
|
94
|
+
return { x, y }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return points
|
|
98
|
+
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
|
|
99
|
+
.join(' ')
|
|
100
|
+
}, [monthlyData])
|
|
101
|
+
|
|
102
|
+
const timeControlBars = useMemo(() => {
|
|
103
|
+
const distribution = manifest?.timeClassDistribution ?? []
|
|
104
|
+
const maxCount = Math.max(...distribution.map(d => d.count), 1)
|
|
105
|
+
|
|
106
|
+
return distribution.slice(0, 6).map((entry) => ({
|
|
107
|
+
label: entry.key,
|
|
108
|
+
count: entry.count,
|
|
109
|
+
height: (entry.count / maxCount) * 100
|
|
110
|
+
}))
|
|
111
|
+
}, [manifest])
|
|
112
|
+
|
|
113
|
+
const formatMonth = (monthKey) => {
|
|
114
|
+
if (!monthKey) return ''
|
|
115
|
+
const [year, month] = monthKey.split('-').map(Number)
|
|
116
|
+
const date = new Date(year, (month ?? 1) - 1)
|
|
117
|
+
return date.toLocaleString('default', { month: 'short' })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="chess-hero">
|
|
122
|
+
<div className="chess-hero__metrics">
|
|
123
|
+
<MetricCard
|
|
124
|
+
icon="♔"
|
|
125
|
+
label="Total games"
|
|
126
|
+
value={metrics.totalGames.toLocaleString()}
|
|
127
|
+
delta="106 months"
|
|
128
|
+
trend="neutral"
|
|
129
|
+
/>
|
|
130
|
+
<MetricCard
|
|
131
|
+
icon="⚡"
|
|
132
|
+
label="Win rate"
|
|
133
|
+
value={`${metrics.winRate.toFixed(1)}%`}
|
|
134
|
+
delta={`${metrics.wins} wins`}
|
|
135
|
+
trend="up"
|
|
136
|
+
/>
|
|
137
|
+
<MetricCard
|
|
138
|
+
icon="⭐"
|
|
139
|
+
label="Rated games"
|
|
140
|
+
value={metrics.ratedGames.toLocaleString()}
|
|
141
|
+
delta={`${metrics.ratedPct.toFixed(1)}%`}
|
|
142
|
+
trend="up"
|
|
143
|
+
/>
|
|
144
|
+
<MetricCard
|
|
145
|
+
icon="🎯"
|
|
146
|
+
label="Net score"
|
|
147
|
+
value={`+${metrics.wins - metrics.losses}`}
|
|
148
|
+
delta={`${metrics.losses} losses`}
|
|
149
|
+
trend={metrics.wins > metrics.losses ? 'up' : 'down'}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="chess-hero__card">
|
|
154
|
+
<div className="chess-hero__main">
|
|
155
|
+
<div className="chess-hero__header">
|
|
156
|
+
<div>
|
|
157
|
+
<h3 className="chess-hero__title">Performance trend</h3>
|
|
158
|
+
<div className="chess-hero__subtitle">
|
|
159
|
+
<span className="chess-hero__value">
|
|
160
|
+
{metrics.winRate.toFixed(1)}%
|
|
161
|
+
</span>
|
|
162
|
+
<span className="chess-hero__delta">
|
|
163
|
+
{metrics.winRate > 50 ? '+' : ''}{(metrics.winRate - 50).toFixed(1)}%
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<div className="chess-hero__legend">
|
|
168
|
+
<span className="chess-hero__legend-item">
|
|
169
|
+
<span className="chess-hero__legend-dot chess-hero__legend-dot--primary" />
|
|
170
|
+
Win rate
|
|
171
|
+
</span>
|
|
172
|
+
<span className="chess-hero__legend-item">
|
|
173
|
+
<span className="chess-hero__legend-dot chess-hero__legend-dot--secondary" />
|
|
174
|
+
Volume
|
|
175
|
+
</span>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div className="chess-hero__chart">
|
|
180
|
+
<div className="chess-hero__chart-grid" />
|
|
181
|
+
<svg
|
|
182
|
+
className="chess-hero__chart-svg"
|
|
183
|
+
viewBox="0 0 100 100"
|
|
184
|
+
preserveAspectRatio="none"
|
|
185
|
+
>
|
|
186
|
+
<defs>
|
|
187
|
+
<linearGradient id="winRateGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
188
|
+
<stop offset="0%" stopColor="rgba(245, 210, 69, 0.3)" />
|
|
189
|
+
<stop offset="100%" stopColor="rgba(245, 210, 69, 0)" />
|
|
190
|
+
</linearGradient>
|
|
191
|
+
</defs>
|
|
192
|
+
|
|
193
|
+
{/* Volume line (yellow) */}
|
|
194
|
+
{volumePath && (
|
|
195
|
+
<path
|
|
196
|
+
d={volumePath}
|
|
197
|
+
fill="none"
|
|
198
|
+
stroke="rgba(245, 210, 69, 0.7)"
|
|
199
|
+
strokeWidth="2"
|
|
200
|
+
strokeLinejoin="round"
|
|
201
|
+
strokeLinecap="round"
|
|
202
|
+
/>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{/* Win rate line and fill (orange) */}
|
|
206
|
+
{chartPath && (
|
|
207
|
+
<>
|
|
208
|
+
<path
|
|
209
|
+
d={`${chartPath} L 100 100 L 0 100 Z`}
|
|
210
|
+
fill="url(#winRateGradient)"
|
|
211
|
+
/>
|
|
212
|
+
<path
|
|
213
|
+
d={chartPath}
|
|
214
|
+
fill="none"
|
|
215
|
+
stroke="rgba(255, 140, 60, 0.9)"
|
|
216
|
+
strokeWidth="2.5"
|
|
217
|
+
strokeLinejoin="round"
|
|
218
|
+
strokeLinecap="round"
|
|
219
|
+
/>
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
</svg>
|
|
223
|
+
|
|
224
|
+
<div className="chess-hero__chart-axis">
|
|
225
|
+
{monthlyData.map((d, i) => (
|
|
226
|
+
<span key={d.month} className="chess-hero__chart-label">
|
|
227
|
+
{formatMonth(d.month)}
|
|
228
|
+
</span>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div className="chess-hero__side">
|
|
235
|
+
<div className="chess-hero__side-metric">
|
|
236
|
+
<div className="chess-hero__side-icon">📊</div>
|
|
237
|
+
<div className="chess-hero__side-label">Games by time</div>
|
|
238
|
+
<div className="chess-hero__side-value">
|
|
239
|
+
{timeControlBars[0]?.label ?? '—'}
|
|
240
|
+
</div>
|
|
241
|
+
<div className="chess-hero__side-delta">
|
|
242
|
+
{timeControlBars[0]?.count ?? 0} games
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div className="chess-hero__bar-chart">
|
|
247
|
+
{timeControlBars.slice(0, 5).map((bar, i) => (
|
|
248
|
+
<div key={bar.label} className="chess-hero__bar">
|
|
249
|
+
<div
|
|
250
|
+
className="chess-hero__bar-fill"
|
|
251
|
+
style={{ height: `${bar.height}%` }}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div className="chess-hero__side-footer">
|
|
258
|
+
<span className="chess-hero__side-period">Last 12 months</span>
|
|
259
|
+
<button type="button" className="chess-hero__side-link">View report</button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<div className="chess-hero__side-metric">
|
|
263
|
+
<div className="chess-hero__side-icon">🔥</div>
|
|
264
|
+
<div className="chess-hero__side-label">Monthly avg</div>
|
|
265
|
+
<div className="chess-hero__side-value">
|
|
266
|
+
{monthlyData.length > 0
|
|
267
|
+
? Math.round(
|
|
268
|
+
monthlyData.reduce((sum, d) => sum + d.total, 0) / monthlyData.length
|
|
269
|
+
)
|
|
270
|
+
: 0}
|
|
271
|
+
</div>
|
|
272
|
+
<div className="chess-hero__side-delta">games/month</div>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div className="chess-hero__line-chart">
|
|
276
|
+
<svg viewBox="0 0 100 50" preserveAspectRatio="none">
|
|
277
|
+
{volumePath && (
|
|
278
|
+
<path
|
|
279
|
+
d={volumePath.replace(/100/g, '50')}
|
|
280
|
+
fill="none"
|
|
281
|
+
stroke="rgba(255, 140, 60, 0.8)"
|
|
282
|
+
strokeWidth="2"
|
|
283
|
+
strokeLinejoin="round"
|
|
284
|
+
strokeLinecap="round"
|
|
285
|
+
/>
|
|
286
|
+
)}
|
|
287
|
+
</svg>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<div className="chess-hero__side-footer">
|
|
291
|
+
<span className="chess-hero__side-status">
|
|
292
|
+
<span className="chess-hero__side-status-dot" />
|
|
293
|
+
Live data
|
|
294
|
+
</span>
|
|
295
|
+
<button type="button" className="chess-hero__side-link">View report</button>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export default ChessHero
|