@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,813 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import type { VertexComponentProps } from '@graph-render/types';
3
+ import type { MatchStatus, SquashMatchMeta, SquashNodeRenderMode, SquashPlayer } from '../types';
4
+ import { NODE_DIMENSIONS, DEFAULT_PLAYERS } from '../constants';
5
+ import { useBracketTheme } from '../contexts/BracketThemeContext';
6
+
7
+ interface SquashNodeProps extends VertexComponentProps {
8
+ renderMode?: SquashNodeRenderMode;
9
+ onRenderError?: (nodeId: string, error: Error) => void;
10
+ }
11
+
12
+ const isSvgCompatibleRenderMode = (renderMode: SquashNodeRenderMode): boolean => {
13
+ return renderMode === 'svg' || renderMode === 'export' || renderMode === 'server';
14
+ };
15
+
16
+ const SCORE_FONT_FAMILY = '"Space Mono", "SFMono-Regular", ui-monospace, monospace';
17
+ const BODY_FONT_FAMILY = '"Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif';
18
+ const SCORE_SEGMENT_WIDTH = 16;
19
+ const SCORE_SEGMENT_GAP = 5;
20
+ const SCORE_SEPARATOR_HEIGHT = 10;
21
+ const NODE_BORDER_WIDTH = 2;
22
+
23
+ const truncateText = (value: string, maxLength: number): string => {
24
+ if (value.length <= maxLength) {
25
+ return value;
26
+ }
27
+
28
+ return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
29
+ };
30
+
31
+ const isMatchStatus = (value: unknown): value is MatchStatus => {
32
+ return value === 'completed' || value === 'live' || value === 'upcoming';
33
+ };
34
+
35
+ const normalizePlayerKey = (value: string): string => value.trim().toLowerCase();
36
+
37
+ const normalizePlayer = (value: unknown, label: string): SquashPlayer => {
38
+ if (!value || typeof value !== 'object') {
39
+ throw new TypeError(`Invalid squash match payload: ${label} must be an object.`);
40
+ }
41
+
42
+ const player = value as Partial<SquashPlayer>;
43
+ if (typeof player.name !== 'string' || !player.name.trim()) {
44
+ throw new TypeError(`Invalid squash match payload: ${label}.name must be a non-empty string.`);
45
+ }
46
+
47
+ if (player.seed != null && (typeof player.seed !== 'number' || !Number.isFinite(player.seed))) {
48
+ throw new TypeError(`Invalid squash match payload: ${label}.seed must be a finite number.`);
49
+ }
50
+
51
+ if (
52
+ player.country != null &&
53
+ (typeof player.country !== 'string' || !player.country.trim())
54
+ ) {
55
+ throw new TypeError(
56
+ `Invalid squash match payload: ${label}.country must be a non-empty string when provided.`
57
+ );
58
+ }
59
+
60
+ return {
61
+ name: player.name.trim(),
62
+ seed: player.seed,
63
+ country: player.country?.trim(),
64
+ };
65
+ };
66
+
67
+ const normalizePlayers = (value: unknown): [SquashPlayer, SquashPlayer] => {
68
+ if (value == null) {
69
+ return [DEFAULT_PLAYERS[0], DEFAULT_PLAYERS[1]];
70
+ }
71
+
72
+ if (!Array.isArray(value) || value.length !== 2) {
73
+ throw new TypeError('Invalid squash match payload: players must contain exactly two entries.');
74
+ }
75
+
76
+ return [normalizePlayer(value[0], 'players[0]'), normalizePlayer(value[1], 'players[1]')];
77
+ };
78
+
79
+ const normalizeScore = (value: unknown, label: string): number => {
80
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
81
+ throw new TypeError(`Invalid squash match payload: ${label} must be a non-negative number.`);
82
+ }
83
+
84
+ return value;
85
+ };
86
+
87
+ const normalizeSets = (value: unknown): number[][] => {
88
+ if (value == null) {
89
+ return [];
90
+ }
91
+
92
+ if (!Array.isArray(value)) {
93
+ throw new TypeError('Invalid squash match payload: sets must be an array of score pairs.');
94
+ }
95
+
96
+ return value.map((entry, index) => {
97
+ if (!Array.isArray(entry) || entry.length !== 2) {
98
+ throw new TypeError(
99
+ `Invalid squash match payload: sets[${index}] must contain exactly two scores.`
100
+ );
101
+ }
102
+
103
+ return [
104
+ normalizeScore(entry[0], `sets[${index}][0]`),
105
+ normalizeScore(entry[1], `sets[${index}][1]`),
106
+ ];
107
+ });
108
+ };
109
+
110
+ const normalizeTiebreaks = (value: unknown): (number[] | null)[] => {
111
+ if (value == null) {
112
+ return [];
113
+ }
114
+
115
+ if (!Array.isArray(value)) {
116
+ throw new TypeError(
117
+ 'Invalid squash match payload: tiebreaks must be an array of score pairs or null entries.'
118
+ );
119
+ }
120
+
121
+ return value.map((entry, index) => {
122
+ if (entry == null) {
123
+ return null;
124
+ }
125
+
126
+ if (!Array.isArray(entry) || entry.length !== 2) {
127
+ throw new TypeError(
128
+ `Invalid squash match payload: tiebreaks[${index}] must contain exactly two scores or be null.`
129
+ );
130
+ }
131
+
132
+ return [
133
+ normalizeScore(entry[0], `tiebreaks[${index}][0]`),
134
+ normalizeScore(entry[1], `tiebreaks[${index}][1]`),
135
+ ];
136
+ });
137
+ };
138
+
139
+ const normalizeMatchMeta = (meta: unknown): Required<SquashMatchMeta> => {
140
+ if (meta != null && typeof meta !== 'object') {
141
+ throw new TypeError('Invalid squash match payload: node meta must be an object when provided.');
142
+ }
143
+
144
+ const rawMeta = meta as Partial<SquashMatchMeta> | undefined;
145
+ if (rawMeta?.status != null && !isMatchStatus(rawMeta.status)) {
146
+ throw new TypeError(
147
+ 'Invalid squash match payload: status must be one of completed, live, or upcoming.'
148
+ );
149
+ }
150
+
151
+ const sets = normalizeSets(rawMeta?.sets);
152
+
153
+ return {
154
+ stage:
155
+ typeof rawMeta?.stage === 'string' && rawMeta.stage.trim() ? rawMeta.stage.trim() : 'Stage',
156
+ players: normalizePlayers(rawMeta?.players),
157
+ sets,
158
+ tiebreaks: normalizeTiebreaks(rawMeta?.tiebreaks),
159
+ status: rawMeta?.status ?? 'completed',
160
+ currentSet:
161
+ rawMeta?.currentSet == null
162
+ ? 0
163
+ : typeof rawMeta.currentSet === 'number' && Number.isFinite(rawMeta.currentSet)
164
+ ? Math.max(0, Math.min(Math.floor(rawMeta.currentSet), Math.max(sets.length - 1, 0)))
165
+ : (() => {
166
+ throw new TypeError(
167
+ 'Invalid squash match payload: currentSet must be a finite number when provided.'
168
+ );
169
+ })(),
170
+ };
171
+ };
172
+
173
+ function ensureSquashNodeAnimations(): void {
174
+ if (typeof document === 'undefined') {
175
+ return;
176
+ }
177
+
178
+ if (document.querySelector('style[data-squash-node-animations]')) {
179
+ return;
180
+ }
181
+
182
+ const styleTag = document.createElement('style');
183
+ styleTag.setAttribute('data-squash-node-animations', 'true');
184
+ styleTag.textContent = `
185
+ @keyframes pulse {
186
+ 0%, 100% {
187
+ opacity: 1;
188
+ }
189
+ 50% {
190
+ opacity: 0.5;
191
+ }
192
+ }
193
+ `;
194
+ document.head.appendChild(styleTag);
195
+ }
196
+
197
+ const getPlayerBadgeText = (player: SquashPlayer): string => {
198
+ const initials = player.name
199
+ .split(/\s+/)
200
+ .filter(Boolean)
201
+ .slice(0, 2)
202
+ .map((part) => part[0]?.toUpperCase() ?? '')
203
+ .join('');
204
+
205
+ return initials || '–';
206
+ };
207
+
208
+ const getDisplayScores = (
209
+ sets: number[][],
210
+ tiebreaks: (number[] | null)[],
211
+ playerIndex: number
212
+ ): string[] => {
213
+ return sets.map((setScores, setIndex) => {
214
+ const score = setScores[playerIndex];
215
+
216
+ if (!Number.isFinite(score)) {
217
+ return '—';
218
+ }
219
+
220
+ const tiebreak = tiebreaks[setIndex];
221
+ const tiebreakValue = tiebreak?.[playerIndex];
222
+
223
+ if (typeof tiebreakValue === 'number' && Number.isFinite(tiebreakValue) && tiebreakValue > 0) {
224
+ return `${score}(${tiebreakValue})`;
225
+ }
226
+
227
+ return String(score);
228
+ });
229
+ };
230
+
231
+ const getScoreSegments = (
232
+ sets: number[][],
233
+ tiebreaks: (number[] | null)[],
234
+ playerIndex: number
235
+ ): string[] => {
236
+ const segments = getDisplayScores(sets, tiebreaks, playerIndex);
237
+ return segments.length ? segments : ['—'];
238
+ };
239
+
240
+ const getScoreGroupWidth = (segmentCount: number): number => {
241
+ if (segmentCount <= 0) {
242
+ return SCORE_SEGMENT_WIDTH;
243
+ }
244
+
245
+ return segmentCount * SCORE_SEGMENT_WIDTH + Math.max(0, segmentCount - 1) * SCORE_SEGMENT_GAP;
246
+ };
247
+
248
+ const getCompletedWinnerIndex = (
249
+ setWins: { p1: number; p2: number },
250
+ status: MatchStatus
251
+ ): number | null => {
252
+ if (status !== 'completed') {
253
+ return null;
254
+ }
255
+
256
+ if (setWins.p1 === setWins.p2) {
257
+ return null;
258
+ }
259
+
260
+ return setWins.p1 > setWins.p2 ? 0 : 1;
261
+ };
262
+
263
+ type SquashNodeErrorBoundaryProps = {
264
+ nodeId: string;
265
+ renderMode: SquashNodeRenderMode;
266
+ width: number;
267
+ height: number;
268
+ onRenderError?: (nodeId: string, error: Error) => void;
269
+ children: React.ReactNode;
270
+ };
271
+
272
+ type SquashNodeErrorBoundaryState = {
273
+ error: Error | null;
274
+ };
275
+
276
+ class SquashNodeErrorBoundary extends React.Component<
277
+ SquashNodeErrorBoundaryProps,
278
+ SquashNodeErrorBoundaryState
279
+ > {
280
+ state: SquashNodeErrorBoundaryState = { error: null };
281
+
282
+ static getDerivedStateFromError(error: Error): SquashNodeErrorBoundaryState {
283
+ return { error };
284
+ }
285
+
286
+ componentDidCatch(error: Error): void {
287
+ this.props.onRenderError?.(this.props.nodeId, error);
288
+ }
289
+
290
+ componentDidUpdate(prevProps: SquashNodeErrorBoundaryProps): void {
291
+ if (
292
+ this.state.error &&
293
+ (prevProps.nodeId !== this.props.nodeId || prevProps.children !== this.props.children)
294
+ ) {
295
+ this.setState({ error: null });
296
+ }
297
+ }
298
+
299
+ render(): React.ReactNode {
300
+ if (!this.state.error) {
301
+ return this.props.children;
302
+ }
303
+
304
+ return renderInvalidSquashNode(
305
+ this.props.width,
306
+ this.props.height,
307
+ this.props.renderMode,
308
+ this.props.nodeId
309
+ );
310
+ }
311
+ }
312
+
313
+ const renderInvalidSquashNode = (
314
+ width: number,
315
+ height: number,
316
+ renderMode: SquashNodeRenderMode,
317
+ nodeId: string
318
+ ): React.ReactNode => {
319
+ if (isSvgCompatibleRenderMode(renderMode)) {
320
+ return (
321
+ <g>
322
+ <rect width={width} height={height} rx={16} ry={16} fill="#fff7ed" stroke="#f97316" strokeWidth={2} />
323
+ <text x={16} y={34} fontSize={13} fontWeight={700} fill="#9a3412" fontFamily={BODY_FONT_FAMILY}>
324
+ Invalid match data
325
+ </text>
326
+ <text x={16} y={56} fontSize={11} fill="#c2410c" fontFamily={BODY_FONT_FAMILY}>
327
+ {truncateText(nodeId, 28)}
328
+ </text>
329
+ </g>
330
+ );
331
+ }
332
+
333
+ return (
334
+ <foreignObject width={width} height={height} requiredExtensions="http://www.w3.org/1999/xhtml">
335
+ <div
336
+ style={{
337
+ boxSizing: 'border-box',
338
+ width: '100%',
339
+ height: '100%',
340
+ borderRadius: 16,
341
+ border: '2px solid #f97316',
342
+ background: '#fff7ed',
343
+ color: '#9a3412',
344
+ padding: '16px 14px',
345
+ display: 'flex',
346
+ flexDirection: 'column',
347
+ justifyContent: 'center',
348
+ fontFamily: BODY_FONT_FAMILY,
349
+ }}
350
+ >
351
+ <div style={{ fontSize: 13, fontWeight: 700 }}>Invalid match data</div>
352
+ <div style={{ marginTop: 6, fontSize: 11, color: '#c2410c' }}>{truncateText(nodeId, 28)}</div>
353
+ </div>
354
+ </foreignObject>
355
+ );
356
+ };
357
+
358
+ const SquashNodeContent = React.memo<SquashNodeProps>(function SquashNodeContent({
359
+ node,
360
+ isHovered,
361
+ activePathKey,
362
+ activePathNodeIds,
363
+ onPathHover,
364
+ onPathLeave,
365
+ renderMode = 'export',
366
+ onRenderError: _onRenderError,
367
+ }) {
368
+ const [hoveredPlayerIndex, setHoveredPlayerIndex] = useState<number | null>(null);
369
+
370
+ useEffect(() => {
371
+ if (renderMode === 'html') {
372
+ ensureSquashNodeAnimations();
373
+ }
374
+ }, [renderMode]);
375
+
376
+ const { colors: THEME_COLORS } = useBracketTheme();
377
+
378
+ const meta = normalizeMatchMeta(node.meta);
379
+ const [p1, p2] = meta.players ?? DEFAULT_PLAYERS;
380
+ const sets = meta.sets ?? [];
381
+ const tiebreaks = meta.tiebreaks ?? [];
382
+ const status = meta.status ?? 'completed';
383
+ const currentSet = meta.currentSet ?? 0;
384
+ const nodeWidth = node.size?.width ?? NODE_DIMENSIONS.WIDTH;
385
+ const nodeHeight = node.size?.height ?? NODE_DIMENSIONS.HEIGHT;
386
+
387
+ // Check if match is TBD (both players or either player is TBD)
388
+ const isTBD = p1.name === 'TBD' || p2.name === 'TBD';
389
+ const normalizedActivePathKey = activePathKey ? normalizePlayerKey(activePathKey) : null;
390
+ const isNodeInActivePath = activePathNodeIds?.has(node.id) ?? false;
391
+
392
+ const setWins = sets.reduce(
393
+ (acc, [a, b], index) => {
394
+ // For live matches, exclude the current set from the count
395
+ if (status === 'live' && index === currentSet) {
396
+ return acc;
397
+ }
398
+ if (a > b) acc.p1 += 1;
399
+ else if (b > a) acc.p2 += 1;
400
+ return acc;
401
+ },
402
+ { p1: 0, p2: 0 }
403
+ );
404
+
405
+ const winnerIndex = getCompletedWinnerIndex(setWins, status);
406
+ const visibleBorder = `${NODE_BORDER_WIDTH}px solid ${THEME_COLORS.CARD_BORDER}`;
407
+
408
+ if (isSvgCompatibleRenderMode(renderMode)) {
409
+ const insetX = 14;
410
+ const rowHeight = nodeHeight / 2;
411
+ const dividerY = rowHeight;
412
+ const badgeSize = 28;
413
+ const scoreSectionWidth = getScoreGroupWidth(Math.max(sets.length, 1));
414
+ const matchCountWidth = 22;
415
+ const internalDividerX = nodeWidth - insetX - matchCountWidth - 10;
416
+ const scoreGroupRightX = internalDividerX - 6;
417
+ const matchCountX = nodeWidth - insetX - matchCountWidth / 2;
418
+ const playerTextX = insetX + badgeSize + 10;
419
+ const maxNameWidth = Math.max(56, internalDividerX - playerTextX - scoreSectionWidth - 10);
420
+ const maxNameLength = Math.max(10, Math.floor(maxNameWidth / 7));
421
+ const filterId = `ds-${node.id.replace(/[^a-z0-9]/gi, '')}`;
422
+
423
+ return (
424
+ <g>
425
+ <defs>
426
+ <clipPath id={filterId}>
427
+ <rect width={nodeWidth} height={nodeHeight} rx={16} ry={16} />
428
+ </clipPath>
429
+ </defs>
430
+ <rect
431
+ width={nodeWidth}
432
+ height={nodeHeight}
433
+ rx={16}
434
+ ry={16}
435
+ fill={isHovered ? THEME_COLORS.HOVER_BG : THEME_COLORS.BASE_BG}
436
+ stroke={THEME_COLORS.CARD_BORDER}
437
+ strokeWidth={NODE_BORDER_WIDTH}
438
+ />
439
+
440
+ {status === 'live' && (
441
+ <g transform={`translate(${nodeWidth - 18}, 14)`}>
442
+ <circle r={4} fill={THEME_COLORS.LIVE_INDICATOR} />
443
+ </g>
444
+ )}
445
+
446
+ <g clipPath={`url(#${filterId})`}>
447
+ {[p1, p2].map((player, idx) => {
448
+ const rowY = idx * rowHeight;
449
+ const scoreSegments = getScoreSegments(sets, tiebreaks, idx);
450
+ const scoreGroupWidth = getScoreGroupWidth(scoreSegments.length);
451
+ const scoreGroupLeftX = scoreGroupRightX - scoreGroupWidth;
452
+ const setCount = idx === 0 ? setWins.p1 : setWins.p2;
453
+ const isWinner = winnerIndex === idx;
454
+ const playerOpacity = status === 'upcoming' ? 0.6 : 1;
455
+ const isPlayerPathMatch =
456
+ isNodeInActivePath &&
457
+ normalizedActivePathKey !== null &&
458
+ normalizePlayerKey(player.name) === normalizedActivePathKey;
459
+ const isPlayerHovered = hoveredPlayerIndex === idx || isPlayerPathMatch;
460
+ const rowFill = isPlayerHovered ? THEME_COLORS.ROW_HOVER_BG : THEME_COLORS.ROW_BG;
461
+ const badgeFill = isWinner ? THEME_COLORS.WINNER_CREST_BG : THEME_COLORS.CREST_BG;
462
+ const badgeTextColor = isWinner
463
+ ? THEME_COLORS.WINNER_CREST_TEXT
464
+ : THEME_COLORS.CREST_TEXT;
465
+ const textColor = isWinner ? THEME_COLORS.FOREGROUND : THEME_COLORS.MUTED_TEXT;
466
+ const badgeText = getPlayerBadgeText(player);
467
+
468
+ return (
469
+ <g
470
+ key={`${node.id}-svg-p-${idx}`}
471
+ transform={`translate(0, ${rowY})`}
472
+ opacity={playerOpacity}
473
+ onMouseEnter={() => {
474
+ setHoveredPlayerIndex(idx);
475
+ if (!isTBD) {
476
+ onPathHover?.(idx, { pathKey: player.name });
477
+ }
478
+ }}
479
+ onMouseLeave={() => {
480
+ setHoveredPlayerIndex(null);
481
+ if (!isTBD) {
482
+ onPathLeave?.();
483
+ }
484
+ }}
485
+ >
486
+ <rect x={0} width={nodeWidth} height={rowHeight} fill={rowFill} />
487
+ <rect
488
+ x={insetX}
489
+ y={(rowHeight - badgeSize) / 2}
490
+ width={badgeSize}
491
+ height={badgeSize}
492
+ rx={7}
493
+ ry={7}
494
+ fill={badgeFill}
495
+ />
496
+ <text
497
+ x={insetX + badgeSize / 2}
498
+ y={rowHeight / 2 + 4}
499
+ textAnchor="middle"
500
+ fontSize={12}
501
+ fontWeight={700}
502
+ fill={badgeTextColor}
503
+ fontFamily={BODY_FONT_FAMILY}
504
+ >
505
+ {badgeText}
506
+ </text>
507
+ <text
508
+ x={playerTextX}
509
+ y={rowHeight / 2 + 4}
510
+ fontSize={13}
511
+ fontWeight={isWinner ? 600 : 500}
512
+ fill={textColor}
513
+ fontFamily={BODY_FONT_FAMILY}
514
+ >
515
+ {truncateText(player.name, maxNameLength)}
516
+ </text>
517
+ <line
518
+ x1={internalDividerX}
519
+ y1={rowHeight / 2 - 9}
520
+ x2={internalDividerX}
521
+ y2={rowHeight / 2 + 9}
522
+ stroke={THEME_COLORS.DARK_BORDER}
523
+ strokeWidth={1}
524
+ />
525
+ {scoreSegments.map((segment, segmentIndex) => {
526
+ const segmentX =
527
+ scoreGroupLeftX +
528
+ SCORE_SEGMENT_WIDTH / 2 +
529
+ segmentIndex * (SCORE_SEGMENT_WIDTH + SCORE_SEGMENT_GAP);
530
+ const dividerX = segmentX + SCORE_SEGMENT_WIDTH / 2 + SCORE_SEGMENT_GAP / 2;
531
+
532
+ return (
533
+ <g key={`${node.id}-svg-score-${idx}-${segmentIndex}`}>
534
+ <text
535
+ x={segmentX}
536
+ y={rowHeight / 2 + 1}
537
+ textAnchor="middle"
538
+ dominantBaseline="middle"
539
+ fontSize={10.5}
540
+ fontWeight={400}
541
+ fill={textColor}
542
+ fontFamily={SCORE_FONT_FAMILY}
543
+ >
544
+ {truncateText(segment, 4)}
545
+ </text>
546
+ {segmentIndex < scoreSegments.length - 1 ? (
547
+ <line
548
+ x1={dividerX}
549
+ y1={rowHeight / 2 - SCORE_SEPARATOR_HEIGHT / 2}
550
+ x2={dividerX}
551
+ y2={rowHeight / 2 + SCORE_SEPARATOR_HEIGHT / 2}
552
+ stroke={THEME_COLORS.BORDER}
553
+ strokeWidth={1}
554
+ />
555
+ ) : null}
556
+ </g>
557
+ );
558
+ })}
559
+ <text
560
+ x={matchCountX}
561
+ y={rowHeight / 2 + 1}
562
+ textAnchor="middle"
563
+ dominantBaseline="middle"
564
+ fontSize={18}
565
+ fontWeight={700}
566
+ fill={textColor}
567
+ fontFamily={BODY_FONT_FAMILY}
568
+ >
569
+ {setCount}
570
+ </text>
571
+ </g>
572
+ );
573
+ })}
574
+
575
+ <line
576
+ x1={0}
577
+ y1={dividerY}
578
+ x2={nodeWidth}
579
+ y2={dividerY}
580
+ stroke={THEME_COLORS.BORDER}
581
+ strokeWidth={1}
582
+ />
583
+ </g>
584
+ </g>
585
+ );
586
+ }
587
+
588
+ return (
589
+ <foreignObject
590
+ width={nodeWidth}
591
+ height={nodeHeight}
592
+ requiredExtensions="http://www.w3.org/1999/xhtml"
593
+ >
594
+ <div
595
+ style={{
596
+ boxSizing: 'border-box',
597
+ width: '100%',
598
+ height: '100%',
599
+ borderRadius: 16,
600
+ background: isHovered ? THEME_COLORS.HOVER_BG : THEME_COLORS.BASE_BG,
601
+ border: visibleBorder,
602
+ color: THEME_COLORS.FOREGROUND,
603
+ display: 'flex',
604
+ flexDirection: 'column',
605
+ transition: 'background-color 120ms ease, box-shadow 120ms ease',
606
+ transform: 'none',
607
+ overflow: 'hidden',
608
+ position: 'relative',
609
+ }}
610
+ >
611
+ {status === 'live' && (
612
+ <div
613
+ style={{
614
+ position: 'absolute',
615
+ top: 10,
616
+ right: 12,
617
+ display: 'flex',
618
+ alignItems: 'center',
619
+ gap: 4,
620
+ fontSize: 10,
621
+ fontWeight: 700,
622
+ color: THEME_COLORS.LIVE_INDICATOR,
623
+ textTransform: 'uppercase',
624
+ }}
625
+ >
626
+ <span
627
+ style={{
628
+ width: 10,
629
+ height: 10,
630
+ borderRadius: '50%',
631
+ background: THEME_COLORS.LIVE_INDICATOR,
632
+ animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
633
+ }}
634
+ />
635
+ </div>
636
+ )}
637
+ <div
638
+ style={{
639
+ display: 'grid',
640
+ gridTemplateRows: 'repeat(2, 1fr)',
641
+ }}
642
+ >
643
+ {[p1, p2].map((p, idx) => {
644
+ const badgeText = getPlayerBadgeText(p);
645
+ const scoreSegments = getScoreSegments(sets, tiebreaks, idx);
646
+ const scoreGroupWidth = getScoreGroupWidth(Math.max(sets.length, 1));
647
+ const setCount = idx === 0 ? setWins.p1 : setWins.p2;
648
+ const isWinner = winnerIndex === idx;
649
+ const playerOpacity = status === 'upcoming' ? 0.6 : 1;
650
+ const isPlayerPathMatch =
651
+ isNodeInActivePath &&
652
+ normalizedActivePathKey !== null &&
653
+ normalizePlayerKey(p.name) === normalizedActivePathKey;
654
+ const isPlayerHovered = hoveredPlayerIndex === idx || isPlayerPathMatch;
655
+ const rowBackground = isPlayerHovered ? THEME_COLORS.ROW_HOVER_BG : THEME_COLORS.ROW_BG;
656
+ const rowTextColor = isWinner ? THEME_COLORS.FOREGROUND : THEME_COLORS.MUTED_TEXT;
657
+ const badgeBackground = isWinner ? THEME_COLORS.WINNER_CREST_BG : THEME_COLORS.CREST_BG;
658
+ const badgeColor = isWinner ? THEME_COLORS.WINNER_CREST_TEXT : THEME_COLORS.CREST_TEXT;
659
+
660
+ return (
661
+ <div
662
+ key={`${node.id}-p-${idx}`}
663
+ style={{
664
+ display: 'grid',
665
+ gridTemplateColumns: `28px minmax(0, 1fr) ${scoreGroupWidth}px 24px`,
666
+ alignItems: 'center',
667
+ gap: 8,
668
+ padding: '14px 12px',
669
+ minHeight: nodeHeight / 2,
670
+ background: rowBackground,
671
+ opacity: playerOpacity,
672
+ transition: 'background-color 140ms ease',
673
+ borderTop: idx === 1 ? `1px solid ${THEME_COLORS.BORDER}` : 'none',
674
+ boxSizing: 'border-box',
675
+ }}
676
+ onMouseEnter={() => {
677
+ setHoveredPlayerIndex(idx);
678
+ if (!isTBD) {
679
+ onPathHover?.(idx, { pathKey: p.name });
680
+ }
681
+ }}
682
+ onMouseLeave={() => {
683
+ setHoveredPlayerIndex(null);
684
+ if (!isTBD) {
685
+ onPathLeave?.();
686
+ }
687
+ }}
688
+ >
689
+ <div
690
+ style={{
691
+ width: 28,
692
+ height: 28,
693
+ borderRadius: 7,
694
+ background: badgeBackground,
695
+ display: 'grid',
696
+ placeItems: 'center',
697
+ fontWeight: 700,
698
+ color: badgeColor,
699
+ fontSize: 12,
700
+ flexShrink: 0,
701
+ fontFamily: BODY_FONT_FAMILY,
702
+ }}
703
+ aria-label={`crest-${p.name}`}
704
+ >
705
+ {badgeText}
706
+ </div>
707
+ <div
708
+ style={{
709
+ display: 'flex',
710
+ flexDirection: 'column',
711
+ minWidth: 0,
712
+ }}
713
+ >
714
+ <span
715
+ style={{
716
+ fontSize: 13,
717
+ fontWeight: isWinner ? 600 : 500,
718
+ color: rowTextColor,
719
+ overflow: 'hidden',
720
+ textOverflow: 'ellipsis',
721
+ whiteSpace: 'nowrap',
722
+ fontFamily: BODY_FONT_FAMILY,
723
+ }}
724
+ >
725
+ {p.name}
726
+ </span>
727
+ </div>
728
+ <span
729
+ style={{
730
+ display: 'flex',
731
+ alignItems: 'center',
732
+ justifyContent: 'flex-end',
733
+ minWidth: 0,
734
+ width: '100%',
735
+ gap: SCORE_SEGMENT_GAP,
736
+ }}
737
+ >
738
+ {scoreSegments.map((segment, segmentIndex) => (
739
+ <React.Fragment key={`${node.id}-html-score-${idx}-${segmentIndex}`}>
740
+ <span
741
+ style={{
742
+ width: SCORE_SEGMENT_WIDTH,
743
+ fontSize: 10.5,
744
+ color: rowTextColor,
745
+ fontFamily: SCORE_FONT_FAMILY,
746
+ textAlign: 'center',
747
+ whiteSpace: 'nowrap',
748
+ flexShrink: 0,
749
+ }}
750
+ >
751
+ {truncateText(segment, 4)}
752
+ </span>
753
+ {segmentIndex < scoreSegments.length - 1 ? (
754
+ <span
755
+ style={{
756
+ width: 1,
757
+ height: SCORE_SEPARATOR_HEIGHT,
758
+ background: THEME_COLORS.BORDER,
759
+ flexShrink: 0,
760
+ }}
761
+ />
762
+ ) : null}
763
+ </React.Fragment>
764
+ ))}
765
+ </span>
766
+ <span
767
+ style={{
768
+ display: 'flex',
769
+ alignItems: 'center',
770
+ justifyContent: 'center',
771
+ minHeight: 20,
772
+ borderLeft: `1px solid ${THEME_COLORS.DARK_BORDER}`,
773
+ fontSize: 18,
774
+ fontWeight: 700,
775
+ color: rowTextColor,
776
+ fontFamily: BODY_FONT_FAMILY,
777
+ paddingLeft: 8,
778
+ }}
779
+ >
780
+ {setCount}
781
+ </span>
782
+ </div>
783
+ );
784
+ })}
785
+ </div>
786
+ </div>
787
+ </foreignObject>
788
+ );
789
+ });
790
+
791
+ export const SquashNode = React.memo<SquashNodeProps>(function SquashNode({
792
+ node,
793
+ renderMode = 'export',
794
+ onRenderError,
795
+ ...props
796
+ }) {
797
+ const width = node.size?.width ?? NODE_DIMENSIONS.WIDTH;
798
+ const height = node.size?.height ?? NODE_DIMENSIONS.HEIGHT;
799
+
800
+ return (
801
+ <SquashNodeErrorBoundary
802
+ nodeId={node.id}
803
+ renderMode={renderMode}
804
+ width={width}
805
+ height={height}
806
+ onRenderError={onRenderError}
807
+ >
808
+ <SquashNodeContent {...props} node={node} renderMode={renderMode} />
809
+ </SquashNodeErrorBoundary>
810
+ );
811
+ });
812
+
813
+ SquashNode.displayName = 'SquashNode';