@graph-render/tournament-tree 1.0.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 (50) hide show
  1. package/.eslintrc.json +6 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +0 -0
  4. package/dist/index.js +2258 -0
  5. package/dist/src/components/BracketToolbar.d.ts +11 -0
  6. package/dist/src/components/BracketToolbar.d.ts.map +1 -0
  7. package/dist/src/components/SquashNode.d.ts +10 -0
  8. package/dist/src/components/SquashNode.d.ts.map +1 -0
  9. package/dist/src/components/TournamentBracket.d.ts +4 -0
  10. package/dist/src/components/TournamentBracket.d.ts.map +1 -0
  11. package/dist/src/constants/index.d.ts +3 -0
  12. package/dist/src/constants/index.d.ts.map +1 -0
  13. package/dist/src/constants/node.d.ts +127 -0
  14. package/dist/src/constants/node.d.ts.map +1 -0
  15. package/dist/src/constants/tournament.d.ts +4 -0
  16. package/dist/src/constants/tournament.d.ts.map +1 -0
  17. package/dist/src/contexts/BracketThemeContext.d.ts +16 -0
  18. package/dist/src/contexts/BracketThemeContext.d.ts.map +1 -0
  19. package/dist/src/index.d.ts +9 -0
  20. package/dist/src/index.d.ts.map +1 -0
  21. package/dist/src/types/index.d.ts +3 -0
  22. package/dist/src/types/index.d.ts.map +1 -0
  23. package/dist/src/types/squash.d.ts +18 -0
  24. package/dist/src/types/squash.d.ts.map +1 -0
  25. package/dist/src/types/tournament.d.ts +13 -0
  26. package/dist/src/types/tournament.d.ts.map +1 -0
  27. package/dist/src/utils/pathKeys.d.ts +8 -0
  28. package/dist/src/utils/pathKeys.d.ts.map +1 -0
  29. package/dist/src/utils/roundLabels.d.ts +16 -0
  30. package/dist/src/utils/roundLabels.d.ts.map +1 -0
  31. package/dist/tsconfig.tsbuildinfo +1 -0
  32. package/index.html +18 -0
  33. package/package.json +51 -0
  34. package/project.json +60 -0
  35. package/src/components/BracketToolbar.tsx +135 -0
  36. package/src/components/SquashNode.tsx +813 -0
  37. package/src/components/TournamentBracket.tsx +992 -0
  38. package/src/constants/index.ts +2 -0
  39. package/src/constants/node.ts +96 -0
  40. package/src/constants/tournament.ts +54 -0
  41. package/src/contexts/BracketThemeContext.tsx +35 -0
  42. package/src/index.ts +12 -0
  43. package/src/types/index.ts +2 -0
  44. package/src/types/squash.ts +21 -0
  45. package/src/types/tournament.ts +14 -0
  46. package/src/utils/pathKeys.ts +50 -0
  47. package/src/utils/roundLabels.ts +110 -0
  48. package/tsconfig.json +19 -0
  49. package/tsconfig.node.json +11 -0
  50. package/vite.config.ts +21 -0
@@ -0,0 +1,992 @@
1
+ import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
2
+ import { flushSync } from 'react-dom';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { Graph, groupPositionedNodesByColumn } from '@graph-render/react';
5
+ import type {
6
+ GraphConfig,
7
+ GraphHandle,
8
+ GraphRenderContext,
9
+ GraphViewport,
10
+ PositionedNode,
11
+ VertexComponentProps,
12
+ } from '@graph-render/types';
13
+ import type { TournamentBracketProps } from '../types';
14
+ import { DARK_TOURNAMENT_CONFIG, DEFAULT_TOURNAMENT_CONFIG, NODE_DIMENSIONS } from '../constants';
15
+ import { SquashNode } from './SquashNode';
16
+ import { BracketToolbar } from './BracketToolbar';
17
+ import { BracketThemeProvider, useBracketTheme } from '../contexts/BracketThemeContext';
18
+ import { injectTournamentPathKeys } from '../utils/pathKeys';
19
+ import { roundLabelsForGraph } from '../utils/roundLabels';
20
+
21
+ const STAGE_LABEL_HEIGHT = 20;
22
+ const NAVIGATION_STAGE_PADDING_X = 52;
23
+ const NAVIGATION_STAGE_PADDING_Y = 44;
24
+ const NAVIGATION_STAGE_MIN_WIDTH = 420;
25
+ const NAVIGATION_STAGE_MIN_HEIGHT = 250;
26
+ const NAVIGATION_MIN_ZOOM = 0.45;
27
+ const NAVIGATION_MAX_ZOOM = 2.1;
28
+
29
+ type StageBounds = {
30
+ minX: number;
31
+ minY: number;
32
+ maxX: number;
33
+ maxY: number;
34
+ width: number;
35
+ height: number;
36
+ };
37
+
38
+ type StageView = {
39
+ index: number;
40
+ label: string;
41
+ bounds: StageBounds;
42
+ nodeIds: string[];
43
+ };
44
+
45
+ type VerticalStagePosition = 'top' | 'bottom';
46
+
47
+ type StageViewportResult = {
48
+ viewport: GraphViewport;
49
+ canPageVertically: boolean;
50
+ };
51
+
52
+ type StageSyncProps = {
53
+ context: GraphRenderContext;
54
+ labels: string[];
55
+ labelOffset: number;
56
+ onStagesChange: (stages: StageView[]) => void;
57
+ };
58
+
59
+ const ChevronLeftIcon = ({ color }: { color: string }) => (
60
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
61
+ <path
62
+ d="m14.5 5-7 7 7 7"
63
+ stroke={color}
64
+ strokeWidth="2.1"
65
+ strokeLinecap="round"
66
+ strokeLinejoin="round"
67
+ />
68
+ </svg>
69
+ );
70
+
71
+ const ChevronRightIcon = ({ color }: { color: string }) => (
72
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
73
+ <path
74
+ d="m9.5 5 7 7-7 7"
75
+ stroke={color}
76
+ strokeWidth="2.1"
77
+ strokeLinecap="round"
78
+ strokeLinejoin="round"
79
+ />
80
+ </svg>
81
+ );
82
+
83
+ const ChevronUpIcon = ({ color }: { color: string }) => (
84
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
85
+ <path
86
+ d="m5 14.5 7-7 7 7"
87
+ stroke={color}
88
+ strokeWidth="2.1"
89
+ strokeLinecap="round"
90
+ strokeLinejoin="round"
91
+ />
92
+ </svg>
93
+ );
94
+
95
+ const ChevronDownIcon = ({ color }: { color: string }) => (
96
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
97
+ <path
98
+ d="m5 9.5 7 7 7-7"
99
+ stroke={color}
100
+ strokeWidth="2.1"
101
+ strokeLinecap="round"
102
+ strokeLinejoin="round"
103
+ />
104
+ </svg>
105
+ );
106
+
107
+ const StageNavigationIcon = ({ color }: { color: string }) => (
108
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
109
+ <rect x="3.5" y="5" width="17" height="14" rx="3" stroke={color} strokeWidth="1.8" />
110
+ <path d="M8.5 5v14M15.5 5v14" stroke={color} strokeWidth="1.4" opacity="0.72" />
111
+ <circle cx="12" cy="12" r="2.2" fill={color} />
112
+ </svg>
113
+ );
114
+
115
+ function buildStageViews(
116
+ nodes: PositionedNode[],
117
+ labels: string[],
118
+ labelOffset: number
119
+ ): StageView[] {
120
+ return groupPositionedNodesByColumn(nodes).map((column, index) => {
121
+ const columnNodes = column.nodes;
122
+ const minX = Math.min(...columnNodes.map((node) => node.position.x));
123
+ const minY = Math.min(...columnNodes.map((node) => node.position.y));
124
+ const maxX = Math.max(
125
+ ...columnNodes.map((node) => node.position.x + (node.size?.width ?? NODE_DIMENSIONS.WIDTH))
126
+ );
127
+ const maxY = Math.max(
128
+ ...columnNodes.map((node) => node.position.y + (node.size?.height ?? NODE_DIMENSIONS.HEIGHT))
129
+ );
130
+
131
+ const bounds = {
132
+ minX,
133
+ minY: minY - labelOffset - STAGE_LABEL_HEIGHT + 6,
134
+ maxX,
135
+ maxY,
136
+ width: maxX - minX,
137
+ height: maxY - (minY - labelOffset - STAGE_LABEL_HEIGHT + 6),
138
+ };
139
+
140
+ return {
141
+ index,
142
+ label: labels[index] ?? `STAGE ${index + 1}`,
143
+ bounds,
144
+ nodeIds: columnNodes.map((node) => node.id),
145
+ };
146
+ });
147
+ }
148
+
149
+ function getStageViewport(
150
+ bounds: StageBounds,
151
+ width: number,
152
+ height: number,
153
+ verticalPosition: VerticalStagePosition = 'top'
154
+ ): StageViewportResult {
155
+ const targetWidth = Math.max(
156
+ bounds.width + NAVIGATION_STAGE_PADDING_X * 2,
157
+ NAVIGATION_STAGE_MIN_WIDTH
158
+ );
159
+ const targetHeight = Math.max(
160
+ bounds.height + NAVIGATION_STAGE_PADDING_Y * 2,
161
+ NAVIGATION_STAGE_MIN_HEIGHT
162
+ );
163
+ const zoom = Math.min(
164
+ NAVIGATION_MAX_ZOOM,
165
+ Math.max(NAVIGATION_MIN_ZOOM, Math.min(width / targetWidth, height / targetHeight))
166
+ );
167
+
168
+ const visibleWorldHeight = height / zoom;
169
+ const minTop = bounds.minY - NAVIGATION_STAGE_PADDING_Y;
170
+ const maxTop = bounds.maxY + NAVIGATION_STAGE_PADDING_Y - visibleWorldHeight;
171
+ const canPageVertically = maxTop > minTop + 1;
172
+ const centeredTop = bounds.minY + bounds.height / 2 - visibleWorldHeight / 2;
173
+ const topWorld = canPageVertically
174
+ ? verticalPosition === 'bottom'
175
+ ? maxTop
176
+ : minTop
177
+ : centeredTop;
178
+
179
+ return {
180
+ canPageVertically,
181
+ viewport: {
182
+ zoom,
183
+ x: (width - bounds.width * zoom) / 2 - bounds.minX * zoom,
184
+ y: -topWorld * zoom,
185
+ },
186
+ };
187
+ }
188
+
189
+ function GraphStageSync({ context, labels, labelOffset, onStagesChange }: StageSyncProps) {
190
+ const stages = useMemo(
191
+ () => buildStageViews(context.nodes, labels, labelOffset),
192
+ [context.nodes, labelOffset, labels]
193
+ );
194
+
195
+ useEffect(() => {
196
+ onStagesChange(stages);
197
+ }, [onStagesChange, stages]);
198
+
199
+ return null;
200
+ }
201
+
202
+ const TrophyIcon = () => (
203
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
204
+ <path d="M8 3h8v3a4 4 0 0 1-8 0V3Z" fill="currentColor" />
205
+ <path
206
+ d="M6 5H4a2 2 0 0 0 2 2M18 5h2a2 2 0 0 1-2 2"
207
+ stroke="currentColor"
208
+ strokeWidth="1.8"
209
+ strokeLinecap="round"
210
+ strokeLinejoin="round"
211
+ />
212
+ <path
213
+ d="M12 10v4M9 21h6M10 18h4"
214
+ stroke="currentColor"
215
+ strokeWidth="1.8"
216
+ strokeLinecap="round"
217
+ />
218
+ </svg>
219
+ );
220
+
221
+ function BracketFrame({
222
+ children,
223
+ title,
224
+ badgeText,
225
+ stageLabels,
226
+ isDarkMode,
227
+ isNavigationMode,
228
+ stageViews,
229
+ activeStageIndex,
230
+ verticalStagePosition,
231
+ canPagePlayersVertically,
232
+ contentViewportRef,
233
+ showToolbar,
234
+ onToggleNavigationMode,
235
+ onSelectStage,
236
+ onPreviousStage,
237
+ onNextStage,
238
+ onPagePlayersUp,
239
+ onPagePlayersDown,
240
+ onToggleDarkMode,
241
+ onExportSVG,
242
+ }: {
243
+ children: React.ReactNode;
244
+ title: string;
245
+ badgeText: string;
246
+ stageLabels: string[];
247
+ isDarkMode: boolean;
248
+ isNavigationMode: boolean;
249
+ stageViews: StageView[];
250
+ activeStageIndex: number;
251
+ verticalStagePosition: VerticalStagePosition;
252
+ canPagePlayersVertically: boolean;
253
+ contentViewportRef: React.RefObject<HTMLDivElement>;
254
+ showToolbar: boolean;
255
+ onToggleNavigationMode: () => void;
256
+ onSelectStage: (index: number) => void;
257
+ onPreviousStage: () => void;
258
+ onNextStage: () => void;
259
+ onPagePlayersUp: () => void;
260
+ onPagePlayersDown: () => void;
261
+ onToggleDarkMode: () => void;
262
+ onExportSVG: () => void;
263
+ }) {
264
+ const { colors } = useBracketTheme();
265
+ const navButtonTextColor = isDarkMode ? '#f7f5ef' : '#3f4a38';
266
+ const navButtonBg = isDarkMode ? 'rgba(35, 43, 51, 0.92)' : 'rgba(255, 255, 255, 0.92)';
267
+ const navButtonBorder = isDarkMode ? '#46505c' : '#ddd7cb';
268
+ const floatingControlBg = isDarkMode ? 'rgba(35, 43, 51, 0.94)' : 'rgba(255, 255, 255, 0.94)';
269
+ const floatingControlBorder = isDarkMode ? '#38424d' : '#e5dfd4';
270
+ const floatingControlText = isNavigationMode
271
+ ? isDarkMode
272
+ ? '#f4f8f1'
273
+ : '#516347'
274
+ : isDarkMode
275
+ ? '#d8d2c7'
276
+ : '#59606c';
277
+ const canGoPrev = activeStageIndex > 0;
278
+ const canGoNext = activeStageIndex < stageViews.length - 1;
279
+ const canPageUp = canPagePlayersVertically && verticalStagePosition === 'bottom';
280
+ const canPageDown = canPagePlayersVertically && verticalStagePosition === 'top';
281
+
282
+ return (
283
+ <div
284
+ style={{
285
+ width: '100%',
286
+ maxWidth: 1180,
287
+ background: colors.SURFACE_BG,
288
+ borderRadius: 24,
289
+ boxShadow: colors.SHADOW,
290
+ overflow: 'hidden',
291
+ }}
292
+ >
293
+ <div
294
+ style={{
295
+ display: 'flex',
296
+ alignItems: 'center',
297
+ gap: 14,
298
+ minHeight: 72,
299
+ padding: '0 32px',
300
+ background: colors.HEADER_BG,
301
+ borderBottom: `1px solid ${colors.HEADER_BORDER}`,
302
+ }}
303
+ >
304
+ <div
305
+ style={{
306
+ width: 30,
307
+ height: 30,
308
+ borderRadius: 8,
309
+ display: 'grid',
310
+ placeItems: 'center',
311
+ background: colors.ICON_BG,
312
+ color: colors.ICON_FG,
313
+ flexShrink: 0,
314
+ }}
315
+ >
316
+ <TrophyIcon />
317
+ </div>
318
+ <div
319
+ style={{
320
+ fontFamily: '"Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif',
321
+ fontSize: 18,
322
+ fontWeight: 600,
323
+ color: colors.HEADER_TITLE,
324
+ }}
325
+ >
326
+ {title}
327
+ </div>
328
+ <div style={{ flex: 1 }} />
329
+ <div
330
+ style={{
331
+ display: 'flex',
332
+ alignItems: 'center',
333
+ gap: 6,
334
+ minHeight: 28,
335
+ padding: '0 14px',
336
+ borderRadius: 999,
337
+ background: colors.BADGE_BG,
338
+ color: colors.BADGE_TEXT,
339
+ fontFamily: '"Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif',
340
+ fontSize: 11,
341
+ fontWeight: 600,
342
+ letterSpacing: '0.02em',
343
+ whiteSpace: 'nowrap',
344
+ }}
345
+ >
346
+ <span
347
+ style={{
348
+ width: 6,
349
+ height: 6,
350
+ borderRadius: '50%',
351
+ background: colors.BADGE_DOT,
352
+ flexShrink: 0,
353
+ }}
354
+ />
355
+ {badgeText}
356
+ </div>
357
+ {showToolbar ? (
358
+ <BracketToolbar
359
+ isDarkMode={isDarkMode}
360
+ isNavigationMode={isNavigationMode}
361
+ onToggleNavigationMode={onToggleNavigationMode}
362
+ onToggleDarkMode={onToggleDarkMode}
363
+ onExportSVG={onExportSVG}
364
+ />
365
+ ) : null}
366
+ </div>
367
+
368
+ {stageLabels.length ? (
369
+ <div
370
+ style={{
371
+ padding: '14px 32px 12px',
372
+ background: isDarkMode ? '#20262d' : '#fbfaf7',
373
+ borderBottom: `1px solid ${colors.HEADER_BORDER}`,
374
+ }}
375
+ >
376
+ <div
377
+ style={{
378
+ display: 'grid',
379
+ gridTemplateColumns: `repeat(${stageLabels.length}, minmax(0, 1fr))`,
380
+ gap: 24,
381
+ alignItems: 'center',
382
+ }}
383
+ >
384
+ {stageLabels.map((label, index) => {
385
+ const isActiveStage = isNavigationMode && index === activeStageIndex;
386
+
387
+ return (
388
+ <div
389
+ key={label}
390
+ style={{
391
+ display: 'grid',
392
+ justifyItems: 'center',
393
+ gap: 8,
394
+ minWidth: 0,
395
+ padding: '6px 10px',
396
+ borderRadius: 14,
397
+ background:
398
+ isActiveStage && isDarkMode
399
+ ? 'rgba(216, 210, 199, 0.08)'
400
+ : isActiveStage
401
+ ? 'rgba(124, 144, 112, 0.08)'
402
+ : 'transparent',
403
+ }}
404
+ >
405
+ <div
406
+ style={{
407
+ width: 40,
408
+ height: 1,
409
+ background: isActiveStage
410
+ ? isDarkMode
411
+ ? 'rgba(247, 245, 239, 0.62)'
412
+ : 'rgba(68, 75, 85, 0.34)'
413
+ : isDarkMode
414
+ ? 'rgba(216, 210, 199, 0.24)'
415
+ : 'rgba(68, 75, 85, 0.16)',
416
+ }}
417
+ />
418
+ <div
419
+ style={{
420
+ fontFamily: '"Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif',
421
+ fontSize: 12,
422
+ fontWeight: 800,
423
+ letterSpacing: '0.08em',
424
+ textTransform: 'uppercase',
425
+ color: isActiveStage
426
+ ? isDarkMode
427
+ ? '#f7f5ef'
428
+ : '#2f3741'
429
+ : isDarkMode
430
+ ? '#d8d2c7'
431
+ : '#444b55',
432
+ textAlign: 'center',
433
+ whiteSpace: 'nowrap',
434
+ }}
435
+ >
436
+ {label}
437
+ </div>
438
+ </div>
439
+ );
440
+ })}
441
+ </div>
442
+ </div>
443
+ ) : null}
444
+
445
+ <div
446
+ ref={contentViewportRef}
447
+ style={{
448
+ position: 'relative',
449
+ padding: '12px 24px 24px',
450
+ overflowX: isNavigationMode ? 'hidden' : 'auto',
451
+ overflowY: 'hidden',
452
+ background: isDarkMode
453
+ ? 'radial-gradient(circle at top left, rgba(154, 176, 141, 0.08), transparent 28%), #191e24'
454
+ : 'radial-gradient(circle at top left, rgba(124, 144, 112, 0.08), transparent 28%), #f7f6f3',
455
+ }}
456
+ >
457
+ {children}
458
+
459
+ {showToolbar ? (
460
+ <div
461
+ style={{
462
+ position: 'absolute',
463
+ right: 18,
464
+ top: 18,
465
+ display: 'flex',
466
+ flexDirection: 'column',
467
+ gap: 8,
468
+ padding: 8,
469
+ borderRadius: 22,
470
+ border: `1px solid ${floatingControlBorder}`,
471
+ background: floatingControlBg,
472
+ boxShadow: isDarkMode
473
+ ? '0 18px 40px rgba(0, 0, 0, 0.28)'
474
+ : '0 18px 40px rgba(45, 45, 45, 0.12)',
475
+ }}
476
+ >
477
+ <button
478
+ type="button"
479
+ onClick={onToggleNavigationMode}
480
+ aria-pressed={isNavigationMode}
481
+ title={isNavigationMode ? 'Exit Navigation Mode' : 'Enter Navigation Mode'}
482
+ style={{
483
+ width: 48,
484
+ height: 48,
485
+ borderRadius: 16,
486
+ border: `1px solid ${isNavigationMode ? colors.ICON_BG : floatingControlBorder}`,
487
+ background: isNavigationMode ? colors.ICON_BG : 'transparent',
488
+ color: isNavigationMode ? colors.ICON_FG : floatingControlText,
489
+ display: 'grid',
490
+ placeItems: 'center',
491
+ cursor: 'pointer',
492
+ }}
493
+ >
494
+ <StageNavigationIcon
495
+ color={isNavigationMode ? colors.ICON_FG : floatingControlText}
496
+ />
497
+ </button>
498
+ </div>
499
+ ) : null}
500
+
501
+ {isNavigationMode && stageViews.length > 1 ? (
502
+ <>
503
+ <button
504
+ type="button"
505
+ onClick={onPreviousStage}
506
+ disabled={!canGoPrev}
507
+ aria-label="Go to previous stage"
508
+ style={{
509
+ position: 'absolute',
510
+ left: 14,
511
+ top: '50%',
512
+ transform: 'translateY(-50%)',
513
+ width: 42,
514
+ height: 42,
515
+ borderRadius: 999,
516
+ border: `1px solid ${navButtonBorder}`,
517
+ background: navButtonBg,
518
+ color: navButtonTextColor,
519
+ display: 'grid',
520
+ placeItems: 'center',
521
+ boxShadow: isDarkMode
522
+ ? '0 12px 30px rgba(0, 0, 0, 0.22)'
523
+ : '0 12px 30px rgba(45, 45, 45, 0.12)',
524
+ opacity: canGoPrev ? 1 : 0.45,
525
+ cursor: canGoPrev ? 'pointer' : 'default',
526
+ }}
527
+ >
528
+ <ChevronLeftIcon color={navButtonTextColor} />
529
+ </button>
530
+
531
+ <button
532
+ type="button"
533
+ onClick={onNextStage}
534
+ disabled={!canGoNext}
535
+ aria-label="Go to next stage"
536
+ style={{
537
+ position: 'absolute',
538
+ right: 14,
539
+ top: '50%',
540
+ transform: 'translateY(-50%)',
541
+ width: 42,
542
+ height: 42,
543
+ borderRadius: 999,
544
+ border: `1px solid ${navButtonBorder}`,
545
+ background: navButtonBg,
546
+ color: navButtonTextColor,
547
+ display: 'grid',
548
+ placeItems: 'center',
549
+ boxShadow: isDarkMode
550
+ ? '0 12px 30px rgba(0, 0, 0, 0.22)'
551
+ : '0 12px 30px rgba(45, 45, 45, 0.12)',
552
+ opacity: canGoNext ? 1 : 0.45,
553
+ cursor: canGoNext ? 'pointer' : 'default',
554
+ }}
555
+ >
556
+ <ChevronRightIcon color={navButtonTextColor} />
557
+ </button>
558
+
559
+ {canPagePlayersVertically ? (
560
+ <div
561
+ style={{
562
+ position: 'absolute',
563
+ right: 18,
564
+ bottom: 86,
565
+ display: 'flex',
566
+ flexDirection: 'column',
567
+ gap: 8,
568
+ }}
569
+ >
570
+ <button
571
+ type="button"
572
+ onClick={onPagePlayersUp}
573
+ disabled={!canPageUp}
574
+ aria-label="Show upper players"
575
+ style={{
576
+ width: 42,
577
+ height: 42,
578
+ borderRadius: 999,
579
+ border: `1px solid ${navButtonBorder}`,
580
+ background: navButtonBg,
581
+ color: navButtonTextColor,
582
+ display: 'grid',
583
+ placeItems: 'center',
584
+ boxShadow: isDarkMode
585
+ ? '0 12px 30px rgba(0, 0, 0, 0.22)'
586
+ : '0 12px 30px rgba(45, 45, 45, 0.12)',
587
+ opacity: canPageUp ? 1 : 0.45,
588
+ cursor: canPageUp ? 'pointer' : 'default',
589
+ }}
590
+ >
591
+ <ChevronUpIcon color={navButtonTextColor} />
592
+ </button>
593
+ <button
594
+ type="button"
595
+ onClick={onPagePlayersDown}
596
+ disabled={!canPageDown}
597
+ aria-label="Show lower players"
598
+ style={{
599
+ width: 42,
600
+ height: 42,
601
+ borderRadius: 999,
602
+ border: `1px solid ${navButtonBorder}`,
603
+ background: navButtonBg,
604
+ color: navButtonTextColor,
605
+ display: 'grid',
606
+ placeItems: 'center',
607
+ boxShadow: isDarkMode
608
+ ? '0 12px 30px rgba(0, 0, 0, 0.22)'
609
+ : '0 12px 30px rgba(45, 45, 45, 0.12)',
610
+ opacity: canPageDown ? 1 : 0.45,
611
+ cursor: canPageDown ? 'pointer' : 'default',
612
+ }}
613
+ >
614
+ <ChevronDownIcon color={navButtonTextColor} />
615
+ </button>
616
+ </div>
617
+ ) : null}
618
+
619
+ <div
620
+ style={{
621
+ position: 'absolute',
622
+ left: '50%',
623
+ bottom: 14,
624
+ transform: 'translateX(-50%)',
625
+ display: 'flex',
626
+ gap: 8,
627
+ alignItems: 'center',
628
+ maxWidth: 'calc(100% - 120px)',
629
+ padding: '8px 10px',
630
+ borderRadius: 999,
631
+ border: `1px solid ${navButtonBorder}`,
632
+ background: navButtonBg,
633
+ boxShadow: isDarkMode
634
+ ? '0 18px 38px rgba(0, 0, 0, 0.24)'
635
+ : '0 18px 38px rgba(45, 45, 45, 0.12)',
636
+ overflowX: 'auto',
637
+ }}
638
+ >
639
+ {stageViews.map((stage, index) => {
640
+ const isActive = index === activeStageIndex;
641
+
642
+ return (
643
+ <button
644
+ key={stage.label}
645
+ type="button"
646
+ onClick={() => onSelectStage(index)}
647
+ style={{
648
+ padding: '8px 14px',
649
+ borderRadius: 999,
650
+ border: `1px solid ${isActive ? colors.ICON_BG : navButtonBorder}`,
651
+ background: isActive ? colors.ICON_BG : 'transparent',
652
+ color: isActive ? colors.ICON_FG : colors.BADGE_TEXT,
653
+ fontFamily: '"Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif',
654
+ fontSize: 11,
655
+ fontWeight: 700,
656
+ letterSpacing: '0.04em',
657
+ whiteSpace: 'nowrap',
658
+ cursor: 'pointer',
659
+ }}
660
+ >
661
+ {stage.label}
662
+ </button>
663
+ );
664
+ })}
665
+ </div>
666
+ </>
667
+ ) : null}
668
+ </div>
669
+ </div>
670
+ );
671
+ }
672
+
673
+ export const TournamentBracket = React.memo<TournamentBracketProps>(function TournamentBracket({
674
+ graph,
675
+ config,
676
+ vertexComponent,
677
+ nodeRenderMode = 'export',
678
+ title = 'Tournament Bracket',
679
+ badgeText,
680
+ showToolbar = true,
681
+ onInvalidNode,
682
+ }) {
683
+ const wrapperRef = useRef<HTMLDivElement>(null);
684
+ const graphRef = useRef<GraphHandle>(null);
685
+ const contentViewportRef = useRef<HTMLDivElement>(null);
686
+ const [isDarkMode, setIsDarkMode] = useState(false);
687
+ const [isNavigationMode, setIsNavigationMode] = useState(false);
688
+ const [activeStageIndex, setActiveStageIndex] = useState(0);
689
+ const [stageViews, setStageViews] = useState<StageView[]>([]);
690
+ const [verticalStagePosition, setVerticalStagePosition] = useState<VerticalStagePosition>('top');
691
+ const [canPagePlayersVertically, setCanPagePlayersVertically] = useState(false);
692
+ const previousViewportRef = useRef<GraphViewport | null>(null);
693
+
694
+ const labels = useMemo(
695
+ () => config?.labels ?? roundLabelsForGraph(graph),
696
+ [config?.labels, graph]
697
+ );
698
+
699
+ const mergedConfig = useMemo(() => {
700
+ const { theme: themeOverride, ...restConfig } = config ?? {};
701
+ const baseConfig = isDarkMode ? DARK_TOURNAMENT_CONFIG : DEFAULT_TOURNAMENT_CONFIG;
702
+ const baseTheme = baseConfig.theme ?? {};
703
+
704
+ return {
705
+ ...baseConfig,
706
+ ...restConfig,
707
+ labels: undefined,
708
+ autoLabels: false,
709
+ theme: { ...baseTheme, ...(themeOverride ?? {}) },
710
+ } satisfies GraphConfig;
711
+ }, [config, labels, isDarkMode]);
712
+
713
+ const enrichedGraph = useMemo(() => {
714
+ const graphWithPaths = injectTournamentPathKeys(graph);
715
+
716
+ if (vertexComponent) {
717
+ return graphWithPaths;
718
+ }
719
+
720
+ const sizedNodes = Object.entries(graphWithPaths.nodes ?? {}).reduce<
721
+ NonNullable<typeof graphWithPaths.nodes>
722
+ >((acc, [nodeId, attrs]) => {
723
+ const size = attrs.size;
724
+
725
+ acc[nodeId] = {
726
+ ...attrs,
727
+ size: {
728
+ width: Math.max(size?.width ?? 0, NODE_DIMENSIONS.WIDTH),
729
+ height: Math.max(size?.height ?? 0, NODE_DIMENSIONS.HEIGHT),
730
+ },
731
+ };
732
+
733
+ return acc;
734
+ }, {});
735
+
736
+ return {
737
+ ...graphWithPaths,
738
+ nodes: sizedNodes,
739
+ };
740
+ }, [graph, vertexComponent]);
741
+
742
+ const resolvedBadgeText = useMemo(() => {
743
+ if (badgeText) {
744
+ return badgeText;
745
+ }
746
+
747
+ const nodeCount = Object.keys(enrichedGraph.nodes ?? {}).length;
748
+ const finalLabel = labels.at(-1) ?? 'FINAL';
749
+ return nodeCount > 0 ? `${finalLabel} · ${nodeCount} MATCHES` : finalLabel;
750
+ }, [badgeText, enrichedGraph.nodes, labels]);
751
+
752
+ const handleToggleDarkMode = useCallback(() => {
753
+ setIsDarkMode((prev) => !prev);
754
+ }, []);
755
+
756
+ const handleStagesChange = useCallback((nextStages: StageView[]) => {
757
+ setStageViews((prevStages) => {
758
+ const isSame =
759
+ prevStages.length === nextStages.length &&
760
+ prevStages.every((stage, index) => {
761
+ const nextStage = nextStages[index];
762
+ return (
763
+ stage.label === nextStage.label &&
764
+ stage.bounds.minX === nextStage.bounds.minX &&
765
+ stage.bounds.minY === nextStage.bounds.minY &&
766
+ stage.bounds.maxX === nextStage.bounds.maxX &&
767
+ stage.bounds.maxY === nextStage.bounds.maxY
768
+ );
769
+ });
770
+
771
+ return isSame ? prevStages : nextStages;
772
+ });
773
+ }, []);
774
+
775
+ const focusStage = useCallback(
776
+ (stageIndex: number) => {
777
+ const stage = stageViews[stageIndex];
778
+ const container = contentViewportRef.current;
779
+ if (!stage || !container || !graphRef.current) {
780
+ return;
781
+ }
782
+
783
+ const width = container.clientWidth || mergedConfig.width || 1600;
784
+ const height = container.clientHeight || mergedConfig.height || 1200;
785
+ const nextStageViewport = getStageViewport(
786
+ stage.bounds,
787
+ width,
788
+ height,
789
+ verticalStagePosition
790
+ );
791
+ setCanPagePlayersVertically(nextStageViewport.canPageVertically);
792
+ graphRef.current.setViewport(nextStageViewport.viewport);
793
+ },
794
+ [mergedConfig.height, mergedConfig.width, stageViews, verticalStagePosition]
795
+ );
796
+
797
+ const handleToggleNavigationMode = useCallback(() => {
798
+ if (isNavigationMode) {
799
+ const previousViewport = previousViewportRef.current;
800
+ if (previousViewport) {
801
+ graphRef.current?.setViewport(previousViewport);
802
+ } else {
803
+ graphRef.current?.fitView();
804
+ }
805
+ setIsNavigationMode(false);
806
+ return;
807
+ }
808
+
809
+ previousViewportRef.current = graphRef.current?.getViewport() ?? null;
810
+ setVerticalStagePosition('top');
811
+ setIsNavigationMode(true);
812
+ }, [isNavigationMode]);
813
+
814
+ const handleSelectStage = useCallback((stageIndex: number) => {
815
+ setVerticalStagePosition('top');
816
+ setActiveStageIndex(stageIndex);
817
+ }, []);
818
+
819
+ const handlePreviousStage = useCallback(() => {
820
+ setVerticalStagePosition('top');
821
+ setActiveStageIndex((prev) => Math.max(0, prev - 1));
822
+ }, []);
823
+
824
+ const handleNextStage = useCallback(() => {
825
+ setVerticalStagePosition('top');
826
+ setActiveStageIndex((prev) => Math.min(stageViews.length - 1, prev + 1));
827
+ }, [stageViews.length]);
828
+
829
+ const handlePagePlayersUp = useCallback(() => {
830
+ setVerticalStagePosition('top');
831
+ }, []);
832
+
833
+ const handlePagePlayersDown = useCallback(() => {
834
+ setVerticalStagePosition('bottom');
835
+ }, []);
836
+
837
+ useEffect(() => {
838
+ setActiveStageIndex((prev) => Math.min(prev, Math.max(stageViews.length - 1, 0)));
839
+ }, [stageViews.length]);
840
+
841
+ useEffect(() => {
842
+ if (!isNavigationMode || !stageViews.length) {
843
+ return;
844
+ }
845
+
846
+ focusStage(activeStageIndex);
847
+ }, [activeStageIndex, focusStage, isNavigationMode, stageViews.length]);
848
+
849
+ useEffect(() => {
850
+ if (!isNavigationMode) {
851
+ return;
852
+ }
853
+
854
+ const handleResize = () => focusStage(activeStageIndex);
855
+
856
+ window.addEventListener('resize', handleResize);
857
+ return () => window.removeEventListener('resize', handleResize);
858
+ }, [activeStageIndex, focusStage, isNavigationMode]);
859
+
860
+ const exportVertexComponent = useMemo(
861
+ () =>
862
+ vertexComponent ??
863
+ ((props: VertexComponentProps) => (
864
+ <SquashNode {...props} renderMode="export" onRenderError={onInvalidNode} />
865
+ )),
866
+ [onInvalidNode, vertexComponent]
867
+ );
868
+
869
+ const handleExportSVG = useCallback(() => {
870
+ const renderExportFromElement = (rootElement: Element | null) => {
871
+ const svgElement = rootElement?.querySelector('svg');
872
+ if (!svgElement) {
873
+ return;
874
+ }
875
+
876
+ const clonedSvg = svgElement.cloneNode(true) as SVGElement;
877
+ clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
878
+ clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
879
+
880
+ const serializer = new XMLSerializer();
881
+ const svgString = serializer.serializeToString(clonedSvg);
882
+ const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
883
+ const url = URL.createObjectURL(blob);
884
+ const link = document.createElement('a');
885
+ link.href = url;
886
+ link.download = `tournament-bracket-${Date.now()}.svg`;
887
+ document.body.appendChild(link);
888
+ link.click();
889
+ document.body.removeChild(link);
890
+ URL.revokeObjectURL(url);
891
+ };
892
+
893
+ if (nodeRenderMode !== 'html' || vertexComponent) {
894
+ renderExportFromElement(wrapperRef.current);
895
+ return;
896
+ }
897
+
898
+ const host = document.createElement('div');
899
+ host.style.position = 'absolute';
900
+ host.style.width = '0';
901
+ host.style.height = '0';
902
+ host.style.overflow = 'hidden';
903
+ host.style.opacity = '0';
904
+ host.style.pointerEvents = 'none';
905
+ document.body.appendChild(host);
906
+
907
+ const exportRoot = createRoot(host);
908
+
909
+ try {
910
+ flushSync(() => {
911
+ exportRoot.render(
912
+ <BracketThemeProvider mode={isDarkMode ? 'dark' : 'light'}>
913
+ <Graph
914
+ graph={enrichedGraph}
915
+ vertexComponent={exportVertexComponent}
916
+ config={mergedConfig}
917
+ />
918
+ </BracketThemeProvider>
919
+ );
920
+ });
921
+
922
+ renderExportFromElement(host);
923
+ } finally {
924
+ exportRoot.unmount();
925
+ document.body.removeChild(host);
926
+ }
927
+ }, [
928
+ enrichedGraph,
929
+ exportVertexComponent,
930
+ isDarkMode,
931
+ mergedConfig,
932
+ nodeRenderMode,
933
+ vertexComponent,
934
+ ]);
935
+
936
+ const resolvedVertexComponent = useMemo(
937
+ () =>
938
+ vertexComponent ??
939
+ ((props: VertexComponentProps) => (
940
+ <SquashNode {...props} renderMode={nodeRenderMode} onRenderError={onInvalidNode} />
941
+ )),
942
+ [nodeRenderMode, onInvalidNode, vertexComponent]
943
+ );
944
+
945
+ return (
946
+ <BracketThemeProvider mode={isDarkMode ? 'dark' : 'light'}>
947
+ <BracketFrame
948
+ title={title}
949
+ badgeText={resolvedBadgeText}
950
+ stageLabels={labels}
951
+ isDarkMode={isDarkMode}
952
+ isNavigationMode={isNavigationMode}
953
+ stageViews={stageViews}
954
+ activeStageIndex={activeStageIndex}
955
+ verticalStagePosition={verticalStagePosition}
956
+ canPagePlayersVertically={canPagePlayersVertically}
957
+ contentViewportRef={contentViewportRef}
958
+ showToolbar={showToolbar}
959
+ onToggleNavigationMode={handleToggleNavigationMode}
960
+ onSelectStage={handleSelectStage}
961
+ onPreviousStage={handlePreviousStage}
962
+ onNextStage={handleNextStage}
963
+ onPagePlayersUp={handlePagePlayersUp}
964
+ onPagePlayersDown={handlePagePlayersDown}
965
+ onToggleDarkMode={handleToggleDarkMode}
966
+ onExportSVG={handleExportSVG}
967
+ >
968
+ <div ref={wrapperRef} style={{ minWidth: 'fit-content' }}>
969
+ <Graph
970
+ ref={graphRef}
971
+ graph={enrichedGraph}
972
+ vertexComponent={resolvedVertexComponent}
973
+ config={mergedConfig}
974
+ panEnabled={!isNavigationMode}
975
+ zoomEnabled={!isNavigationMode}
976
+ pinchZoomEnabled={!isNavigationMode}
977
+ renderOverlay={(context) => (
978
+ <GraphStageSync
979
+ context={context}
980
+ labels={labels}
981
+ labelOffset={mergedConfig.labelOffset ?? 46}
982
+ onStagesChange={handleStagesChange}
983
+ />
984
+ )}
985
+ />
986
+ </div>
987
+ </BracketFrame>
988
+ </BracketThemeProvider>
989
+ );
990
+ });
991
+
992
+ TournamentBracket.displayName = 'TournamentBracket';