ankigammon 1.0.6__py3-none-any.whl

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 (61) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +391 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +216 -0
  7. ankigammon/anki/apkg_exporter.py +111 -0
  8. ankigammon/anki/card_generator.py +1325 -0
  9. ankigammon/anki/card_styles.py +1054 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +192 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +594 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +201 -0
  15. ankigammon/gui/dialogs/input_dialog.py +762 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +420 -0
  18. ankigammon/gui/dialogs/update_dialog.py +373 -0
  19. ankigammon/gui/format_detector.py +377 -0
  20. ankigammon/gui/main_window.py +1611 -0
  21. ankigammon/gui/resources/down-arrow.svg +3 -0
  22. ankigammon/gui/resources/icon.icns +0 -0
  23. ankigammon/gui/resources/icon.ico +0 -0
  24. ankigammon/gui/resources/icon.png +0 -0
  25. ankigammon/gui/resources/style.qss +402 -0
  26. ankigammon/gui/resources.py +26 -0
  27. ankigammon/gui/update_checker.py +259 -0
  28. ankigammon/gui/widgets/__init__.py +8 -0
  29. ankigammon/gui/widgets/position_list.py +166 -0
  30. ankigammon/gui/widgets/smart_input.py +268 -0
  31. ankigammon/models.py +356 -0
  32. ankigammon/parsers/__init__.py +7 -0
  33. ankigammon/parsers/gnubg_match_parser.py +1094 -0
  34. ankigammon/parsers/gnubg_parser.py +468 -0
  35. ankigammon/parsers/sgf_parser.py +290 -0
  36. ankigammon/parsers/xg_binary_parser.py +1097 -0
  37. ankigammon/parsers/xg_text_parser.py +688 -0
  38. ankigammon/renderer/__init__.py +5 -0
  39. ankigammon/renderer/animation_controller.py +391 -0
  40. ankigammon/renderer/animation_helper.py +191 -0
  41. ankigammon/renderer/color_schemes.py +145 -0
  42. ankigammon/renderer/svg_board_renderer.py +791 -0
  43. ankigammon/settings.py +315 -0
  44. ankigammon/thirdparty/__init__.py +7 -0
  45. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  46. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  47. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  48. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  49. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  50. ankigammon/utils/__init__.py +13 -0
  51. ankigammon/utils/gnubg_analyzer.py +590 -0
  52. ankigammon/utils/gnuid.py +577 -0
  53. ankigammon/utils/move_parser.py +204 -0
  54. ankigammon/utils/ogid.py +326 -0
  55. ankigammon/utils/xgid.py +387 -0
  56. ankigammon-1.0.6.dist-info/METADATA +352 -0
  57. ankigammon-1.0.6.dist-info/RECORD +61 -0
  58. ankigammon-1.0.6.dist-info/WHEEL +5 -0
  59. ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
  60. ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
  61. ankigammon-1.0.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,391 @@
1
+ """
2
+ Animation controller for backgammon checker movements.
3
+
4
+ Generates JavaScript code for animating checker movements using various strategies:
5
+ - Cross-fade: Simple opacity transition between positions
6
+ - Arc movement: GSAP-based arc animation of individual checkers
7
+ """
8
+
9
+ import json
10
+ from typing import List, Tuple, Dict, Optional
11
+ from ankigammon.models import Position, Move, Player
12
+ from ankigammon.renderer.animation_helper import AnimationHelper
13
+
14
+
15
+ class AnimationController:
16
+ """
17
+ Orchestrates checker movement animations for Anki cards.
18
+
19
+ This controller generates JavaScript code that animates transitions
20
+ between backgammon positions using either simple cross-fade or
21
+ sophisticated GSAP-based arc movements.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ board_width: int = 900,
27
+ board_height: int = 600,
28
+ point_height_ratio: float = 0.45
29
+ ):
30
+ """
31
+ Initialize the animation controller.
32
+
33
+ Args:
34
+ board_width: SVG viewBox width (must match renderer)
35
+ board_height: SVG viewBox height (must match renderer)
36
+ point_height_ratio: Height of points as ratio of board height
37
+ """
38
+ self.board_width = board_width
39
+ self.board_height = board_height
40
+ self.point_height_ratio = point_height_ratio
41
+
42
+ # Board dimensions matching SVGBoardRenderer
43
+ self.margin = 20
44
+ self.cube_area_width = 70
45
+ self.bearoff_area_width = 100
46
+
47
+ self.playing_width = (
48
+ self.board_width - 2 * self.margin -
49
+ self.cube_area_width - self.bearoff_area_width
50
+ )
51
+ self.board_height_inner = self.board_height - 2 * self.margin
52
+
53
+ self.bar_width = self.playing_width * 0.08
54
+ self.half_width = (self.playing_width - self.bar_width) / 2
55
+ self.point_width = self.half_width / 6
56
+ self.point_height = self.board_height_inner * point_height_ratio
57
+
58
+ self.checker_radius = min(self.point_width * 0.45, 25)
59
+
60
+ self.board_x = self.margin + self.cube_area_width
61
+ self.board_y = self.margin
62
+
63
+ def get_point_coordinates(self, point_num: int, checker_index: int = 0, player: Optional[Player] = None) -> Tuple[float, float]:
64
+ """
65
+ Calculate SVG coordinates for a checker on a specific point.
66
+
67
+ Args:
68
+ point_num: Point number (0=X bar, 1-24=board points, 25=O bar, -1=bear off)
69
+ checker_index: Index of checker in stack (0=bottom, 1=next up, etc.)
70
+ player: Player bearing off (required when point_num == -1)
71
+
72
+ Returns:
73
+ (x, y) coordinates in SVG space
74
+ """
75
+ # Handle bar positions
76
+ if point_num == 0: # X's bar (top)
77
+ bar_x = self.board_x + self.half_width
78
+ bar_center_x = bar_x + self.bar_width / 2
79
+ y = self.board_y + self.point_height + checker_index * (self.checker_radius * 2 + 2)
80
+ return (bar_center_x, y)
81
+
82
+ if point_num == 25: # O's bar (bottom)
83
+ bar_x = self.board_x + self.half_width
84
+ bar_center_x = bar_x + self.bar_width / 2
85
+ y = (self.board_y + self.board_height_inner - self.point_height -
86
+ checker_index * (self.checker_radius * 2 + 2))
87
+ return (bar_center_x, y)
88
+
89
+ # Handle bear-off
90
+ if point_num == -1:
91
+ bearoff_x = self.board_x + self.playing_width + 50
92
+ checker_height = 50
93
+
94
+ if player == Player.X:
95
+ tray_bottom = self.board_y + self.board_height_inner / 2 - 10
96
+ bearoff_y = tray_bottom - 10 - checker_height
97
+ else:
98
+ tray_bottom = self.board_y + self.board_height_inner - 10
99
+ bearoff_y = tray_bottom - 10 - checker_height
100
+
101
+ return (bearoff_x, bearoff_y)
102
+
103
+ if point_num < 1 or point_num > 24:
104
+ raise ValueError(f"Invalid point number: {point_num}")
105
+
106
+ x, y_base, is_top = self._get_point_position(point_num)
107
+
108
+ if is_top:
109
+ y = y_base + self.checker_radius + checker_index * (self.checker_radius * 2 + 2)
110
+ else:
111
+ y = y_base - self.checker_radius - checker_index * (self.checker_radius * 2 + 2)
112
+
113
+ cx = x + self.point_width / 2
114
+
115
+ return (cx, y)
116
+
117
+ def _get_point_position(self, point_idx: int) -> Tuple[float, float, bool]:
118
+ """
119
+ Get the x, y position and orientation of a point.
120
+
121
+ Returns:
122
+ (x, y_base, is_top) where is_top indicates if point extends from top
123
+ """
124
+ if point_idx <= 6:
125
+ x = self.board_x + self.half_width + self.bar_width + (6 - point_idx) * self.point_width
126
+ y_base = self.board_y + self.board_height_inner
127
+ is_top = False
128
+ elif point_idx <= 12:
129
+ x = self.board_x + (12 - point_idx) * self.point_width
130
+ y_base = self.board_y + self.board_height_inner
131
+ is_top = False
132
+ elif point_idx <= 18:
133
+ x = self.board_x + (point_idx - 13) * self.point_width
134
+ y_base = self.board_y
135
+ is_top = True
136
+ else:
137
+ x = self.board_x + self.half_width + self.bar_width + (point_idx - 19) * self.point_width
138
+ y_base = self.board_y
139
+ is_top = True
140
+
141
+ return x, y_base, is_top
142
+
143
+ def generate_animation_script(
144
+ self,
145
+ from_position: Position,
146
+ to_position: Position,
147
+ move_notation: str,
148
+ on_roll: Player,
149
+ animation_style: str = "arc",
150
+ duration: float = 0.8,
151
+ svg_id_original: str = "original-svg",
152
+ svg_id_result: str = "result-svg"
153
+ ) -> str:
154
+ """
155
+ Generate JavaScript for animating a move.
156
+
157
+ Args:
158
+ from_position: Starting position
159
+ to_position: Ending position
160
+ move_notation: Move notation (e.g., "24/23 24/23")
161
+ on_roll: Player making the move
162
+ animation_style: 'fade', 'arc', or 'none'
163
+ duration: Animation duration in seconds
164
+ svg_id_original: ID of original position SVG container
165
+ svg_id_result: ID of result position SVG container
166
+
167
+ Returns:
168
+ JavaScript code as string
169
+ """
170
+ if animation_style == "none":
171
+ return ""
172
+
173
+ if animation_style == "fade":
174
+ return self._generate_fade_animation(
175
+ svg_id_original, svg_id_result, duration
176
+ )
177
+
178
+ if animation_style == "arc":
179
+ return self._generate_arc_animation(
180
+ from_position, to_position, move_notation, on_roll,
181
+ duration, svg_id_original, svg_id_result
182
+ )
183
+
184
+ return ""
185
+
186
+ def _generate_fade_animation(
187
+ self,
188
+ svg_id_original: str,
189
+ svg_id_result: str,
190
+ duration: float
191
+ ) -> str:
192
+ """Generate simple cross-fade animation JavaScript."""
193
+ return f"""
194
+ // Cross-fade animation
195
+ function animatePositionTransition() {{
196
+ const originalSvg = document.getElementById('{svg_id_original}');
197
+ const resultSvg = document.getElementById('{svg_id_result}');
198
+
199
+ if (!originalSvg || !resultSvg) return;
200
+
201
+ // Set up initial state
202
+ originalSvg.style.opacity = '1';
203
+ originalSvg.style.transition = 'opacity {duration}s ease-in-out';
204
+ resultSvg.style.opacity = '0';
205
+ resultSvg.style.transition = 'opacity {duration}s ease-in-out';
206
+ resultSvg.style.display = 'block';
207
+
208
+ // Trigger fade
209
+ setTimeout(() => {{
210
+ originalSvg.style.opacity = '0';
211
+ resultSvg.style.opacity = '1';
212
+ }}, 50);
213
+
214
+ // Clean up
215
+ setTimeout(() => {{
216
+ originalSvg.style.display = 'none';
217
+ }}, {duration * 1000 + 100});
218
+ }}
219
+ """
220
+
221
+ def _generate_arc_animation(
222
+ self,
223
+ from_position: Position,
224
+ to_position: Position,
225
+ move_notation: str,
226
+ on_roll: Player,
227
+ duration: float,
228
+ svg_id_original: str,
229
+ svg_id_result: str
230
+ ) -> str:
231
+ """Generate GSAP-based arc animation JavaScript."""
232
+ moves = AnimationHelper.parse_move_notation(move_notation, on_roll)
233
+
234
+ if not moves:
235
+ return self._generate_fade_animation(svg_id_original, svg_id_result, duration)
236
+
237
+ # Calculate movement coordinates
238
+ movements = []
239
+ for from_point, to_point in moves:
240
+ checker_index = 0
241
+
242
+ start_x, start_y = self.get_point_coordinates(from_point, checker_index)
243
+ end_x, end_y = self.get_point_coordinates(to_point, checker_index, player=on_roll if to_point == -1 else None)
244
+
245
+ # Calculate arc control point
246
+ mid_x = (start_x + end_x) / 2
247
+ mid_y = min(start_y, end_y) - 80
248
+
249
+ movements.append({
250
+ 'from_point': from_point,
251
+ 'to_point': to_point,
252
+ 'start': [start_x, start_y],
253
+ 'control': [mid_x, mid_y],
254
+ 'end': [end_x, end_y],
255
+ })
256
+
257
+ movements_json = json.dumps(movements)
258
+ stagger = 0.05 # Delay between multiple checker animations
259
+
260
+ return f"""
261
+ // GSAP arc animation
262
+ if (typeof gsap !== 'undefined') {{
263
+ gsap.registerPlugin(MotionPathPlugin);
264
+
265
+ const movements = {movements_json};
266
+ const duration = {duration};
267
+ const stagger = {stagger};
268
+
269
+ function animatePositionTransition() {{
270
+ const originalSvg = document.getElementById('{svg_id_original}');
271
+ const resultSvg = document.getElementById('{svg_id_result}');
272
+
273
+ if (!originalSvg || !resultSvg) return;
274
+
275
+ // Clone the original SVG for animation
276
+ const animSvg = originalSvg.cloneNode(true);
277
+ animSvg.id = 'animation-svg';
278
+ animSvg.style.position = 'absolute';
279
+ animSvg.style.top = '0';
280
+ animSvg.style.left = '0';
281
+ animSvg.style.width = '100%';
282
+ animSvg.style.height = '100%';
283
+
284
+ originalSvg.parentNode.style.position = 'relative';
285
+ originalSvg.parentNode.appendChild(animSvg);
286
+
287
+ // Hide result initially
288
+ resultSvg.style.opacity = '0';
289
+ resultSvg.style.display = 'block';
290
+
291
+ // Animate each checker movement
292
+ const timeline = gsap.timeline({{
293
+ onComplete: function() {{
294
+ // Show final position
295
+ resultSvg.style.transition = 'opacity 0.2s';
296
+ resultSvg.style.opacity = '1';
297
+
298
+ // Remove animation SVG
299
+ setTimeout(() => {{
300
+ if (animSvg.parentNode) {{
301
+ animSvg.parentNode.removeChild(animSvg);
302
+ }}
303
+ originalSvg.style.display = 'none';
304
+ }}, 200);
305
+ }}
306
+ }});
307
+
308
+ movements.forEach((movement, index) => {{
309
+ // Find checkers at from_point in the animation SVG
310
+ const checkers = animSvg.querySelectorAll('.checker[data-point="' + movement.from_point + '"]');
311
+
312
+ if (checkers.length > 0) {{
313
+ const checker = checkers[0]; // Animate first (top) checker
314
+
315
+ // Create path for arc movement
316
+ const path = [
317
+ {{ x: movement.start[0], y: movement.start[1] }},
318
+ {{ x: movement.control[0], y: movement.control[1] }},
319
+ {{ x: movement.end[0], y: movement.end[1] }}
320
+ ];
321
+
322
+ timeline.to(checker, {{
323
+ duration: duration,
324
+ motionPath: {{
325
+ path: path,
326
+ type: 'soft',
327
+ autoRotate: false
328
+ }},
329
+ ease: 'power2.inOut'
330
+ }}, index * stagger);
331
+ }}
332
+ }});
333
+ }}
334
+ }} else {{
335
+ // GSAP not available, fall back to fade
336
+ function animatePositionTransition() {{
337
+ const originalSvg = document.getElementById('{svg_id_original}');
338
+ const resultSvg = document.getElementById('{svg_id_result}');
339
+
340
+ if (!originalSvg || !resultSvg) return;
341
+
342
+ originalSvg.style.transition = 'opacity {duration}s';
343
+ resultSvg.style.transition = 'opacity {duration}s';
344
+ resultSvg.style.display = 'block';
345
+ resultSvg.style.opacity = '0';
346
+
347
+ setTimeout(() => {{
348
+ originalSvg.style.opacity = '0';
349
+ resultSvg.style.opacity = '1';
350
+ }}, 50);
351
+
352
+ setTimeout(() => {{
353
+ originalSvg.style.display = 'none';
354
+ }}, {duration * 1000 + 100});
355
+ }}
356
+ }}
357
+ """
358
+
359
+ def get_gsap_cdn_urls(self) -> List[str]:
360
+ """
361
+ Get GSAP CDN URLs for inclusion in card HTML.
362
+
363
+ Returns:
364
+ List of CDN URLs
365
+ """
366
+ return [
367
+ "https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js",
368
+ "https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js"
369
+ ]
370
+
371
+ def generate_trigger_button_html(
372
+ self,
373
+ button_text: str = "▶ Animate Move",
374
+ button_class: str = "animate-btn"
375
+ ) -> str:
376
+ """
377
+ Generate HTML for animation trigger button.
378
+
379
+ Args:
380
+ button_text: Text to display on button
381
+ button_class: CSS class for button
382
+
383
+ Returns:
384
+ HTML string
385
+ """
386
+ return f"""
387
+ <div class="animation-controls" style="text-align: center; margin: 10px 0;">
388
+ <button onclick="if(typeof animatePositionTransition === 'function') animatePositionTransition();"
389
+ class="{button_class}">{button_text}</button>
390
+ </div>
391
+ """
@@ -0,0 +1,191 @@
1
+ """Helper for generating GSAP-based checker movement animations."""
2
+
3
+ import re
4
+ from typing import List, Tuple, Dict, Optional
5
+ from ankigammon.models import Position, Move, Player
6
+ from ankigammon.utils.move_parser import MoveParser
7
+
8
+
9
+ class AnimationHelper:
10
+ """Generates animation data and JavaScript for checker movements."""
11
+
12
+ @staticmethod
13
+ def parse_move_notation(notation: str, on_roll: Player) -> List[Tuple[int, int]]:
14
+ """
15
+ Parse backgammon move notation into (from_point, to_point) pairs.
16
+
17
+ Args:
18
+ notation: Move notation (e.g., "24/23 24/23" or "bar/24")
19
+ on_roll: Player making the move
20
+
21
+ Returns:
22
+ List of (from_point, to_point) tuples (0-25, where 0=X bar, 25=O bar)
23
+ """
24
+ moves = []
25
+
26
+ if not notation or notation == "Can't move":
27
+ return moves
28
+
29
+ parts = notation.strip().split()
30
+
31
+ for part in parts:
32
+ if '/' not in part:
33
+ continue
34
+
35
+ # Handle repetition notation like "6/4(4)"
36
+ repetition_count = 1
37
+ repetition_match = re.search(r'\((\d+)\)$', part)
38
+ if repetition_match:
39
+ repetition_count = int(repetition_match.group(1))
40
+ part = re.sub(r'\(\d+\)$', '', part)
41
+
42
+ # Handle compound notation like "6/5*/3"
43
+ segments = part.split('/')
44
+ segments = [seg.rstrip('*') for seg in segments]
45
+ if len(segments) > 2:
46
+ # Convert compound notation to individual moves
47
+ for i in range(len(segments) - 1):
48
+ from_str = segments[i]
49
+ to_str = segments[i + 1]
50
+ if from_str.lower() == 'bar':
51
+ from_point = 0 if on_roll == Player.X else 25
52
+ elif from_str.lower() == 'off':
53
+ continue
54
+ else:
55
+ try:
56
+ from_point = int(from_str)
57
+ except ValueError:
58
+ continue
59
+
60
+ if to_str.lower() == 'bar':
61
+ to_point = 25 if on_roll == Player.X else 0
62
+ elif to_str.lower() == 'off':
63
+ to_point = -1
64
+ else:
65
+ try:
66
+ to_point = int(to_str)
67
+ except ValueError:
68
+ continue
69
+
70
+ for _ in range(repetition_count):
71
+ moves.append((from_point, to_point))
72
+ else:
73
+ from_str = segments[0]
74
+ to_str = segments[1] if len(segments) > 1 else ''
75
+
76
+ if not to_str:
77
+ continue
78
+
79
+ if from_str.lower() == 'bar':
80
+ from_point = 0 if on_roll == Player.X else 25
81
+ elif from_str.lower() == 'off':
82
+ continue
83
+ else:
84
+ try:
85
+ from_point = int(from_str)
86
+ except ValueError:
87
+ continue
88
+
89
+ if to_str.lower() == 'bar':
90
+ to_point = 25 if on_roll == Player.X else 0
91
+ elif to_str.lower() == 'off':
92
+ to_point = -1
93
+ else:
94
+ try:
95
+ to_point = int(to_str)
96
+ except ValueError:
97
+ continue
98
+
99
+ for _ in range(repetition_count):
100
+ moves.append((from_point, to_point))
101
+
102
+ return moves
103
+
104
+ @staticmethod
105
+ def generate_animation_javascript(
106
+ move_notation: str,
107
+ on_roll: Player,
108
+ duration: float = 0.8,
109
+ stagger: float = 0.05
110
+ ) -> str:
111
+ """
112
+ Generate JavaScript code for animating a backgammon move using GSAP.
113
+
114
+ Args:
115
+ move_notation: Move notation string
116
+ on_roll: Player making the move
117
+ duration: Animation duration in seconds
118
+ stagger: Stagger time between checkers in seconds
119
+
120
+ Returns:
121
+ JavaScript code string
122
+ """
123
+ moves = AnimationHelper.parse_move_notation(move_notation, on_roll)
124
+
125
+ if not moves:
126
+ return ""
127
+
128
+ js_code = f"""
129
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
130
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js"></script>
131
+ <script>
132
+ if (typeof gsap !== 'undefined') {{
133
+ gsap.registerPlugin(MotionPathPlugin);
134
+
135
+ const moves = {moves};
136
+ const duration = {duration};
137
+ const stagger = {stagger};
138
+
139
+ function animateMove() {{
140
+ moves.forEach((move, index) => {{
141
+ const [fromPoint, toPoint] = move;
142
+ const checkers = document.querySelectorAll('.checker[data-point="' + fromPoint + '"]');
143
+
144
+ if (checkers.length > 0) {{
145
+ const checker = checkers[0];
146
+
147
+ gsap.to(checker, {{
148
+ duration: duration,
149
+ delay: index * stagger,
150
+ attr: {{
151
+ cx: '+=100',
152
+ cy: '+=0'
153
+ }},
154
+ ease: 'power2.inOut'
155
+ }});
156
+ }}
157
+ }});
158
+ }}
159
+ }}
160
+ </script>
161
+ """
162
+ return js_code
163
+
164
+ @staticmethod
165
+ def get_gsap_cdn_scripts() -> str:
166
+ """
167
+ Get GSAP CDN script tags.
168
+
169
+ Returns:
170
+ HTML script tags for GSAP and MotionPathPlugin
171
+ """
172
+ return """
173
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
174
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js"></script>
175
+ """
176
+
177
+ @staticmethod
178
+ def generate_animation_button() -> str:
179
+ """
180
+ Generate HTML for an animation trigger button.
181
+
182
+ Returns:
183
+ HTML button element
184
+ """
185
+ return """
186
+ <div style="text-align: center; margin: 10px 0;">
187
+ <button onclick="animateCheckers()" class="toggle-btn">
188
+ ▶ Animate Move
189
+ </button>
190
+ </div>
191
+ """