react-bracket-ui
Version:
A modern, feature-rich React component library for displaying single-elimination tournament brackets with drag-drop, zoom/pan, and error validation
1 lines • 74 kB
Source Map (JSON)
{"version":3,"sources":["../src/components/Bracket.tsx","../src/utils/bracketValidation.ts","../src/components/MatchComponent.tsx","../src/components/BracketRound.tsx","../src/components/BracketConnectors.tsx"],"sourcesContent":["import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';\r\nimport { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';\r\nimport { BracketProps, Match, BracketChangeEvent, DragDropResult, BracketError } from '../types';\r\nimport { validateBracket, hasMatchError, getMatchErrorMessage } from '../utils/bracketValidation';\r\nimport MatchComponent from './MatchComponent';\r\nimport BracketRound from './BracketRound';\r\nimport BracketConnectors from './BracketConnectors';\r\n\r\nconst DEFAULT_COLORS = {\r\n primary: '#1976d2',\r\n secondary: '#424242',\r\n background: '#f9f9f9',\r\n error: '#d32f2f',\r\n warning: '#ff9800',\r\n winner: '#e8f5e9'\r\n};\r\n\r\nexport default function Bracket({\r\n matches,\r\n className,\r\n style,\r\n enableDragDrop = false,\r\n onBracketChange,\r\n enableZoomPan = false,\r\n minZoom = 0.5,\r\n maxZoom = 3,\r\n initialZoom = 1,\r\n errors: externalErrors,\r\n onErrorClick,\r\n showRoundNames = true,\r\n roundNames,\r\n matchWidth = 220,\r\n matchHeight = 100,\r\n gap = 20,\r\n colors: customColors,\r\n onScheduleChange,\r\n schedules = {}\r\n}: BracketProps & { schedules?: Record<string | number, string | Date> }) {\r\n const [internalMatches, setInternalMatches] = useState<Match[]>(matches);\r\n const [isDragging, setIsDragging] = useState(false);\r\n const [matchPositions, setMatchPositions] = useState<Map<string | number, { top: number; height: number }>>(new Map());\r\n const [currentScale, setCurrentScale] = useState<number>(initialZoom);\r\n const dragDataRef = useRef<{\r\n fromMatchId: string | number;\r\n participantId: string | number;\r\n participantSlot: 'participant1' | 'participant2';\r\n } | null>(null);\r\n // --- Fixed match height logic ---\r\n const [fixedMatchHeight, setFixedMatchHeight] = useState<number>(matchHeight);\r\n const round1MatchRef = useRef<HTMLDivElement | null>(null);\r\n // --- Dynamic pan bounding logic ---\r\n const bracketContentRef = useRef<HTMLDivElement | null>(null);\r\n const [viewportSize, setViewportSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });\r\n const [contentSize, setContentSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });\r\n const [limitPan, setLimitPan] = useState<boolean>(true);\r\n\r\n // Merge custom colors with defaults\r\n const colors = useMemo(() => ({\r\n ...DEFAULT_COLORS,\r\n ...customColors\r\n }), [customColors]);\r\n\r\n // Validate bracket and combine with external errors\r\n const allErrors = useMemo(() => {\r\n const validationErrors = validateBracket(internalMatches);\r\n return externalErrors ? [...validationErrors, ...externalErrors] : validationErrors;\r\n }, [internalMatches, externalErrors]);\r\n\r\n // Update internal matches when props change\r\n React.useEffect(() => {\r\n setInternalMatches(matches);\r\n }, [matches]);\r\n\r\n // Group matches by round\r\n const groupedMatches = useMemo(() => {\r\n return internalMatches.reduce((acc, match) => {\r\n if (!acc[match.round]) {\r\n acc[match.round] = [];\r\n }\r\n acc[match.round].push(match);\r\n return acc;\r\n }, {} as Record<number, Match[]>);\r\n }, [internalMatches]);\r\n\r\n const rounds = useMemo(() =>\r\n Object.keys(groupedMatches).map(Number).sort((a, b) => a - b),\r\n [groupedMatches]\r\n );\r\n\r\n // Drag & Drop handlers\r\n const handleDragStart = useCallback((\r\n matchId: string | number,\r\n participantId: string | number,\r\n slot: 'participant1' | 'participant2'\r\n ) => {\r\n setIsDragging(true);\r\n dragDataRef.current = {\r\n fromMatchId: matchId,\r\n participantId,\r\n participantSlot: slot\r\n };\r\n }, []);\r\n\r\n const handleDragEnd = useCallback(() => {\r\n setIsDragging(false);\r\n dragDataRef.current = null; // Also clear drag data on cancel\r\n }, []);\r\n\r\n const handleDragOver = useCallback((e: React.DragEvent) => {\r\n e.preventDefault();\r\n e.dataTransfer.dropEffect = 'move';\r\n }, []);\r\n\r\n const handleDrop = useCallback((\r\n toMatchId: string | number,\r\n toSlot: 'participant1' | 'participant2'\r\n ) => {\r\n if (!dragDataRef.current || !enableDragDrop) return;\r\n\r\n const { fromMatchId, participantId, participantSlot } = dragDataRef.current;\r\n\r\n // Don't allow drop on same position\r\n if (fromMatchId === toMatchId && participantSlot === toSlot) {\r\n dragDataRef.current = null;\r\n setIsDragging(false);\r\n return;\r\n }\r\n\r\n // Find source and target matches\r\n const sourceMatch = internalMatches.find(m => m.id === fromMatchId);\r\n const targetMatch = internalMatches.find(m => m.id === toMatchId);\r\n if (!sourceMatch || !targetMatch) {\r\n dragDataRef.current = null;\r\n setIsDragging(false);\r\n return;\r\n }\r\n\r\n // Get participants\r\n const sourceParticipant = sourceMatch[participantSlot];\r\n const targetSlot = toSlot;\r\n const targetParticipant = targetMatch[targetSlot];\r\n\r\n // Prepare updated matches\r\n const updatedMatches = internalMatches.map(match => {\r\n // Swap participants if both slots are occupied\r\n if (match.id === fromMatchId) {\r\n // Source match: set to target participant (may be null)\r\n return {\r\n ...match,\r\n [participantSlot]: targetParticipant || null\r\n };\r\n } else if (match.id === toMatchId) {\r\n // Target match: set to source participant\r\n return {\r\n ...match,\r\n [targetSlot]: sourceParticipant || null\r\n };\r\n } else {\r\n return match;\r\n }\r\n });\r\n\r\n setInternalMatches(updatedMatches);\r\n\r\n // Emit change event\r\n if (onBracketChange) {\r\n const dragDropResult: DragDropResult = {\r\n fromMatchId,\r\n toMatchId,\r\n participantId,\r\n participantSlot: toSlot\r\n };\r\n\r\n const event: BracketChangeEvent = {\r\n type: 'drag-drop',\r\n matches: updatedMatches,\r\n dragDropResult\r\n };\r\n\r\n onBracketChange(event);\r\n }\r\n\r\n dragDataRef.current = null;\r\n setIsDragging(false); // Reset drag state after drop\r\n }, [internalMatches, enableDragDrop, onBracketChange]);\r\n\r\n // Render match component\r\n // For round 1, attach a ref to the first match to measure its height\r\n const renderMatch = useCallback((match: Match, round?: number, matchIndex?: number) => {\r\n const hasError = hasMatchError(match.id, allErrors);\r\n const errorMessage = getMatchErrorMessage(match.id, allErrors);\r\n // Only attach ref to the first match in round 1\r\n const ref = (round === 1 && matchIndex === 0) ? round1MatchRef : undefined;\r\n const schedule = schedules && match.id in schedules ? schedules[match.id] : undefined;\r\n return (\r\n <MatchComponent\r\n key={match.id}\r\n match={match}\r\n hasError={hasError}\r\n errorMessage={errorMessage}\r\n enableDragDrop={enableDragDrop}\r\n onDragStart={handleDragStart}\r\n onDragEnd={handleDragEnd}\r\n onDragOver={handleDragOver}\r\n onDrop={handleDrop}\r\n colors={colors}\r\n matchWidth={matchWidth}\r\n matchHeight={fixedMatchHeight}\r\n forwardedRef={ref}\r\n onScheduleChange={onScheduleChange}\r\n schedule={schedule}\r\n />\r\n );\r\n }, [allErrors, enableDragDrop, handleDragStart, handleDragEnd, handleDragOver, handleDrop, colors, matchWidth, fixedMatchHeight, schedules]);\r\n\r\n // Measure the actual DOM height of the first match in round 1 after render\r\n useEffect(() => {\r\n // Only measure and update fixedMatchHeight when zoom scale is exactly 1\r\n if (round1MatchRef.current && (!enableZoomPan || currentScale === 1)) {\r\n const height = round1MatchRef.current.getBoundingClientRect().height;\r\n if (height > 0 && Math.abs(height - fixedMatchHeight) > 1) {\r\n setFixedMatchHeight(height);\r\n // eslint-disable-next-line no-console\r\n console.log('[Bracket] Measured fixedMatchHeight from round 1 (scale=1):', height);\r\n }\r\n }\r\n // Do NOT update fixedMatchHeight when zoom ≠ 1\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [internalMatches, enableZoomPan, currentScale]);\r\n\r\n // Get round name\r\n const getRoundName = useCallback((round: number) => {\r\n if (roundNames && roundNames[round]) {\r\n return roundNames[round];\r\n }\r\n\r\n const totalRounds = rounds.length;\r\n const roundsFromEnd = totalRounds - round + 1;\r\n\r\n if (roundsFromEnd === 1) return 'Final';\r\n if (roundsFromEnd === 2) return 'Semi-Final';\r\n if (roundsFromEnd === 3) return 'Quarter-Final';\r\n\r\n return `Round ${round}`;\r\n }, [rounds, roundNames]);\r\n\r\n // Handle match position updates\r\n const handleMatchPositionsChange = useCallback((positions: { matchId: string | number; top: number; height: number }[]) => {\r\n setMatchPositions((prev) => {\r\n const newMap = new Map(prev);\r\n positions.forEach(({ matchId, top, height }) => {\r\n newMap.set(matchId, { top, height });\r\n });\r\n return newMap;\r\n });\r\n }, []);\r\n\r\n const defaultStyles: React.CSSProperties = {\r\n border: '1px solid #ddd',\r\n borderRadius: '8px',\r\n padding: '16px',\r\n fontFamily: 'Arial, sans-serif',\r\n backgroundColor: colors.background,\r\n height: '600px',\r\n overflow: 'hidden'\r\n };\r\n\r\n // --- Measure content and viewport size after render ---\r\n useEffect(() => {\r\n if (!enableZoomPan) return;\r\n const handleResize = () => {\r\n if (bracketContentRef.current) {\r\n const contentRect = bracketContentRef.current.getBoundingClientRect();\r\n setContentSize({ width: contentRect.width, height: contentRect.height });\r\n }\r\n const parent = bracketContentRef.current?.parentElement;\r\n if (parent) {\r\n const viewportRect = parent.getBoundingClientRect();\r\n setViewportSize({ width: viewportRect.width, height: viewportRect.height });\r\n }\r\n };\r\n handleResize();\r\n window.addEventListener('resize', handleResize);\r\n return () => window.removeEventListener('resize', handleResize);\r\n }, [enableZoomPan, fixedMatchHeight, internalMatches]);\r\n\r\n // --- Dynamically set limitToBounds ---\r\n useEffect(() => {\r\n if (!enableZoomPan) return;\r\n // Allow pan if content is larger than viewport\r\n if (contentSize.width > viewportSize.width || contentSize.height > viewportSize.height) {\r\n setLimitPan(false);\r\n } else {\r\n setLimitPan(true);\r\n }\r\n }, [contentSize, viewportSize, enableZoomPan]);\r\n\r\n const bracketContent = (\r\n <div ref={enableZoomPan ? bracketContentRef : undefined} style={{ padding: enableZoomPan ? '20px' : '0', position: 'relative' }}>\r\n <h3 style={{ margin: '0 0 16px 0', color: colors.secondary }}>\r\n Tournament Bracket\r\n </h3>\r\n\r\n {rounds.length === 0 ? (\r\n <div style={{\r\n textAlign: 'center',\r\n color: '#999',\r\n fontStyle: 'italic',\r\n padding: '40px 0'\r\n }}>\r\n No matches available\r\n </div>\r\n ) : (\r\n <div style={{\r\n display: 'flex',\r\n alignItems: 'flex-start',\r\n gap: '0px',\r\n minWidth: 'min-content'\r\n }}>\r\n {rounds.map((round, index) => (\r\n <React.Fragment key={round}>\r\n <BracketRound\r\n round={round}\r\n matches={groupedMatches[round]}\r\n totalRounds={rounds.length}\r\n renderMatch={(match, matchIdx) => renderMatch(match, round, matchIdx)}\r\n matchHeight={fixedMatchHeight}\r\n matchWidth={matchWidth}\r\n gap={gap}\r\n roundName={getRoundName(round)}\r\n showRoundNames={showRoundNames}\r\n primaryColor={colors.primary}\r\n onMatchPositionsChange={handleMatchPositionsChange}\r\n zoomScale={enableZoomPan ? currentScale : 1}\r\n />\r\n {index < rounds.length - 1 && (\r\n <BracketConnectors\r\n leftRoundMatches={groupedMatches[round]}\r\n rightRoundMatches={groupedMatches[rounds[index + 1]]}\r\n leftRound={round}\r\n rightRound={rounds[index + 1]}\r\n matchHeight={fixedMatchHeight}\r\n gap={gap}\r\n color={colors.secondary}\r\n showRoundNames={showRoundNames}\r\n matchPositions={matchPositions}\r\n zoomScale={enableZoomPan ? currentScale : 1}\r\n />\r\n )}\r\n </React.Fragment>\r\n ))}\r\n </div>\r\n )}\r\n </div>\r\n );\r\n\r\n return (\r\n <div\r\n className={className}\r\n style={{ ...defaultStyles, ...style, position: 'relative', overflow: 'hidden' }}\r\n >\r\n {/* Controls must be inside the viewport container, not fixed to window */}\r\n {enableZoomPan && (\r\n <div style={{\r\n position: 'absolute',\r\n bottom: '24px',\r\n right: '24px',\r\n zIndex: 100,\r\n display: 'flex',\r\n gap: '8px',\r\n backgroundColor: 'white',\r\n padding: '8px',\r\n borderRadius: '6px',\r\n boxShadow: '0 2px 8px rgba(0,0,0,0.15)'\r\n }}>\r\n {/* The actual controls will be rendered by TransformWrapper below, so this is just a placeholder for correct stacking context */}\r\n </div>\r\n )}\r\n {enableZoomPan ? (\r\n <TransformWrapper\r\n initialScale={initialZoom}\r\n minScale={0.25}\r\n maxScale={2}\r\n wheel={{ disabled: true }}\r\n doubleClick={{ disabled: true }}\r\n panning={{\r\n disabled: isDragging,\r\n excluded: enableDragDrop ? ['draggable-participant', 'match-container'] : [],\r\n velocityDisabled: false\r\n }}\r\n limitToBounds={false}\r\n centerOnInit={true}\r\n onTransformed={(ref, state) => {\r\n setCurrentScale(state.scale);\r\n }}\r\n >\r\n {({ zoomIn, zoomOut, resetTransform, setTransform }) => {\r\n // Custom zoom functions with fixed steps\r\n const zoomToLevel = (level: number) => {\r\n setTransform(0, 0, level, 300, 'easeOut');\r\n };\r\n\r\n const zoomLevels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];\r\n const currentLevelIndex = zoomLevels.findIndex(level => Math.abs(level - currentScale) < 0.01);\r\n const canZoomIn = currentLevelIndex < zoomLevels.length - 1;\r\n const canZoomOut = currentLevelIndex > 0;\r\n\r\n const handleZoomIn = () => {\r\n if (canZoomIn) {\r\n zoomToLevel(zoomLevels[currentLevelIndex + 1]);\r\n }\r\n };\r\n\r\n const handleZoomOut = () => {\r\n if (canZoomOut) {\r\n zoomToLevel(zoomLevels[currentLevelIndex - 1]);\r\n }\r\n };\r\n\r\n return (\r\n <>\r\n <div style={{\r\n position: 'absolute',\r\n bottom: '24px',\r\n right: '24px',\r\n zIndex: 100,\r\n display: 'flex',\r\n gap: '8px',\r\n backgroundColor: 'white',\r\n padding: '8px',\r\n borderRadius: '6px',\r\n boxShadow: '0 2px 8px rgba(0,0,0,0.15)'\r\n }}>\r\n <button\r\n onClick={handleZoomOut}\r\n disabled={!canZoomOut}\r\n style={{\r\n padding: '6px 12px',\r\n border: '1px solid #ddd',\r\n borderRadius: '4px',\r\n backgroundColor: 'white',\r\n cursor: canZoomOut ? 'pointer' : 'not-allowed',\r\n fontSize: '16px',\r\n opacity: canZoomOut ? 1 : 0.5\r\n }}\r\n title=\"Zoom Out\"\r\n >\r\n −\r\n </button>\r\n <span style={{\r\n padding: '6px 12px',\r\n fontSize: '14px',\r\n fontWeight: 'bold',\r\n display: 'flex',\r\n alignItems: 'center',\r\n minWidth: '60px',\r\n justifyContent: 'center'\r\n }}>\r\n {Math.round(currentScale * 100)}%\r\n </span>\r\n <button\r\n onClick={handleZoomIn}\r\n disabled={!canZoomIn}\r\n style={{\r\n padding: '6px 12px',\r\n border: '1px solid #ddd',\r\n borderRadius: '4px',\r\n backgroundColor: 'white',\r\n cursor: canZoomIn ? 'pointer' : 'not-allowed',\r\n fontSize: '16px',\r\n opacity: canZoomIn ? 1 : 0.5\r\n }}\r\n title=\"Zoom In\"\r\n >\r\n +\r\n </button>\r\n <button\r\n onClick={() => zoomToLevel(1)}\r\n style={{\r\n padding: '6px 12px',\r\n border: '1px solid #ddd',\r\n borderRadius: '4px',\r\n backgroundColor: 'white',\r\n cursor: 'pointer',\r\n fontSize: '12px'\r\n }}\r\n title=\"Reset to 100%\"\r\n >\r\n ↺\r\n </button>\r\n <button\r\n onClick={() => setTransform(0, 0, currentScale, 300, 'easeOut')}\r\n style={{\r\n padding: '6px 12px',\r\n border: '1px solid #1976d2',\r\n borderRadius: '4px',\r\n backgroundColor: '#1976d2',\r\n color: 'white',\r\n cursor: 'pointer',\r\n fontSize: '12px',\r\n fontWeight: 600\r\n }}\r\n title=\"Center Bracket\"\r\n >\r\n Center\r\n </button>\r\n </div>\r\n <TransformComponent\r\n wrapperStyle={{\r\n width: '100%',\r\n height: '100%'\r\n }}\r\n contentStyle={{\r\n width: '100%',\r\n height: '100%'\r\n }}\r\n >\r\n {bracketContent}\r\n </TransformComponent>\r\n </>\r\n );\r\n }}\r\n </TransformWrapper>\r\n ) : (\r\n <div style={{\r\n width: '100%',\r\n height: '100%',\r\n overflow: 'auto'\r\n }}>\r\n {bracketContent}\r\n </div>\r\n )}\r\n </div>\r\n );\r\n}\r\n\r\n// Export types for consumers\r\nexport type { BracketProps, Match, Participant, BracketError, BracketChangeEvent, DragDropResult } from '../types';\r\n","import { Match, BracketError } from '../types';\r\n\r\n/**\r\n * Validate bracket structure and return errors\r\n */\r\nexport function validateBracket(matches: Match[]): BracketError[] {\r\n const errors: BracketError[] = [];\r\n\r\n if (!matches || matches.length === 0) {\r\n return errors;\r\n }\r\n\r\n // Check for duplicate match IDs\r\n const matchIds = new Set<string | number>();\r\n matches.forEach(match => {\r\n if (matchIds.has(match.id)) {\r\n errors.push({\r\n matchId: match.id,\r\n message: 'Duplicate match ID detected',\r\n type: 'error'\r\n });\r\n }\r\n matchIds.add(match.id);\r\n });\r\n\r\n // Check for invalid nextMatchId references\r\n matches.forEach(match => {\r\n if (match.nextMatchId && !matchIds.has(match.nextMatchId)) {\r\n errors.push({\r\n matchId: match.id,\r\n message: `Invalid nextMatchId: ${match.nextMatchId}`,\r\n type: 'error'\r\n });\r\n }\r\n });\r\n\r\n // Check for participants without IDs\r\n matches.forEach(match => {\r\n if (match.participant1 && !match.participant1.id) {\r\n errors.push({\r\n matchId: match.id,\r\n message: 'Participant 1 missing ID',\r\n type: 'warning'\r\n });\r\n }\r\n if (match.participant2 && !match.participant2.id) {\r\n errors.push({\r\n matchId: match.id,\r\n message: 'Participant 2 missing ID',\r\n type: 'warning'\r\n });\r\n }\r\n });\r\n\r\n // Check for winner not matching participants\r\n matches.forEach(match => {\r\n if (match.winner) {\r\n const participant1Id = match.participant1?.id;\r\n const participant2Id = match.participant2?.id;\r\n\r\n if (match.winner !== participant1Id && match.winner !== participant2Id) {\r\n errors.push({\r\n matchId: match.id,\r\n message: 'Winner ID does not match any participant',\r\n type: 'error'\r\n });\r\n }\r\n }\r\n });\r\n\r\n return errors;\r\n}\r\n\r\n/**\r\n * Check if a specific match has errors\r\n */\r\nexport function hasMatchError(matchId: string | number, errors: BracketError[]): boolean {\r\n return errors.some(error => error.matchId === matchId);\r\n}\r\n\r\n/**\r\n * Get error message for a specific match\r\n */\r\nexport function getMatchErrorMessage(matchId: string | number, errors: BracketError[]): string | undefined {\r\n const matchErrors = errors.filter(error => error.matchId === matchId);\r\n return matchErrors.map(e => e.message).join(', ');\r\n}\r\n","import React, { memo, forwardRef, useState } from 'react';\r\nimport { MatchComponentProps, Participant } from '../types';\r\n\r\ninterface MatchComponentWithRefProps extends Omit<MatchComponentProps, 'schedule'> {\r\n forwardedRef?: React.Ref<HTMLDivElement>;\r\n schedule?: string | Date;\r\n}\r\n\r\nconst MatchComponent: React.FC<MatchComponentWithRefProps> = ({\r\n match,\r\n hasError,\r\n errorMessage,\r\n enableDragDrop,\r\n onDragStart,\r\n onDragEnd,\r\n onDragOver,\r\n onDrop,\r\n colors,\r\n matchWidth,\r\n matchHeight,\r\n forwardedRef,\r\n onScheduleChange,\r\n schedule\r\n}) => {\r\n const isParticipant1Winner = match.winner === match.participant1?.id;\r\n const isParticipant2Winner = match.winner === match.participant2?.id;\r\n\r\n const matchStyles: React.CSSProperties = {\r\n border: hasError ? `2px solid ${colors.error}` : `1px solid #ccc`,\r\n borderRadius: '6px',\r\n padding: '8px',\r\n backgroundColor: hasError ? '#ffebee' : 'white',\r\n display: 'flex',\r\n flexDirection: 'column',\r\n minWidth: `${matchWidth}px`,\r\n minHeight: `${matchHeight}px`,\r\n height: `${matchHeight}px`,\r\n boxShadow: hasError ? `0 0 0 3px ${colors.error}33, 0 0 12px ${colors.error}` : '0 1px 3px rgba(0,0,0,0.1)',\r\n transition: 'all 0.2s ease',\r\n position: 'relative',\r\n overflow: 'visible', // Đảm bảo tooltip không bị cắt\r\n };\r\n\r\n const participantStyles = (participant: Participant | null | undefined, isWinner: boolean, slot: 'participant1' | 'participant2'): React.CSSProperties => ({\r\n padding: '8px 12px',\r\n borderBottom: slot === 'participant1' ? '1px solid #eee' : 'none',\r\n display: 'flex',\r\n justifyContent: 'space-between',\r\n alignItems: 'center',\r\n fontWeight: isWinner ? 'bold' : 'normal',\r\n backgroundColor: isWinner ? colors.winner : 'transparent',\r\n cursor: enableDragDrop && participant ? 'grab' : 'default',\r\n transition: 'background-color 0.2s ease',\r\n borderRadius: '4px',\r\n minHeight: '32px'\r\n });\r\n\r\n const renderParticipant = (\r\n participant: Participant | null | undefined,\r\n isWinner: boolean,\r\n slot: 'participant1' | 'participant2'\r\n ) => {\r\n if (!participant) {\r\n const style = participantStyles(null, false, slot);\r\n // Log placeholder style\r\n // eslint-disable-next-line no-console\r\n console.log(`[MatchComponent] Placeholder for match ${match.id} slot ${slot}:`, style);\r\n return (\r\n <div\r\n className={enableDragDrop ? 'draggable-participant' : undefined}\r\n style={{\r\n ...style,\r\n fontStyle: 'italic',\r\n color: '#999',\r\n cursor: enableDragDrop ? 'pointer' : 'default',\r\n backgroundColor: enableDragDrop ? '#f5f5f5' : 'transparent'\r\n }}\r\n onDragOver={enableDragDrop ? onDragOver : undefined}\r\n onDrop={enableDragDrop ? (e) => {\r\n e.preventDefault();\r\n onDrop(match.id, slot);\r\n } : undefined}\r\n >\r\n TBD\r\n </div>\r\n );\r\n }\r\n\r\n return (\r\n <div\r\n className={enableDragDrop ? 'draggable-participant' : undefined}\r\n draggable={enableDragDrop}\r\n onDragStart={enableDragDrop ? (e) => {\r\n e.dataTransfer.effectAllowed = 'move';\r\n onDragStart(match.id, participant.id, slot);\r\n } : undefined}\r\n onDragEnd={enableDragDrop && onDragEnd ? onDragEnd : undefined}\r\n onDragOver={enableDragDrop ? onDragOver : undefined}\r\n onDrop={enableDragDrop ? (e) => {\r\n e.preventDefault();\r\n onDrop(match.id, slot);\r\n } : undefined}\r\n style={participantStyles(participant, isWinner, slot)}\r\n onMouseEnter={(e) => {\r\n if (enableDragDrop && participant) {\r\n e.currentTarget.style.backgroundColor = isWinner ? colors.winner : '#f0f0f0';\r\n }\r\n }}\r\n onMouseLeave={(e) => {\r\n if (enableDragDrop && participant) {\r\n e.currentTarget.style.backgroundColor = isWinner ? colors.winner : 'transparent';\r\n }\r\n }}\r\n >\r\n <span style={{ flex: 1 }}>{participant.name}</span>\r\n {typeof participant.score === 'number' && (\r\n <span style={{\r\n fontWeight: 'bold',\r\n marginLeft: '8px',\r\n fontSize: '14px',\r\n color: colors.primary\r\n }}>\r\n {participant.score}\r\n </span>\r\n )}\r\n </div>\r\n );\r\n };\r\n\r\n // Log match style and participants\r\n // eslint-disable-next-line no-console\r\n console.log(`[MatchComponent] Render match ${match.id} style:`, matchStyles, {\r\n participant1: match.participant1,\r\n participant2: match.participant2\r\n });\r\n // Format schedule if present (from prop)\r\n let formattedSchedule = '';\r\n if (schedule) {\r\n let dateObj: Date | null = null;\r\n if (typeof schedule === 'string') {\r\n const parsed = Date.parse(schedule);\r\n if (!isNaN(parsed)) dateObj = new Date(parsed);\r\n } else if (schedule instanceof Date) {\r\n dateObj = schedule;\r\n }\r\n if (dateObj) {\r\n const pad = (n: number) => n < 10 ? '0' + n : n;\r\n const h = pad(dateObj.getHours());\r\n const m = pad(dateObj.getMinutes());\r\n const d = pad(dateObj.getDate());\r\n const mo = pad(dateObj.getMonth() + 1);\r\n const y = dateObj.getFullYear();\r\n formattedSchedule = `${h}:${m}, ${d}/${mo}/${y}`;\r\n } else if (typeof schedule === 'string') {\r\n formattedSchedule = schedule;\r\n }\r\n }\r\n\r\n // Tooltip hover state\r\n const [hovered, setHovered] = useState(false);\r\n\r\n return (\r\n <div\r\n className={(enableDragDrop ? 'match-container ' : '') + 'relative'}\r\n style={{ ...matchStyles, zIndex: hasError && hovered ? 1050 : undefined }}\r\n ref={forwardedRef}\r\n onMouseEnter={() => setHovered(true)}\r\n onMouseLeave={() => setHovered(false)}\r\n >\r\n <div style={{\r\n fontSize: '11px',\r\n color: hasError ? colors.error : '#666',\r\n fontWeight: hasError ? 'bold' : 'normal',\r\n display: 'flex',\r\n flexDirection: 'row',\r\n alignItems: 'center',\r\n justifyContent: 'space-between',\r\n position: 'relative',\r\n gap: 8\r\n }}>\r\n <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>\r\n Match {match.matchNumber || match.id}\r\n </span>\r\n <div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>\r\n <input\r\n type=\"datetime-local\"\r\n value={(() => {\r\n if (!schedule) return '';\r\n let d: Date | null = null;\r\n if (typeof schedule === 'string') {\r\n const parsed = Date.parse(schedule);\r\n if (!isNaN(parsed)) d = new Date(parsed);\r\n } else if (schedule instanceof Date) {\r\n d = schedule;\r\n }\r\n if (!d) return '';\r\n const pad = (n: number) => n < 10 ? '0' + n : n;\r\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;\r\n })()}\r\n onChange={e => {\r\n if (typeof match.id !== 'undefined' && typeof e.target.value === 'string' && e.target.value && typeof onScheduleChange === 'function') {\r\n onScheduleChange(match.id, e.target.value);\r\n }\r\n }}\r\n style={{\r\n fontSize: 12,\r\n color: '#888',\r\n fontWeight: 400,\r\n border: '1px solid #ccc',\r\n borderRadius: 4,\r\n padding: '2px 4px',\r\n background: '#fafbfc',\r\n minWidth: 120,\r\n maxWidth: 150\r\n }}\r\n title=\"Chỉnh sửa lịch thi đấu\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {renderParticipant(match.participant1, isParticipant1Winner, 'participant1')}\r\n {renderParticipant(match.participant2, isParticipant2Winner, 'participant2')}\r\n\r\n {/* Custom error tooltip (Tailwind) */}\r\n {hasError && hovered && errorMessage && (\r\n <div\r\n className=\"absolute left-1/2 -translate-x-1/2 top-full mt-2 z-[1051] bg-red-600 text-white px-3 py-2 rounded shadow-lg text-sm\"\r\n style={{\r\n pointerEvents: 'none',\r\n boxShadow: '0 0 0 2px #d32f2f, 0 4px 16px #d32f2faa',\r\n background: '#d32f2f',\r\n minWidth: 0,\r\n maxWidth: matchWidth - 16,\r\n wordBreak: 'break-word',\r\n whiteSpace: 'pre-line',\r\n textAlign: 'left',\r\n }}\r\n >\r\n {errorMessage}\r\n </div>\r\n )}\r\n </div>\r\n );\r\n};\r\n\r\nexport default memo(MatchComponent);\r\n","import React, { useRef, useEffect, useState } from 'react';\r\nimport { Match } from '../types';\r\n\r\ninterface BracketRoundProps {\r\n round: number;\r\n matches: Match[];\r\n totalRounds: number;\r\n renderMatch: (match: Match, matchIndex: number) => React.ReactNode;\r\n matchHeight: number;\r\n gap: number;\r\n roundName?: string;\r\n showRoundNames: boolean;\r\n primaryColor: string;\r\n matchWidth: number;\r\n onMatchPositionsChange?: (positions: { matchId: string | number; top: number; height: number }[]) => void;\r\n zoomScale?: number;\r\n}\r\n\r\nconst BracketRound: React.FC<BracketRoundProps> = ({\r\n round,\r\n matches,\r\n totalRounds,\r\n renderMatch,\r\n matchHeight,\r\n gap,\r\n roundName,\r\n showRoundNames,\r\n primaryColor,\r\n matchWidth,\r\n onMatchPositionsChange,\r\n zoomScale = 1\r\n}) => {\r\n const matchRefs = useRef<Map<string | number, HTMLDivElement>>(new Map());\r\n const containerRef = useRef<HTMLDivElement>(null);\r\n // No need to measure actual match height; always use matchHeight from props for all spacing/topPadding\r\n\r\n // Measure actual match positions after render\r\n useEffect(() => {\r\n if (!onMatchPositionsChange || !containerRef.current) return;\r\n\r\n const positions: { matchId: string | number; top: number; height: number }[] = [];\r\n const containerTop = containerRef.current.getBoundingClientRect().top;\r\n\r\n matches.forEach((match) => {\r\n const element = matchRefs.current.get(match.id);\r\n if (element) {\r\n const rect = element.getBoundingClientRect();\r\n // eslint-disable-next-line no-console\r\n console.log(`[BracketRound] round ${round} match ${match.id} measured top:`, (rect.top - containerTop) / zoomScale, 'height:', rect.height / zoomScale);\r\n positions.push({\r\n matchId: match.id,\r\n top: (rect.top - containerTop) / zoomScale,\r\n height: rect.height / zoomScale\r\n });\r\n }\r\n });\r\n\r\n onMatchPositionsChange(positions);\r\n }, [matches, onMatchPositionsChange, zoomScale]);\r\n\r\n // Calculate spacing between matches using FIXED matchHeight from props\r\n // Round 1: gap\r\n // Round 2: matchHeight + 2*gap\r\n // Round 3: 3*matchHeight + 4*gap\r\n // Round 4: 7*matchHeight + 8*gap\r\n // Formula: (2^(round-1) - 1) * matchHeight + 2^(round-1) * gap\r\n const matchSpacing = round === 1\r\n ? gap\r\n : (Math.pow(2, round - 1) - 1) * matchHeight + Math.pow(2, round - 1) * gap;\r\n // eslint-disable-next-line no-console\r\n console.log(`[BracketRound] round ${round} matchSpacing:`, matchSpacing, 'fixed matchHeight:', matchHeight, 'gap:', gap);\r\n\r\n // Top padding: centers first match relative to its children using FIXED matchHeight\r\n const topPadding = round === 1\r\n ? 0\r\n : (matchHeight + gap) * (Math.pow(2, round - 1) - 1) / 2;\r\n // eslint-disable-next-line no-console\r\n console.log(`[BracketRound] round ${round} topPadding:`, topPadding);\r\n\r\n return (\r\n <div style={{\r\n display: 'flex',\r\n flexDirection: 'column',\r\n minWidth: `${matchWidth + 20}px`\r\n }}>\r\n {showRoundNames && roundName && (\r\n <h4 style={{\r\n margin: '0 0 12px 0',\r\n color: primaryColor,\r\n fontSize: '14px',\r\n fontWeight: '600',\r\n textAlign: 'center',\r\n height: '30px'\r\n }}>\r\n {roundName}\r\n </h4>\r\n )}\r\n <div\r\n ref={containerRef}\r\n style={{\r\n display: 'flex',\r\n flexDirection: 'column',\r\n paddingTop: `${topPadding}px`\r\n }}\r\n >\r\n {matches.map((match, index) => (\r\n <div\r\n key={match.id}\r\n ref={(el) => {\r\n if (el) {\r\n matchRefs.current.set(match.id, el);\r\n } else {\r\n matchRefs.current.delete(match.id);\r\n }\r\n }}\r\n style={{\r\n marginBottom: index < matches.length - 1 ? `${matchSpacing}px` : '0',\r\n visibility: match.isVirtual ? 'hidden' : 'visible',\r\n }}\r\n >\r\n {/* For round 1, index 0, pass ref to renderMatch for DOM measurement */}\r\n {renderMatch(match, index)}\r\n </div>\r\n ))}\r\n </div>\r\n </div>\r\n );\r\n};\r\n\r\nexport default BracketRound;\r\n","import React from 'react';\r\nimport { Match } from '../types';\r\n\r\ninterface BracketConnectorsProps {\r\n leftRoundMatches: Match[];\r\n rightRoundMatches: Match[];\r\n leftRound: number;\r\n rightRound: number;\r\n matchHeight: number;\r\n gap: number;\r\n color: string;\r\n showRoundNames: boolean;\r\n matchPositions: Map<string | number, { top: number; height: number }>;\r\n zoomScale?: number;\r\n}\r\n\r\nconst BracketConnectors: React.FC<BracketConnectorsProps> = ({\r\n leftRoundMatches,\r\n rightRoundMatches,\r\n leftRound,\r\n rightRound,\r\n matchHeight,\r\n gap,\r\n color,\r\n showRoundNames,\r\n matchPositions,\r\n zoomScale = 1\r\n}) => {\r\n const connectorWidth = 50;\r\n\r\n // Get actual match height from measured positions\r\n const getActualMatchHeight = (matches: Match[]): number => {\r\n if (matches.length === 0) return matchHeight;\r\n\r\n let totalHeight = 0;\r\n let count = 0;\r\n\r\n matches.forEach(match => {\r\n const pos = matchPositions.get(match.id);\r\n if (pos && pos.height > 0) {\r\n totalHeight += pos.height;\r\n count++;\r\n // eslint-disable-next-line no-console\r\n console.log(`[BracketConnectors] getActualMatchHeight match ${match.id} height:`, pos.height);\r\n }\r\n });\r\n\r\n return count > 0 ? totalHeight / count : matchHeight;\r\n };\r\n\r\n const actualLeftMatchHeight = getActualMatchHeight(leftRoundMatches);\r\n const actualRightMatchHeight = getActualMatchHeight(rightRoundMatches);\r\n\r\n // Calculate spacing using ACTUAL measured heights\r\n // Formula: (2^(round-1) - 1) * matchHeight + 2^(round-1) * gap\r\n const leftMatchSpacing = leftRound === 1\r\n ? gap\r\n : (Math.pow(2, leftRound - 1) - 1) * actualLeftMatchHeight + Math.pow(2, leftRound - 1) * gap;\r\n // eslint-disable-next-line no-console\r\n console.log(`[BracketConnectors] leftRound ${leftRound} leftMatchSpacing:`, leftMatchSpacing, 'actualLeftMatchHeight:', actualLeftMatchHeight, 'gap:', gap);\r\n\r\n const leftTopPadding = leftRound === 1\r\n ? 0\r\n : (actualLeftMatchHeight + gap) * (Math.pow(2, leftRound - 1) - 1) / 2;\r\n // eslint-disable-next-line no-console\r\n console.log(`[BracketConnectors] leftRound ${leftRound} leftTopPadding:`, leftTopPadding);\r\n\r\n const rightMatchSpacing = rightRound === 1\r\n ? gap\r\n : (Math.pow(2, rightRound - 1) - 1) * actualRightMatchHeight + Math.pow(2, rightRound - 1) * gap;\r\n // eslint-disable-next-line no-console\r\n console.log(`[BracketConnectors] rightRound ${rightRound} rightMatchSpacing:`, rightMatchSpacing, 'actualRightMatchHeight:', actualRightMatchHeight, 'gap:', gap);\r\n\r\n const rightTopPadding = rightRound === 1\r\n ? 0\r\n : (actualRightMatchHeight + gap) * (Math.pow(2, rightRound - 1) - 1) / 2;\r\n // eslint-disable-next-line no-console\r\n console.log(`[BracketConnectors] rightRound ${rightRound} rightTopPadding:`, rightTopPadding);\r\n\r\n // Calculate total height needed (use actual heights)\r\n const leftTotalHeight = leftTopPadding + (leftRoundMatches.length * actualLeftMatchHeight) +\r\n ((leftRoundMatches.length - 1) * leftMatchSpacing);\r\n const rightTotalHeight = rightTopPadding + (rightRoundMatches.length * actualRightMatchHeight) +\r\n ((rightRoundMatches.length - 1) * rightMatchSpacing);\r\n const totalHeight = Math.max(leftTotalHeight, rightTotalHeight);\r\n\r\n // Generate connector paths\r\n const connectors: React.ReactElement[] = [];\r\n\r\n rightRoundMatches.forEach((rightMatch, rightIndex) => {\r\n // Find the left matches that connect to this right match\r\n let leftMatches = leftRoundMatches.filter(\r\n m => m.nextMatchId === rightMatch.id\r\n );\r\n\r\n // Nếu là round 1, loại bỏ các match ảo (isVirtual)\r\n if (leftRound === 1) {\r\n leftMatches = leftMatches.filter(m => !m.isVirtual);\r\n }\r\n\r\n if (leftMatches.length === 0) return;\r\n\r\n // Get actual position from DOM measurement\r\n const rightPos = matchPositions.get(rightMatch.id);\r\n if (!rightPos) return;\r\n\r\n const rightY = rightPos.top + rightPos.height / 2;\r\n\r\n if (leftMatches.length === 1) {\r\n // Only one match connects - still draw 3 lines (horizontal-vertical-horizontal)\r\n const leftMatch = leftMatches[0];\r\n const leftPos = matchPositions.get(leftMatch.id);\r\n if (!leftPos) return;\r\n\r\n const leftY = leftPos.top + leftPos.height / 2;\r\n const meetX = connectorWidth / 2;\r\n\r\n // Horizontal line from left match to middle\r\n connectors.push(\r\n <line\r\n key={`connector-h-left-${leftMatch.id}`}\r\n x1=\"0\"\r\n y1={leftY}\r\n x2={meetX}\r\n y2={leftY}\r\n stroke={color}\r\n strokeWidth=\"2\"\r\n />\r\n );\r\n\r\n // Vertical line in the middle connecting left and right\r\n connectors.push(\r\n <line\r\n key={`connector-v-${leftMatch.id}-${rightMatch.id}`}\r\n x1={meetX}\r\n y1={leftY}\r\n x2={meetX}\r\n y2={rightY}\r\n stroke={color}\r\n strokeWidth=\"2\"\r\n />\r\n );\r\n\r\n // Horizontal line from middle to right match\r\n connectors.push(\r\n <line\r\n key={`connector-h-right-${rightMatch.id}`}\r\n x1={meetX}\r\n y1={rightY}\r\n x2={connectorWidth}\r\n y2={rightY}\r\n stroke={color}\r\n strokeWidth=\"2\"\r\n />\r\n );\r\n } else {\r\n // Multiple matches connect - draw with vertical line in middle\r\n