@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.
- package/.eslintrc.json +6 -0
- package/CHANGELOG.md +23 -0
- package/README.md +0 -0
- package/dist/index.js +2258 -0
- package/dist/src/components/BracketToolbar.d.ts +11 -0
- package/dist/src/components/BracketToolbar.d.ts.map +1 -0
- package/dist/src/components/SquashNode.d.ts +10 -0
- package/dist/src/components/SquashNode.d.ts.map +1 -0
- package/dist/src/components/TournamentBracket.d.ts +4 -0
- package/dist/src/components/TournamentBracket.d.ts.map +1 -0
- package/dist/src/constants/index.d.ts +3 -0
- package/dist/src/constants/index.d.ts.map +1 -0
- package/dist/src/constants/node.d.ts +127 -0
- package/dist/src/constants/node.d.ts.map +1 -0
- package/dist/src/constants/tournament.d.ts +4 -0
- package/dist/src/constants/tournament.d.ts.map +1 -0
- package/dist/src/contexts/BracketThemeContext.d.ts +16 -0
- package/dist/src/contexts/BracketThemeContext.d.ts.map +1 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/types/index.d.ts +3 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/squash.d.ts +18 -0
- package/dist/src/types/squash.d.ts.map +1 -0
- package/dist/src/types/tournament.d.ts +13 -0
- package/dist/src/types/tournament.d.ts.map +1 -0
- package/dist/src/utils/pathKeys.d.ts +8 -0
- package/dist/src/utils/pathKeys.d.ts.map +1 -0
- package/dist/src/utils/roundLabels.d.ts +16 -0
- package/dist/src/utils/roundLabels.d.ts.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/index.html +18 -0
- package/package.json +51 -0
- package/project.json +60 -0
- package/src/components/BracketToolbar.tsx +135 -0
- package/src/components/SquashNode.tsx +813 -0
- package/src/components/TournamentBracket.tsx +992 -0
- package/src/constants/index.ts +2 -0
- package/src/constants/node.ts +96 -0
- package/src/constants/tournament.ts +54 -0
- package/src/contexts/BracketThemeContext.tsx +35 -0
- package/src/index.ts +12 -0
- package/src/types/index.ts +2 -0
- package/src/types/squash.ts +21 -0
- package/src/types/tournament.ts +14 -0
- package/src/utils/pathKeys.ts +50 -0
- package/src/utils/roundLabels.ts +110 -0
- package/tsconfig.json +19 -0
- package/tsconfig.node.json +11 -0
- 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';
|