@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.
Files changed (91) hide show
  1. package/package.json +4 -2
  2. package/src/chess/ChessPiece.jsx +191 -0
  3. package/src/chess/apparatus/AlternativeControlsMock.jsx +460 -0
  4. package/src/chess/apparatus/ChessAnalysisLayout.jsx +24 -0
  5. package/src/chess/apparatus/ChessBoard.jsx +210 -0
  6. package/src/chess/apparatus/ChessBoardFullscreen.jsx +49 -0
  7. package/src/chess/apparatus/ChessBoardWithControls.jsx +41 -0
  8. package/src/chess/apparatus/ChessBoardWithSidebar.jsx +122 -0
  9. package/src/chess/apparatus/ChessSidebar.jsx +131 -0
  10. package/src/chess/apparatus/GameArchiveTable.jsx +493 -0
  11. package/src/chess/apparatus/GameSelector.jsx +47 -0
  12. package/src/chess/apparatus/NotationPanel.jsx +70 -0
  13. package/src/chess/apparatus/PlaybackControls.jsx +52 -0
  14. package/src/chess/apparatus/VariationTree.jsx +137 -0
  15. package/src/chess/assets/chess-extra-set/set-1/bishop-1.svg +6 -0
  16. package/src/chess/assets/chess-extra-set/set-1/bishop.svg +6 -0
  17. package/src/chess/assets/chess-extra-set/set-1/king-1.svg +9 -0
  18. package/src/chess/assets/chess-extra-set/set-1/king.svg +7 -0
  19. package/src/chess/assets/chess-extra-set/set-1/knight-1.svg +8 -0
  20. package/src/chess/assets/chess-extra-set/set-1/knight.svg +7 -0
  21. package/src/chess/assets/chess-extra-set/set-1/pawn-1.svg +3 -0
  22. package/src/chess/assets/chess-extra-set/set-1/pawn.svg +3 -0
  23. package/src/chess/assets/chess-extra-set/set-1/queen-1.svg +11 -0
  24. package/src/chess/assets/chess-extra-set/set-1/queen.svg +6 -0
  25. package/src/chess/assets/chess-extra-set/set-1/rook-1.svg +7 -0
  26. package/src/chess/assets/chess-extra-set/set-1/rook.svg +11 -0
  27. package/src/chess/assets/chess-extra-set/set-2/bishop-1.svg +4 -0
  28. package/src/chess/assets/chess-extra-set/set-2/bishop.svg +4 -0
  29. package/src/chess/assets/chess-extra-set/set-2/king-1.svg +4 -0
  30. package/src/chess/assets/chess-extra-set/set-2/king.svg +4 -0
  31. package/src/chess/assets/chess-extra-set/set-2/knight-1.svg +5 -0
  32. package/src/chess/assets/chess-extra-set/set-2/knight.svg +5 -0
  33. package/src/chess/assets/chess-extra-set/set-2/pawn-1.svg +4 -0
  34. package/src/chess/assets/chess-extra-set/set-2/pawn.svg +4 -0
  35. package/src/chess/assets/chess-extra-set/set-2/queen-1.svg +4 -0
  36. package/src/chess/assets/chess-extra-set/set-2/queen.svg +4 -0
  37. package/src/chess/assets/chess-extra-set/set-2/rook-1.svg +5 -0
  38. package/src/chess/assets/chess-extra-set/set-2/rook.svg +5 -0
  39. package/src/chess/assets/chess-extra-set/set-3/bishop-1.svg +10 -0
  40. package/src/chess/assets/chess-extra-set/set-3/bishop.svg +3 -0
  41. package/src/chess/assets/chess-extra-set/set-3/king-1.svg +12 -0
  42. package/src/chess/assets/chess-extra-set/set-3/king.svg +3 -0
  43. package/src/chess/assets/chess-extra-set/set-3/knight-1.svg +3 -0
  44. package/src/chess/assets/chess-extra-set/set-3/knight.svg +10 -0
  45. package/src/chess/assets/chess-extra-set/set-3/pawn-1.svg +10 -0
  46. package/src/chess/assets/chess-extra-set/set-3/pawn.svg +10 -0
  47. package/src/chess/assets/chess-extra-set/set-3/queen-1.svg +10 -0
  48. package/src/chess/assets/chess-extra-set/set-3/queen.svg +3 -0
  49. package/src/chess/assets/chess-extra-set/set-3/rook-1.svg +3 -0
  50. package/src/chess/assets/chess-extra-set/set-3/rook.svg +11 -0
  51. package/src/chess/assets/chess-vector-set/ABoard.svg +164 -0
  52. package/src/chess/assets/chess-vector-set/BBiskup.svg +12 -0
  53. package/src/chess/assets/chess-vector-set/BKing.svg +15 -0
  54. package/src/chess/assets/chess-vector-set/BKnight.svg +13 -0
  55. package/src/chess/assets/chess-vector-set/BPawn.svg +7 -0
  56. package/src/chess/assets/chess-vector-set/BQueen.svg +6 -0
  57. package/src/chess/assets/chess-vector-set/BRook.svg +12 -0
  58. package/src/chess/assets/chess-vector-set/WBiskup.svg +14 -0
  59. package/src/chess/assets/chess-vector-set/WKing.svg +13 -0
  60. package/src/chess/assets/chess-vector-set/WKnight.svg +10 -0
  61. package/src/chess/assets/chess-vector-set/WPawn.svg +8 -0
  62. package/src/chess/assets/chess-vector-set/WQueen.svg +17 -0
  63. package/src/chess/assets/chess-vector-set/WRook.svg +13 -0
  64. package/src/chess/context/ChessControlsContext.jsx +379 -0
  65. package/src/chess/dashboards/ChessHero.jsx +303 -0
  66. package/src/chess/index.js +32 -0
  67. package/src/chess/utils/parsePgnTree.js +97 -0
  68. package/src/dashboards/cards/DashAlertCard.jsx +44 -0
  69. package/src/dashboards/cards/DashChartCard.jsx +44 -0
  70. package/src/dashboards/cards/DashFeaturedCard.jsx +42 -0
  71. package/src/dashboards/cards/DashListCard.jsx +87 -0
  72. package/src/dashboards/cards/DashMetricCard.jsx +59 -0
  73. package/src/dashboards/cards/DashSlotCard.jsx +45 -0
  74. package/src/dashboards/cards/DashStackedBarCard.jsx +70 -0
  75. package/src/dashboards/cards/DashTableCard.jsx +31 -0
  76. package/src/dashboards/cards/_shared/CardHeader.jsx +15 -0
  77. package/src/dashboards/cards/_shared/LegendRow.jsx +25 -0
  78. package/src/dashboards/charts/Candlestick.jsx +79 -0
  79. package/src/dashboards/charts/DonutChart.jsx +87 -0
  80. package/src/dashboards/charts/Heatmap.jsx +79 -0
  81. package/src/dashboards/charts/Histogram.jsx +56 -0
  82. package/src/dashboards/charts/LineChart.jsx +157 -0
  83. package/src/dashboards/charts/ScatterPlot.jsx +93 -0
  84. package/src/dashboards/charts/Sparkline.jsx +63 -0
  85. package/src/dashboards/index.js +28 -0
  86. package/src/dashboards/layout/DashboardGrid.jsx +30 -0
  87. package/src/dashboards/layout/GridCard.jsx +44 -0
  88. package/src/dashboards/shared/DashTooltip.jsx +6 -0
  89. package/src/dashboards/shared/formatMetric.js +26 -0
  90. package/src/dashboards/shared/useChartTooltip.js +25 -0
  91. 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