react-native-chess-kit 0.4.2 → 0.5.1

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