react-native-chess-kit 0.5.2 → 0.5.3

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 (106) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +187 -168
  3. package/lib/commonjs/board-annotations.js +8 -8
  4. package/lib/commonjs/board-annotations.js.map +1 -1
  5. package/lib/commonjs/board-arrows.js.map +1 -1
  6. package/lib/commonjs/board-background.js +5 -5
  7. package/lib/commonjs/board-background.js.map +1 -1
  8. package/lib/commonjs/board-coordinates.js +8 -8
  9. package/lib/commonjs/board-coordinates.js.map +1 -1
  10. package/lib/commonjs/board-drag-ghost.js +10 -10
  11. package/lib/commonjs/board-drag-ghost.js.map +1 -1
  12. package/lib/commonjs/board-highlights.js +15 -15
  13. package/lib/commonjs/board-highlights.js.map +1 -1
  14. package/lib/commonjs/board-legal-dots.js +5 -5
  15. package/lib/commonjs/board-legal-dots.js.map +1 -1
  16. package/lib/commonjs/board-piece.js +25 -25
  17. package/lib/commonjs/board-piece.js.map +1 -1
  18. package/lib/commonjs/board-pieces.js +6 -6
  19. package/lib/commonjs/board-pieces.js.map +1 -1
  20. package/lib/commonjs/board.js +68 -63
  21. package/lib/commonjs/board.js.map +1 -1
  22. package/lib/commonjs/constants.js.map +1 -1
  23. package/lib/commonjs/index.js.map +1 -1
  24. package/lib/commonjs/pieces/default-pieces.js.map +1 -1
  25. package/lib/commonjs/pieces/index.js.map +1 -1
  26. package/lib/commonjs/promotion-picker.js +9 -9
  27. package/lib/commonjs/promotion-picker.js.map +1 -1
  28. package/lib/commonjs/static-board.js +7 -7
  29. package/lib/commonjs/static-board.js.map +1 -1
  30. package/lib/commonjs/themes.js.map +1 -1
  31. package/lib/commonjs/types.js.map +1 -1
  32. package/lib/commonjs/use-board-gesture.js +26 -26
  33. package/lib/commonjs/use-board-gesture.js.map +1 -1
  34. package/lib/commonjs/use-board-pieces.js +15 -15
  35. package/lib/commonjs/use-board-pieces.js.map +1 -1
  36. package/lib/commonjs/use-board-state.js +21 -12
  37. package/lib/commonjs/use-board-state.js.map +1 -1
  38. package/lib/commonjs/use-premove.js +12 -12
  39. package/lib/commonjs/use-premove.js.map +1 -1
  40. package/lib/module/board-annotations.js +8 -8
  41. package/lib/module/board-annotations.js.map +1 -1
  42. package/lib/module/board-arrows.js.map +1 -1
  43. package/lib/module/board-background.js +5 -5
  44. package/lib/module/board-background.js.map +1 -1
  45. package/lib/module/board-coordinates.js +8 -8
  46. package/lib/module/board-coordinates.js.map +1 -1
  47. package/lib/module/board-drag-ghost.js +10 -10
  48. package/lib/module/board-drag-ghost.js.map +1 -1
  49. package/lib/module/board-highlights.js +15 -15
  50. package/lib/module/board-highlights.js.map +1 -1
  51. package/lib/module/board-legal-dots.js +5 -5
  52. package/lib/module/board-legal-dots.js.map +1 -1
  53. package/lib/module/board-piece.js +25 -25
  54. package/lib/module/board-piece.js.map +1 -1
  55. package/lib/module/board-pieces.js +6 -6
  56. package/lib/module/board-pieces.js.map +1 -1
  57. package/lib/module/board.js +68 -63
  58. package/lib/module/board.js.map +1 -1
  59. package/lib/module/constants.js.map +1 -1
  60. package/lib/module/index.js.map +1 -1
  61. package/lib/module/pieces/default-pieces.js.map +1 -1
  62. package/lib/module/pieces/index.js.map +1 -1
  63. package/lib/module/promotion-picker.js +9 -9
  64. package/lib/module/promotion-picker.js.map +1 -1
  65. package/lib/module/static-board.js +7 -7
  66. package/lib/module/static-board.js.map +1 -1
  67. package/lib/module/themes.js.map +1 -1
  68. package/lib/module/types.js.map +1 -1
  69. package/lib/module/use-board-gesture.js +26 -26
  70. package/lib/module/use-board-gesture.js.map +1 -1
  71. package/lib/module/use-board-pieces.js +15 -15
  72. package/lib/module/use-board-pieces.js.map +1 -1
  73. package/lib/module/use-board-state.js +21 -12
  74. package/lib/module/use-board-state.js.map +1 -1
  75. package/lib/module/use-premove.js +12 -12
  76. package/lib/module/use-premove.js.map +1 -1
  77. package/lib/typescript/board-coordinates.d.ts.map +1 -1
  78. package/lib/typescript/board.d.ts.map +1 -1
  79. package/lib/typescript/promotion-picker.d.ts.map +1 -1
  80. package/lib/typescript/static-board.d.ts.map +1 -1
  81. package/lib/typescript/types.d.ts +12 -2
  82. package/lib/typescript/types.d.ts.map +1 -1
  83. package/lib/typescript/use-board-gesture.d.ts.map +1 -1
  84. package/lib/typescript/use-board-state.d.ts.map +1 -1
  85. package/package.json +23 -3
  86. package/src/board-annotations.tsx +147 -147
  87. package/src/board-background.tsx +46 -46
  88. package/src/board-coordinates.tsx +192 -192
  89. package/src/board-drag-ghost.tsx +132 -132
  90. package/src/board-highlights.tsx +226 -226
  91. package/src/board-legal-dots.tsx +73 -73
  92. package/src/board-piece.tsx +160 -160
  93. package/src/board-pieces.tsx +63 -63
  94. package/src/board.tsx +688 -685
  95. package/src/constants.ts +103 -103
  96. package/src/index.ts +101 -101
  97. package/src/pieces/default-pieces.tsx +383 -383
  98. package/src/pieces/index.ts +1 -1
  99. package/src/promotion-picker.tsx +147 -147
  100. package/src/static-board.tsx +186 -187
  101. package/src/themes.ts +129 -129
  102. package/src/types.ts +394 -373
  103. package/src/use-board-gesture.ts +459 -429
  104. package/src/use-board-pieces.ts +158 -158
  105. package/src/use-board-state.ts +120 -111
  106. package/src/use-premove.ts +59 -59
package/src/board.tsx CHANGED
@@ -1,685 +1,688 @@
1
- import React, {
2
- forwardRef,
3
- useState,
4
- useCallback,
5
- useImperativeHandle,
6
- useEffect,
7
- useMemo,
8
- useRef,
9
- } from 'react';
10
- import { View, type LayoutChangeEvent } from 'react-native';
11
- import { GestureDetector } from 'react-native-gesture-handler';
12
- import Animated, {
13
- useSharedValue,
14
- useAnimatedStyle,
15
- withTiming,
16
- FadeOut,
17
- } from 'react-native-reanimated';
18
-
19
- import type {
20
- BoardRef,
21
- BoardProps,
22
- LegalMoveTarget,
23
- HighlightData,
24
- PieceCode,
25
- PromotionPiece,
26
- } from './types';
27
- import {
28
- DEFAULT_BOARD_COLORS,
29
- DEFAULT_MOVE_DURATION,
30
- DEFAULT_LAST_MOVE_COLOR,
31
- DEFAULT_CHECK_COLOR,
32
- DEFAULT_SELECTED_COLOR,
33
- DEFAULT_PREMOVE_COLOR,
34
- DEFAULT_DRAG_TARGET_COLOR,
35
- CAPTURE_FADE_DURATION,
36
- COORDINATE_GUTTER_SCALE,
37
- } from './constants';
38
- import { DefaultPieceSet } from './pieces';
39
- import { useBoardPieces } from './use-board-pieces';
40
- import { useBoardState } from './use-board-state';
41
- import { useBoardGesture } from './use-board-gesture';
42
- import { usePremove } from './use-premove';
43
- import { BoardBackground } from './board-background';
44
- import { BoardCoordinates } from './board-coordinates';
45
- import { BoardHighlights, DragTargetHighlight } from './board-highlights';
46
- import { BoardLegalDots } from './board-legal-dots';
47
- import { BoardPiecesLayer } from './board-pieces';
48
- import { BoardDragGhost } from './board-drag-ghost';
49
- import { BoardArrows } from './board-arrows';
50
- import { BoardAnnotations } from './board-annotations';
51
- import { PromotionPicker } from './promotion-picker';
52
-
53
- // ---------------------------------------------------------------------------
54
- // Check detection helper
55
- // ---------------------------------------------------------------------------
56
-
57
- /**
58
- * Find the king square for the side currently in check.
59
- * Returns null if not in check.
60
- */
61
- function detectCheckSquare(
62
- fen: string,
63
- isInCheck: () => boolean,
64
- getTurn: () => 'w' | 'b',
65
- ): string | null {
66
- if (!isInCheck()) return null;
67
-
68
- const turn = getTurn();
69
- const kingChar = turn === 'w' ? 'K' : 'k';
70
- const placement = fen.split(' ')[0];
71
- const ranks = placement.split('/');
72
-
73
- for (let rankIdx = 0; rankIdx < ranks.length; rankIdx++) {
74
- const rank = ranks[rankIdx]!;
75
- let fileIdx = 0;
76
- for (const char of rank) {
77
- if (char >= '1' && char <= '8') {
78
- fileIdx += parseInt(char, 10);
79
- continue;
80
- }
81
- if (char === kingChar) {
82
- const files = 'abcdefgh';
83
- return `${files[fileIdx]}${8 - rankIdx}`;
84
- }
85
- fileIdx++;
86
- }
87
- }
88
-
89
- return null;
90
- }
91
-
92
- // ---------------------------------------------------------------------------
93
- // Board component
94
- // ---------------------------------------------------------------------------
95
-
96
- /**
97
- * High-performance custom chess board built on Reanimated + Gesture Handler.
98
- *
99
- * Architecture:
100
- * - 1 gesture handler (vs 32 in typical implementations)
101
- * - ~40 components mounted (vs ~281)
102
- * - ~75 native views (vs ~470)
103
- * - 0 React Context providers
104
- * - 0 re-renders during drag (pure worklet only 2 shared value writes per frame)
105
- *
106
- * v0.2.0 layer stack (10 layers):
107
- * 1. BoardBackground (64 squares)
108
- * 2. BoardCoordinates (a-h, 1-8)
109
- * 3. BoardHighlights (last move, check, selected, premove, custom, imperative)
110
- * 4. DragTargetHighlight (animated, worklet-driven)
111
- * 5. BoardLegalDots (legal move indicators)
112
- * 6. BoardPiecesLayer (all pieces)
113
- * 7. BoardArrows (SVG arrows + circles)
114
- * 8. BoardAnnotations (text badges)
115
- * 9. BoardDragGhost (floating piece)
116
- * 10. PromotionPicker (modal, conditional)
117
- */
118
- export const Board = forwardRef<BoardRef, BoardProps>(function Board(
119
- {
120
- fen,
121
- orientation,
122
-
123
- // Layout
124
- boardSize: boardSizeProp,
125
-
126
- // Interaction
127
- gestureEnabled = true,
128
- player = 'both',
129
- moveMethod = 'both',
130
- showLegalMoves = true,
131
- premovesEnabled = false,
132
-
133
- // Appearance
134
- colors,
135
- coordinatePosition: coordinatePositionProp,
136
- withLetters: withLettersProp,
137
- withNumbers: withNumbersProp,
138
- renderPiece,
139
- pieceSet,
140
-
141
- // Overlays
142
- lastMove,
143
- highlights,
144
- arrows,
145
- shapes,
146
- annotations,
147
- showDragTarget = true,
148
-
149
- // Overlay colors
150
- lastMoveColor = DEFAULT_LAST_MOVE_COLOR,
151
- checkHighlightColor = DEFAULT_CHECK_COLOR,
152
- selectedSquareColor = DEFAULT_SELECTED_COLOR,
153
- premoveColor = DEFAULT_PREMOVE_COLOR,
154
- dragTargetColor = DEFAULT_DRAG_TARGET_COLOR,
155
-
156
- // Animation
157
- moveDuration = DEFAULT_MOVE_DURATION,
158
- animationConfig,
159
- animateFlip = true,
160
- pieceExitAnimation,
161
-
162
- // Promotion
163
- onPromotion,
164
-
165
- // Callbacks
166
- onMove,
167
- onPieceClick,
168
- onSquareClick,
169
- onPieceDragBegin,
170
- onPieceDragEnd,
171
- onSquareLongPress,
172
- onPremove,
173
- onHaptic,
174
- },
175
- ref,
176
- ) {
177
- // --- Auto-sizing via onLayout when boardSize not provided ---
178
- const [measuredSize, setMeasuredSize] = useState(0);
179
- const handleLayout = useCallback((e: LayoutChangeEvent) => {
180
- const { width, height } = e.nativeEvent.layout;
181
- setMeasuredSize(Math.min(width, height));
182
- }, []);
183
-
184
- const outerSize = boardSizeProp ?? measuredSize;
185
- const boardColors = colors ?? DEFAULT_BOARD_COLORS;
186
-
187
- // Resolve coordinate position: new prop takes precedence over legacy booleans
188
- const coordinatePosition = coordinatePositionProp
189
- ?? (withLettersProp === false && withNumbersProp === false ? 'none' : 'inside');
190
- const isOutside = coordinatePosition === 'outside';
191
- const isCoordVisible = coordinatePosition !== 'none';
192
-
193
- // When outside: reserve gutter space; inner board = outer minus gutters
194
- const gutterWidth = isOutside ? Math.round((outerSize / 8) * COORDINATE_GUTTER_SCALE) : 0;
195
- const boardSize = isOutside ? outerSize - gutterWidth : outerSize;
196
- const squareSize = boardSize / 8;
197
-
198
- // Resolve piece exit animation: null = disabled, undefined = default FadeOut
199
- const resolvedPieceExitAnimation =
200
- pieceExitAnimation === null
201
- ? undefined
202
- : (pieceExitAnimation ?? FadeOut.duration(CAPTURE_FADE_DURATION));
203
-
204
- // --- Board flip animation ---
205
- const flipRotation = useSharedValue(orientation === 'black' ? 180 : 0);
206
- const prevOrientationRef = useRef(orientation);
207
-
208
- useEffect(() => {
209
- if (prevOrientationRef.current !== orientation) {
210
- prevOrientationRef.current = orientation;
211
- if (animateFlip) {
212
- flipRotation.value = withTiming(
213
- orientation === 'black' ? 180 : 0,
214
- { duration: 300 },
215
- );
216
- } else {
217
- flipRotation.value = orientation === 'black' ? 180 : 0;
218
- }
219
- }
220
- }, [orientation, animateFlip, flipRotation]);
221
-
222
- // Note: We don't actually rotate the board view because all layers already
223
- // handle orientation via squareToXY coordinate math. The flip animation is
224
- // a visual effect only the rotation shared value is available for consumers
225
- // who want to add a rotation transition effect.
226
-
227
- // --- Internal FEN state ---
228
- // The Board owns a private FEN that drives piece rendering.
229
- // It starts from the parent prop and stays in sync via useEffect,
230
- // but can temporarily diverge when the library applies a user move
231
- // before the parent validates it (enables Chess.com-style "show move
232
- // then revert" behavior via undo()).
233
- const [internalFen, setInternalFen] = useState(fen);
234
-
235
- // --- Chess.js for legal move validation + internal state ---
236
- const boardState = useBoardState(fen);
237
-
238
- // Sync internal FEN + chess.js when parent changes the prop FEN.
239
- // This covers: accepted moves (parent updates FEN), board reset,
240
- // and opponent programmatic moves.
241
- useEffect(() => {
242
- setInternalFen(fen);
243
- boardState.loadFen(fen);
244
- }, [fen, boardState]);
245
-
246
- // --- Piece data from internal FEN ---
247
- const pieces = useBoardPieces(internalFen);
248
-
249
- // --- Check detection ---
250
- // Detect if the side to move is in check by parsing the FEN.
251
- // chess.js isInCheck() isn't exposed through boardState, so we use
252
- // a simple heuristic: if the FEN has the king of the side to move
253
- // attacked, highlight it.
254
- const checkSquareState = useMemo(() => {
255
- try {
256
- return detectCheckSquare(
257
- internalFen,
258
- () => boardState.isInCheck(),
259
- boardState.getTurn,
260
- );
261
- } catch {
262
- return null;
263
- }
264
- }, [internalFen, boardState]);
265
-
266
- // --- Selection state ---
267
- const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
268
- const [legalMoves, setLegalMoves] = useState<LegalMoveTarget[]>([]);
269
-
270
- // --- Imperative highlights ---
271
- const [imperativeHighlights, setImperativeHighlights] = useState<HighlightData[]>([]);
272
-
273
- // --- Premove state ---
274
- const { premove, setPremove, clearPremove, consumePremove } = usePremove();
275
-
276
- // --- Promotion state ---
277
- const [promotionState, setPromotionState] = useState<{
278
- from: string;
279
- to: string;
280
- color: 'w' | 'b';
281
- } | null>(null);
282
-
283
- // --- Resolve piece renderer: renderPiece > pieceSet > DefaultPieceSet ---
284
- const resolvedRenderer = useMemo(() => {
285
- if (renderPiece) return renderPiece;
286
- const set = pieceSet ?? DefaultPieceSet;
287
- return (code: string, size: number) => {
288
- const renderer = set[code as PieceCode];
289
- if (renderer) return renderer(size);
290
- return <View style={{ width: size, height: size }} />;
291
- };
292
- }, [renderPiece, pieceSet]);
293
-
294
- // --- Promotion detection ---
295
- const isPromotionMove = useCallback(
296
- (from: string, to: string): boolean => {
297
- const piece = pieces.find((p) => p.square === from);
298
- if (!piece) return false;
299
- // Must be a pawn
300
- if (piece.code !== 'wp' && piece.code !== 'bp') return false;
301
- // Must be moving to the last rank
302
- const toRank = to[1];
303
- if (piece.color === 'w' && toRank === '8') return true;
304
- if (piece.color === 'b' && toRank === '1') return true;
305
- return false;
306
- },
307
- [pieces],
308
- );
309
-
310
- // --- Gesture callbacks ---
311
- const handlePieceSelected = useCallback(
312
- (square: string) => {
313
- setSelectedSquare(square);
314
- if (showLegalMoves) {
315
- setLegalMoves(boardState.getLegalMoves(square));
316
- }
317
- },
318
- [showLegalMoves, boardState],
319
- );
320
-
321
- const handleSelectionCleared = useCallback(() => {
322
- setSelectedSquare(null);
323
- setLegalMoves([]);
324
- }, []);
325
-
326
- const handlePieceMoved = useCallback(
327
- async (from: string, to: string) => {
328
- // Clear selection and legal dots
329
- setSelectedSquare(null);
330
- setLegalMoves([]);
331
-
332
- // Check for promotion
333
- if (isPromotionMove(from, to)) {
334
- if (onPromotion) {
335
- const piece = pieces.find((p) => p.square === from);
336
- const color = piece?.color ?? 'w';
337
- setPromotionState({ from, to, color });
338
- return;
339
- }
340
- // Auto-promote to queen
341
- const result = boardState.applyMove(from, to, 'q');
342
- if (result.applied && result.fen) {
343
- setInternalFen(result.fen);
344
- }
345
- onMove?.({ from, to });
346
- return;
347
- }
348
-
349
- // Apply the move visually FIRST (Chess.com-style: show the move,
350
- // then let the parent validate). The parent can call undo() to
351
- // revert if the move is rejected.
352
- const result = boardState.applyMove(from, to);
353
- if (result.applied && result.fen) {
354
- setInternalFen(result.fen);
355
- onMove?.({ from, to });
356
- }
357
- // If chess.js rejected the move (truly illegal), do nothing —
358
- // piece stays at its original square.
359
- },
360
- [onMove, onPromotion, isPromotionMove, pieces, boardState],
361
- );
362
-
363
- // --- Promotion picker handlers ---
364
- const handlePromotionSelect = useCallback(
365
- async (piece: PromotionPiece) => {
366
- if (!promotionState) return;
367
- const { from, to } = promotionState;
368
- setPromotionState(null);
369
-
370
- const promo = piece.toLowerCase();
371
-
372
- if (onPromotion) {
373
- try {
374
- const choice = await onPromotion(from, to);
375
- const result = boardState.applyMove(from, to, choice);
376
- if (result.applied && result.fen) {
377
- setInternalFen(result.fen);
378
- }
379
- onMove?.({ from, to });
380
- } catch {
381
- // Promotion cancelled — piece stays at origin
382
- }
383
- } else {
384
- const result = boardState.applyMove(from, to, promo);
385
- if (result.applied && result.fen) {
386
- setInternalFen(result.fen);
387
- }
388
- onMove?.({ from, to });
389
- }
390
- },
391
- [promotionState, onPromotion, onMove, boardState],
392
- );
393
-
394
- const handlePromotionCancel = useCallback(() => {
395
- setPromotionState(null);
396
- }, []);
397
-
398
- // --- Premove handling ---
399
- const handlePremoveSet = useCallback(
400
- (pm: { from: string; to: string }) => {
401
- setPremove(pm);
402
- onPremove?.(pm);
403
- onHaptic?.('select');
404
- },
405
- [setPremove, onPremove, onHaptic],
406
- );
407
-
408
- // Execute premove when turn changes
409
- useEffect(() => {
410
- if (!premovesEnabled || !premove) return;
411
-
412
- const turn = boardState.getTurn();
413
- // Check if it's now the premover's turn
414
- const premovePiece = pieces.find((p) => p.square === premove.from);
415
- if (!premovePiece) {
416
- clearPremove();
417
- return;
418
- }
419
-
420
- if (premovePiece.color === turn) {
421
- const consumed = consumePremove();
422
- if (consumed) {
423
- // Try to execute the premove
424
- const result = boardState.applyMove(consumed.from, consumed.to, consumed.promotion);
425
- if (result.applied && result.fen) {
426
- setInternalFen(result.fen);
427
- onMove?.({ from: consumed.from, to: consumed.to });
428
- onHaptic?.('move');
429
- } else {
430
- // Premove was illegal — discard silently
431
- onHaptic?.('error');
432
- }
433
- }
434
- }
435
- }, [fen, premovesEnabled, premove, pieces, boardState, consumePremove, clearPremove, onMove, onHaptic]);
436
-
437
- // --- Rich callbacks ref (stable, for gesture hook) ---
438
- const richCallbacks = useMemo(
439
- () => ({
440
- onPieceClick,
441
- onSquareClick,
442
- onPieceDragBegin,
443
- onPieceDragEnd,
444
- onSquareLongPress,
445
- onHaptic,
446
- }),
447
- [onPieceClick, onSquareClick, onPieceDragBegin, onPieceDragEnd, onSquareLongPress, onHaptic],
448
- );
449
-
450
- const premoveCallbacks = useMemo(
451
- () => ({
452
- onPremoveSet: handlePremoveSet,
453
- }),
454
- [handlePremoveSet],
455
- );
456
-
457
- // --- Single centralized gesture ---
458
- const { gesture, gestureState } = useBoardGesture({
459
- squareSize,
460
- orientation,
461
- gestureEnabled,
462
- player,
463
- moveMethod,
464
- pieces,
465
- callbacks: {
466
- onPieceSelected: handlePieceSelected,
467
- onPieceMoved: handlePieceMoved,
468
- onSelectionCleared: handleSelectionCleared,
469
- },
470
- richCallbacks,
471
- premoveCallbacks,
472
- premovesEnabled,
473
- selectedSquare,
474
- legalMoves,
475
- currentTurn: boardState.getTurn(),
476
- });
477
-
478
- // --- Imperative ref ---
479
- useImperativeHandle(ref, () => ({
480
- move: (moveArg) => {
481
- const result = boardState.applyMove(moveArg.from, moveArg.to, moveArg.promotion);
482
- if (result.applied && result.fen) {
483
- setInternalFen(result.fen);
484
- }
485
- // Resolve after the piece animation completes. The moveDuration prop
486
- // drives BoardPieceView's withTiming, so this matches exactly.
487
- const duration = moveDuration ?? DEFAULT_MOVE_DURATION;
488
- return new Promise<void>((resolve) => setTimeout(resolve, duration));
489
- },
490
-
491
- highlight: (square, color) => {
492
- setImperativeHighlights((prev) => {
493
- const filtered = prev.filter((h) => h.square !== square);
494
- return [...filtered, { square, color }];
495
- });
496
- },
497
-
498
- clearHighlights: () => {
499
- setImperativeHighlights([]);
500
- },
501
-
502
- resetBoard: (newFen) => {
503
- boardState.loadFen(newFen);
504
- setInternalFen(newFen);
505
- setSelectedSquare(null);
506
- setLegalMoves([]);
507
- setImperativeHighlights([]);
508
- clearPremove();
509
- setPromotionState(null);
510
- },
511
-
512
- undo: () => {
513
- const prevFen = boardState.undoMove();
514
- if (prevFen) {
515
- setInternalFen(prevFen);
516
- }
517
- setSelectedSquare(null);
518
- setLegalMoves([]);
519
- },
520
-
521
- clearPremoves: () => {
522
- clearPremove();
523
- },
524
- }));
525
-
526
- // If no size yet (auto-sizing), render invisible container for measurement
527
- if (outerSize === 0) {
528
- return (
529
- <View style={{ flex: 1, aspectRatio: 1 }} onLayout={handleLayout} />
530
- );
531
- }
532
-
533
- // Inner board with all interactive layers
534
- const boardContent = (
535
- <GestureDetector gesture={gesture}>
536
- <View
537
- style={isOutside
538
- ? { width: boardSize, height: boardSize, position: 'absolute', top: 0, right: 0 }
539
- : { width: boardSize, height: boardSize }}
540
- onLayout={!isOutside && !boardSizeProp ? handleLayout : undefined}
541
- accessibilityLabel="Chess board"
542
- accessibilityRole="adjustable"
543
- >
544
- {/* Layer 1: Board background (64 colored squares) */}
545
- <BoardBackground
546
- boardSize={boardSize}
547
- lightColor={boardColors.light}
548
- darkColor={boardColors.dark}
549
- />
550
-
551
- {/* Layer 2: Inside coordinate labels (when position='inside') */}
552
- {isCoordVisible && !isOutside && (
553
- <BoardCoordinates
554
- boardSize={boardSize}
555
- orientation={orientation}
556
- lightColor={boardColors.light}
557
- darkColor={boardColors.dark}
558
- withLetters
559
- withNumbers
560
- position="inside"
561
- />
562
- )}
563
-
564
- {/* Layer 3: Square highlights (last move, check, selected, premove, custom, imperative) */}
565
- <BoardHighlights
566
- boardSize={boardSize}
567
- orientation={orientation}
568
- squareSize={squareSize}
569
- lastMove={lastMove}
570
- lastMoveColor={lastMoveColor}
571
- checkSquare={checkSquareState}
572
- checkColor={checkHighlightColor}
573
- selectedSquare={selectedSquare}
574
- selectedColor={selectedSquareColor}
575
- premoveSquares={premove ? { from: premove.from, to: premove.to } : null}
576
- premoveColor={premoveColor}
577
- highlights={highlights}
578
- imperativeHighlights={imperativeHighlights}
579
- />
580
-
581
- {/* Layer 4: Drag target highlight (animated, updates during drag) */}
582
- {showDragTarget && (
583
- <DragTargetHighlight
584
- squareSize={squareSize}
585
- orientation={orientation}
586
- dragTargetSquare={gestureState.dragTargetSquare}
587
- color={dragTargetColor}
588
- />
589
- )}
590
-
591
- {/* Layer 5: Legal move dots (only when a piece is selected) */}
592
- {showLegalMoves && (
593
- <BoardLegalDots
594
- legalMoves={legalMoves}
595
- squareSize={squareSize}
596
- orientation={orientation}
597
- />
598
- )}
599
-
600
- {/* Layer 6: Pieces */}
601
- <BoardPiecesLayer
602
- pieces={pieces}
603
- squareSize={squareSize}
604
- orientation={orientation}
605
- moveDuration={moveDuration}
606
- animationConfig={animationConfig}
607
- renderPiece={resolvedRenderer}
608
- activeSquare={gestureState.activeSquare}
609
- isDragging={gestureState.isDragging}
610
- exitingAnimation={resolvedPieceExitAnimation}
611
- />
612
-
613
- {/* Layer 7: Arrows + shapes (SVG overlay) */}
614
- {((arrows && arrows.length > 0) || (shapes && shapes.length > 0)) && (
615
- <BoardArrows
616
- boardSize={boardSize}
617
- orientation={orientation}
618
- arrows={arrows}
619
- shapes={shapes}
620
- />
621
- )}
622
-
623
- {/* Layer 8: Annotations (text badges) */}
624
- {annotations && annotations.length > 0 && (
625
- <BoardAnnotations
626
- boardSize={boardSize}
627
- orientation={orientation}
628
- squareSize={squareSize}
629
- annotations={annotations}
630
- />
631
- )}
632
-
633
- {/* Layer 9: Drag ghost (single floating piece) */}
634
- <BoardDragGhost
635
- squareSize={squareSize}
636
- isDragging={gestureState.isDragging}
637
- dragX={gestureState.dragX}
638
- dragY={gestureState.dragY}
639
- dragPieceCode={gestureState.dragPieceCode}
640
- renderPiece={resolvedRenderer}
641
- />
642
-
643
- {/* Layer 10: Promotion picker (conditional) */}
644
- {promotionState && (
645
- <PromotionPicker
646
- square={promotionState.to}
647
- pieceColor={promotionState.color}
648
- boardSize={boardSize}
649
- squareSize={squareSize}
650
- orientation={orientation}
651
- renderPiece={resolvedRenderer}
652
- onSelect={handlePromotionSelect}
653
- onCancel={handlePromotionCancel}
654
- />
655
- )}
656
- </View>
657
- </GestureDetector>
658
- );
659
-
660
- // Outside coordinates: wrap board in outer container with gutter
661
- if (isOutside) {
662
- return (
663
- <View
664
- style={{ width: outerSize, height: boardSize + gutterWidth }}
665
- onLayout={boardSizeProp ? undefined : handleLayout}
666
- >
667
- {boardContent}
668
-
669
- {/* Outside coordinate labels in the gutter area */}
670
- <BoardCoordinates
671
- boardSize={boardSize}
672
- orientation={orientation}
673
- lightColor={boardColors.light}
674
- darkColor={boardColors.dark}
675
- withLetters
676
- withNumbers
677
- position="outside"
678
- gutterWidth={gutterWidth}
679
- />
680
- </View>
681
- );
682
- }
683
-
684
- return boardContent;
685
- });
1
+ import React, {
2
+ forwardRef,
3
+ useState,
4
+ useCallback,
5
+ useImperativeHandle,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ } from 'react';
10
+ import { View, type LayoutChangeEvent } from 'react-native';
11
+ import { GestureDetector } from 'react-native-gesture-handler';
12
+ import { useSharedValue, withTiming, FadeOut } from 'react-native-reanimated';
13
+
14
+ import type {
15
+ BoardRef,
16
+ BoardProps,
17
+ LegalMoveTarget,
18
+ HighlightData,
19
+ PieceCode,
20
+ PromotionPiece,
21
+ } from './types';
22
+ import {
23
+ DEFAULT_BOARD_COLORS,
24
+ DEFAULT_MOVE_DURATION,
25
+ DEFAULT_LAST_MOVE_COLOR,
26
+ DEFAULT_CHECK_COLOR,
27
+ DEFAULT_SELECTED_COLOR,
28
+ DEFAULT_PREMOVE_COLOR,
29
+ DEFAULT_DRAG_TARGET_COLOR,
30
+ CAPTURE_FADE_DURATION,
31
+ COORDINATE_GUTTER_SCALE,
32
+ } from './constants';
33
+ import { DefaultPieceSet } from './pieces';
34
+ import { useBoardPieces } from './use-board-pieces';
35
+ import { useBoardState } from './use-board-state';
36
+ import { useBoardGesture } from './use-board-gesture';
37
+ import { usePremove } from './use-premove';
38
+ import { BoardBackground } from './board-background';
39
+ import { BoardCoordinates } from './board-coordinates';
40
+ import { BoardHighlights, DragTargetHighlight } from './board-highlights';
41
+ import { BoardLegalDots } from './board-legal-dots';
42
+ import { BoardPiecesLayer } from './board-pieces';
43
+ import { BoardDragGhost } from './board-drag-ghost';
44
+ import { BoardArrows } from './board-arrows';
45
+ import { BoardAnnotations } from './board-annotations';
46
+ import { PromotionPicker } from './promotion-picker';
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Check detection helper
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Find the king square for the side currently in check.
54
+ * Returns null if not in check.
55
+ */
56
+ function detectCheckSquare(
57
+ fen: string,
58
+ isInCheck: () => boolean,
59
+ getTurn: () => 'w' | 'b',
60
+ ): string | null {
61
+ if (!isInCheck()) return null;
62
+
63
+ const turn = getTurn();
64
+ const kingChar = turn === 'w' ? 'K' : 'k';
65
+ const placement = fen.split(' ')[0];
66
+ const ranks = placement.split('/');
67
+
68
+ for (let rankIdx = 0; rankIdx < ranks.length; rankIdx++) {
69
+ const rank = ranks[rankIdx]!;
70
+ let fileIdx = 0;
71
+ for (const char of rank) {
72
+ if (char >= '1' && char <= '8') {
73
+ fileIdx += parseInt(char, 10);
74
+ continue;
75
+ }
76
+ if (char === kingChar) {
77
+ const files = 'abcdefgh';
78
+ return `${files[fileIdx]}${8 - rankIdx}`;
79
+ }
80
+ fileIdx++;
81
+ }
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Board component
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * High-performance custom chess board built on Reanimated + Gesture Handler.
93
+ *
94
+ * Architecture:
95
+ * - 1 gesture handler (vs 32 in typical implementations)
96
+ * - ~40 components mounted (vs ~281)
97
+ * - ~75 native views (vs ~470)
98
+ * - 0 React Context providers
99
+ * - 0 re-renders during drag (pure worklet — only 2 shared value writes per frame)
100
+ *
101
+ * v0.2.0 layer stack (10 layers):
102
+ * 1. BoardBackground (64 squares)
103
+ * 2. BoardCoordinates (a-h, 1-8)
104
+ * 3. BoardHighlights (last move, check, selected, premove, custom, imperative)
105
+ * 4. DragTargetHighlight (animated, worklet-driven)
106
+ * 5. BoardLegalDots (legal move indicators)
107
+ * 6. BoardPiecesLayer (all pieces)
108
+ * 7. BoardArrows (SVG arrows + circles)
109
+ * 8. BoardAnnotations (text badges)
110
+ * 9. BoardDragGhost (floating piece)
111
+ * 10. PromotionPicker (modal, conditional)
112
+ */
113
+ export const Board = forwardRef<BoardRef, BoardProps>(function Board(
114
+ {
115
+ fen,
116
+ orientation,
117
+
118
+ // Layout
119
+ boardSize: boardSizeProp,
120
+
121
+ // Interaction
122
+ gestureEnabled = true,
123
+ player = 'both',
124
+ moveMethod = 'both',
125
+ showLegalMoves = true,
126
+ premovesEnabled = false,
127
+
128
+ // Appearance
129
+ colors,
130
+ coordinatePosition: coordinatePositionProp,
131
+ withLetters: withLettersProp,
132
+ withNumbers: withNumbersProp,
133
+ renderPiece,
134
+ pieceSet,
135
+
136
+ // Overlays
137
+ lastMove,
138
+ highlights,
139
+ arrows,
140
+ shapes,
141
+ annotations,
142
+ showDragTarget = true,
143
+
144
+ // Overlay colors
145
+ lastMoveColor = DEFAULT_LAST_MOVE_COLOR,
146
+ checkHighlightColor = DEFAULT_CHECK_COLOR,
147
+ selectedSquareColor = DEFAULT_SELECTED_COLOR,
148
+ premoveColor = DEFAULT_PREMOVE_COLOR,
149
+ dragTargetColor = DEFAULT_DRAG_TARGET_COLOR,
150
+
151
+ // Animation
152
+ moveDuration = DEFAULT_MOVE_DURATION,
153
+ animationConfig,
154
+ animateFlip = true,
155
+ pieceExitAnimation,
156
+
157
+ // Promotion
158
+ autoPromoteTo,
159
+ onPromotion,
160
+
161
+ // Callbacks
162
+ onMove,
163
+ onPieceClick,
164
+ onSquareClick,
165
+ onPieceDragBegin,
166
+ onPieceDragEnd,
167
+ onSquareLongPress,
168
+ onPremove,
169
+ onHaptic,
170
+ },
171
+ ref,
172
+ ) {
173
+ // --- Auto-sizing via onLayout when boardSize not provided ---
174
+ const [measuredSize, setMeasuredSize] = useState(0);
175
+ const handleLayout = useCallback((e: LayoutChangeEvent) => {
176
+ const { width, height } = e.nativeEvent.layout;
177
+ setMeasuredSize(Math.min(width, height));
178
+ }, []);
179
+
180
+ const outerSize = boardSizeProp ?? measuredSize;
181
+ const boardColors = colors ?? DEFAULT_BOARD_COLORS;
182
+
183
+ // Resolve coordinate position: new prop takes precedence over legacy booleans
184
+ const coordinatePosition =
185
+ coordinatePositionProp ??
186
+ (withLettersProp === false && withNumbersProp === false ? 'none' : 'inside');
187
+ const isOutside = coordinatePosition === 'outside';
188
+ const isCoordVisible = coordinatePosition !== 'none';
189
+
190
+ // When outside: reserve gutter space; inner board = outer minus gutters
191
+ const gutterWidth = isOutside ? Math.round((outerSize / 8) * COORDINATE_GUTTER_SCALE) : 0;
192
+ const boardSize = isOutside ? outerSize - gutterWidth : outerSize;
193
+ const squareSize = boardSize / 8;
194
+
195
+ // Resolve piece exit animation: null = disabled, undefined = default FadeOut
196
+ const resolvedPieceExitAnimation =
197
+ pieceExitAnimation === null
198
+ ? undefined
199
+ : (pieceExitAnimation ?? FadeOut.duration(CAPTURE_FADE_DURATION));
200
+
201
+ // --- Board flip animation ---
202
+ const flipRotation = useSharedValue(orientation === 'black' ? 180 : 0);
203
+ const prevOrientationRef = useRef(orientation);
204
+
205
+ useEffect(() => {
206
+ if (prevOrientationRef.current !== orientation) {
207
+ prevOrientationRef.current = orientation;
208
+ if (animateFlip) {
209
+ flipRotation.value = withTiming(orientation === 'black' ? 180 : 0, { duration: 300 });
210
+ } else {
211
+ flipRotation.value = orientation === 'black' ? 180 : 0;
212
+ }
213
+ }
214
+ }, [orientation, animateFlip, flipRotation]);
215
+
216
+ // Note: We don't actually rotate the board view because all layers already
217
+ // handle orientation via squareToXY coordinate math. The flip animation is
218
+ // a visual effect only — the rotation shared value is available for consumers
219
+ // who want to add a rotation transition effect.
220
+
221
+ // --- Internal FEN state ---
222
+ // The Board owns a private FEN that drives piece rendering.
223
+ // It starts from the parent prop and stays in sync via useEffect,
224
+ // but can temporarily diverge when the library applies a user move
225
+ // before the parent validates it (enables Chess.com-style "show move
226
+ // then revert" behavior via undo()).
227
+ const [internalFen, setInternalFen] = useState(fen);
228
+
229
+ // --- Chess.js for legal move validation + internal state ---
230
+ const boardState = useBoardState(fen);
231
+
232
+ // Sync internal FEN + chess.js when parent changes the prop FEN.
233
+ // This covers: accepted moves (parent updates FEN), board reset,
234
+ // and opponent programmatic moves.
235
+ useEffect(() => {
236
+ setInternalFen(fen);
237
+ boardState.loadFen(fen);
238
+ }, [fen, boardState]);
239
+
240
+ // --- Piece data from internal FEN ---
241
+ const pieces = useBoardPieces(internalFen);
242
+
243
+ // --- Check detection ---
244
+ // Detect if the side to move is in check by parsing the FEN.
245
+ // chess.js isInCheck() isn't exposed through boardState, so we use
246
+ // a simple heuristic: if the FEN has the king of the side to move
247
+ // attacked, highlight it.
248
+ const checkSquareState = useMemo(() => {
249
+ try {
250
+ return detectCheckSquare(internalFen, () => boardState.isInCheck(), boardState.getTurn);
251
+ } catch {
252
+ return null;
253
+ }
254
+ }, [internalFen, boardState]);
255
+
256
+ // --- Selection state ---
257
+ const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
258
+ const [legalMoves, setLegalMoves] = useState<LegalMoveTarget[]>([]);
259
+
260
+ // --- Imperative highlights ---
261
+ const [imperativeHighlights, setImperativeHighlights] = useState<HighlightData[]>([]);
262
+
263
+ // --- Premove state ---
264
+ const { premove, setPremove, clearPremove, consumePremove } = usePremove();
265
+
266
+ // --- Promotion state ---
267
+ const [promotionState, setPromotionState] = useState<{
268
+ from: string;
269
+ to: string;
270
+ color: 'w' | 'b';
271
+ } | null>(null);
272
+
273
+ // --- Resolve piece renderer: renderPiece > pieceSet > DefaultPieceSet ---
274
+ const resolvedRenderer = useMemo(() => {
275
+ if (renderPiece) return renderPiece;
276
+ const set = pieceSet ?? DefaultPieceSet;
277
+ return (code: string, size: number) => {
278
+ const renderer = set[code as PieceCode];
279
+ if (renderer) return renderer(size);
280
+ return <View style={{ width: size, height: size }} />;
281
+ };
282
+ }, [renderPiece, pieceSet]);
283
+
284
+ // --- Promotion detection ---
285
+ const isPromotionMove = useCallback(
286
+ (from: string, to: string): boolean => {
287
+ const piece = pieces.find((p) => p.square === from);
288
+ if (!piece) return false;
289
+ // Must be a pawn
290
+ if (piece.code !== 'wp' && piece.code !== 'bp') return false;
291
+ // Must be moving to the last rank
292
+ const toRank = to[1];
293
+ if (piece.color === 'w' && toRank === '8') return true;
294
+ if (piece.color === 'b' && toRank === '1') return true;
295
+ return false;
296
+ },
297
+ [pieces],
298
+ );
299
+
300
+ // --- Gesture callbacks ---
301
+ const handlePieceSelected = useCallback(
302
+ (square: string) => {
303
+ setSelectedSquare(square);
304
+ if (showLegalMoves) {
305
+ setLegalMoves(boardState.getLegalMoves(square));
306
+ }
307
+ },
308
+ [showLegalMoves, boardState],
309
+ );
310
+
311
+ const handleSelectionCleared = useCallback(() => {
312
+ setSelectedSquare(null);
313
+ setLegalMoves([]);
314
+ }, []);
315
+
316
+ const handlePieceMoved = useCallback(
317
+ async (from: string, to: string) => {
318
+ // Clear selection and legal dots
319
+ setSelectedSquare(null);
320
+ setLegalMoves([]);
321
+
322
+ // Check for promotion
323
+ if (isPromotionMove(from, to)) {
324
+ // 1. Auto-promote to a specific piece (no picker, no callback)
325
+ if (autoPromoteTo) {
326
+ const result = boardState.applyMove(from, to, autoPromoteTo);
327
+ if (result.applied && result.fen) {
328
+ setInternalFen(result.fen);
329
+ }
330
+ onMove?.({ from, to });
331
+ return;
332
+ }
333
+
334
+ // 2. Consumer handles promotion UI externally via callback
335
+ if (onPromotion) {
336
+ try {
337
+ const choice = await onPromotion(from, to);
338
+ const result = boardState.applyMove(from, to, choice);
339
+ if (result.applied && result.fen) {
340
+ setInternalFen(result.fen);
341
+ }
342
+ onMove?.({ from, to });
343
+ } catch {
344
+ // Promotion cancelled by consumer — piece stays at origin
345
+ }
346
+ return;
347
+ }
348
+
349
+ // 3. Default: show built-in promotion picker
350
+ const piece = pieces.find((p) => p.square === from);
351
+ const color = piece?.color ?? 'w';
352
+ setPromotionState({ from, to, color });
353
+ return;
354
+ }
355
+
356
+ // Apply the move visually FIRST (Chess.com-style: show the move,
357
+ // then let the parent validate). The parent can call undo() to
358
+ // revert if the move is rejected.
359
+ const result = boardState.applyMove(from, to);
360
+ if (result.applied && result.fen) {
361
+ setInternalFen(result.fen);
362
+ onMove?.({ from, to });
363
+ }
364
+ // If chess.js rejected the move (truly illegal), do nothing —
365
+ // piece stays at its original square.
366
+ },
367
+ [onMove, onPromotion, autoPromoteTo, isPromotionMove, pieces, boardState],
368
+ );
369
+
370
+ // --- Promotion picker handlers ---
371
+ // Only reached when neither autoPromoteTo nor onPromotion is set.
372
+ const handlePromotionSelect = useCallback(
373
+ (piece: PromotionPiece) => {
374
+ if (!promotionState) return;
375
+ const { from, to } = promotionState;
376
+ setPromotionState(null);
377
+
378
+ const result = boardState.applyMove(from, to, piece);
379
+ if (result.applied && result.fen) {
380
+ setInternalFen(result.fen);
381
+ }
382
+ onMove?.({ from, to });
383
+ },
384
+ [promotionState, onMove, boardState],
385
+ );
386
+
387
+ const handlePromotionCancel = useCallback(() => {
388
+ setPromotionState(null);
389
+ }, []);
390
+
391
+ // --- Premove handling ---
392
+ const handlePremoveSet = useCallback(
393
+ (pm: { from: string; to: string }) => {
394
+ setPremove(pm);
395
+ onPremove?.(pm);
396
+ onHaptic?.('select');
397
+ },
398
+ [setPremove, onPremove, onHaptic],
399
+ );
400
+
401
+ // Execute premove when turn changes
402
+ useEffect(() => {
403
+ if (!premovesEnabled || !premove) return;
404
+
405
+ const turn = boardState.getTurn();
406
+ // Check if it's now the premover's turn
407
+ const premovePiece = pieces.find((p) => p.square === premove.from);
408
+ if (!premovePiece) {
409
+ clearPremove();
410
+ return;
411
+ }
412
+
413
+ if (premovePiece.color === turn) {
414
+ const consumed = consumePremove();
415
+ if (consumed) {
416
+ // Try to execute the premove
417
+ const result = boardState.applyMove(consumed.from, consumed.to, consumed.promotion);
418
+ if (result.applied && result.fen) {
419
+ setInternalFen(result.fen);
420
+ onMove?.({ from: consumed.from, to: consumed.to });
421
+ onHaptic?.('move');
422
+ } else {
423
+ // Premove was illegal discard silently
424
+ onHaptic?.('error');
425
+ }
426
+ }
427
+ }
428
+ }, [
429
+ fen,
430
+ premovesEnabled,
431
+ premove,
432
+ pieces,
433
+ boardState,
434
+ consumePremove,
435
+ clearPremove,
436
+ onMove,
437
+ onHaptic,
438
+ ]);
439
+
440
+ // --- Rich callbacks ref (stable, for gesture hook) ---
441
+ const richCallbacks = useMemo(
442
+ () => ({
443
+ onPieceClick,
444
+ onSquareClick,
445
+ onPieceDragBegin,
446
+ onPieceDragEnd,
447
+ onSquareLongPress,
448
+ onHaptic,
449
+ }),
450
+ [onPieceClick, onSquareClick, onPieceDragBegin, onPieceDragEnd, onSquareLongPress, onHaptic],
451
+ );
452
+
453
+ const premoveCallbacks = useMemo(
454
+ () => ({
455
+ onPremoveSet: handlePremoveSet,
456
+ }),
457
+ [handlePremoveSet],
458
+ );
459
+
460
+ // --- Single centralized gesture ---
461
+ const { gesture, gestureState } = useBoardGesture({
462
+ squareSize,
463
+ orientation,
464
+ gestureEnabled,
465
+ player,
466
+ moveMethod,
467
+ pieces,
468
+ callbacks: {
469
+ onPieceSelected: handlePieceSelected,
470
+ onPieceMoved: handlePieceMoved,
471
+ onSelectionCleared: handleSelectionCleared,
472
+ },
473
+ richCallbacks,
474
+ premoveCallbacks,
475
+ premovesEnabled,
476
+ selectedSquare,
477
+ legalMoves,
478
+ currentTurn: boardState.getTurn(),
479
+ });
480
+
481
+ // --- Imperative ref ---
482
+ useImperativeHandle(ref, () => ({
483
+ move: (moveArg) => {
484
+ const result = boardState.applyMove(moveArg.from, moveArg.to, moveArg.promotion);
485
+ if (result.applied && result.fen) {
486
+ setInternalFen(result.fen);
487
+ }
488
+ // Resolve after the piece animation completes. The moveDuration prop
489
+ // drives BoardPieceView's withTiming, so this matches exactly.
490
+ const duration = moveDuration ?? DEFAULT_MOVE_DURATION;
491
+ return new Promise<void>((resolve) => setTimeout(resolve, duration));
492
+ },
493
+
494
+ highlight: (square, color) => {
495
+ setImperativeHighlights((prev) => {
496
+ const filtered = prev.filter((h) => h.square !== square);
497
+ return [...filtered, { square, color }];
498
+ });
499
+ },
500
+
501
+ clearHighlights: () => {
502
+ setImperativeHighlights([]);
503
+ },
504
+
505
+ resetBoard: (newFen) => {
506
+ boardState.loadFen(newFen);
507
+ setInternalFen(newFen);
508
+ setSelectedSquare(null);
509
+ setLegalMoves([]);
510
+ setImperativeHighlights([]);
511
+ clearPremove();
512
+ setPromotionState(null);
513
+ },
514
+
515
+ undo: () => {
516
+ const prevFen = boardState.undoMove();
517
+ if (prevFen) {
518
+ setInternalFen(prevFen);
519
+ }
520
+ setSelectedSquare(null);
521
+ setLegalMoves([]);
522
+ },
523
+
524
+ clearPremoves: () => {
525
+ clearPremove();
526
+ },
527
+ }));
528
+
529
+ // If no size yet (auto-sizing), render invisible container for measurement
530
+ if (outerSize === 0) {
531
+ return <View style={{ flex: 1, aspectRatio: 1 }} onLayout={handleLayout} />;
532
+ }
533
+
534
+ // Inner board with all interactive layers
535
+ const boardContent = (
536
+ <GestureDetector gesture={gesture}>
537
+ <View
538
+ style={
539
+ isOutside
540
+ ? { width: boardSize, height: boardSize, position: 'absolute', top: 0, right: 0 }
541
+ : { width: boardSize, height: boardSize }
542
+ }
543
+ onLayout={!isOutside && !boardSizeProp ? handleLayout : undefined}
544
+ accessibilityLabel="Chess board"
545
+ accessibilityRole="adjustable"
546
+ >
547
+ {/* Layer 1: Board background (64 colored squares) */}
548
+ <BoardBackground
549
+ boardSize={boardSize}
550
+ lightColor={boardColors.light}
551
+ darkColor={boardColors.dark}
552
+ />
553
+
554
+ {/* Layer 2: Inside coordinate labels (when position='inside') */}
555
+ {isCoordVisible && !isOutside && (
556
+ <BoardCoordinates
557
+ boardSize={boardSize}
558
+ orientation={orientation}
559
+ lightColor={boardColors.light}
560
+ darkColor={boardColors.dark}
561
+ withLetters
562
+ withNumbers
563
+ position="inside"
564
+ />
565
+ )}
566
+
567
+ {/* Layer 3: Square highlights (last move, check, selected, premove, custom, imperative) */}
568
+ <BoardHighlights
569
+ boardSize={boardSize}
570
+ orientation={orientation}
571
+ squareSize={squareSize}
572
+ lastMove={lastMove}
573
+ lastMoveColor={lastMoveColor}
574
+ checkSquare={checkSquareState}
575
+ checkColor={checkHighlightColor}
576
+ selectedSquare={selectedSquare}
577
+ selectedColor={selectedSquareColor}
578
+ premoveSquares={premove ? { from: premove.from, to: premove.to } : null}
579
+ premoveColor={premoveColor}
580
+ highlights={highlights}
581
+ imperativeHighlights={imperativeHighlights}
582
+ />
583
+
584
+ {/* Layer 4: Drag target highlight (animated, updates during drag) */}
585
+ {showDragTarget && (
586
+ <DragTargetHighlight
587
+ squareSize={squareSize}
588
+ orientation={orientation}
589
+ dragTargetSquare={gestureState.dragTargetSquare}
590
+ color={dragTargetColor}
591
+ />
592
+ )}
593
+
594
+ {/* Layer 5: Legal move dots (only when a piece is selected) */}
595
+ {showLegalMoves && (
596
+ <BoardLegalDots
597
+ legalMoves={legalMoves}
598
+ squareSize={squareSize}
599
+ orientation={orientation}
600
+ />
601
+ )}
602
+
603
+ {/* Layer 6: Pieces */}
604
+ <BoardPiecesLayer
605
+ pieces={pieces}
606
+ squareSize={squareSize}
607
+ orientation={orientation}
608
+ moveDuration={moveDuration}
609
+ animationConfig={animationConfig}
610
+ renderPiece={resolvedRenderer}
611
+ activeSquare={gestureState.activeSquare}
612
+ isDragging={gestureState.isDragging}
613
+ exitingAnimation={resolvedPieceExitAnimation}
614
+ />
615
+
616
+ {/* Layer 7: Arrows + shapes (SVG overlay) */}
617
+ {((arrows && arrows.length > 0) || (shapes && shapes.length > 0)) && (
618
+ <BoardArrows
619
+ boardSize={boardSize}
620
+ orientation={orientation}
621
+ arrows={arrows}
622
+ shapes={shapes}
623
+ />
624
+ )}
625
+
626
+ {/* Layer 8: Annotations (text badges) */}
627
+ {annotations && annotations.length > 0 && (
628
+ <BoardAnnotations
629
+ boardSize={boardSize}
630
+ orientation={orientation}
631
+ squareSize={squareSize}
632
+ annotations={annotations}
633
+ />
634
+ )}
635
+
636
+ {/* Layer 9: Drag ghost (single floating piece) */}
637
+ <BoardDragGhost
638
+ squareSize={squareSize}
639
+ isDragging={gestureState.isDragging}
640
+ dragX={gestureState.dragX}
641
+ dragY={gestureState.dragY}
642
+ dragPieceCode={gestureState.dragPieceCode}
643
+ renderPiece={resolvedRenderer}
644
+ />
645
+
646
+ {/* Layer 10: Promotion picker (conditional) */}
647
+ {promotionState && (
648
+ <PromotionPicker
649
+ square={promotionState.to}
650
+ pieceColor={promotionState.color}
651
+ boardSize={boardSize}
652
+ squareSize={squareSize}
653
+ orientation={orientation}
654
+ renderPiece={resolvedRenderer}
655
+ onSelect={handlePromotionSelect}
656
+ onCancel={handlePromotionCancel}
657
+ />
658
+ )}
659
+ </View>
660
+ </GestureDetector>
661
+ );
662
+
663
+ // Outside coordinates: wrap board in outer container with gutter
664
+ if (isOutside) {
665
+ return (
666
+ <View
667
+ style={{ width: outerSize, height: boardSize + gutterWidth }}
668
+ onLayout={boardSizeProp ? undefined : handleLayout}
669
+ >
670
+ {boardContent}
671
+
672
+ {/* Outside coordinate labels in the gutter area */}
673
+ <BoardCoordinates
674
+ boardSize={boardSize}
675
+ orientation={orientation}
676
+ lightColor={boardColors.light}
677
+ darkColor={boardColors.dark}
678
+ withLetters
679
+ withNumbers
680
+ position="outside"
681
+ gutterWidth={gutterWidth}
682
+ />
683
+ </View>
684
+ );
685
+ }
686
+
687
+ return boardContent;
688
+ });