diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d543d17..454783a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import Tree, { prewarmTreeCanvas } from '../Tree/Tree' import FallingLeaves from '../Tree/FallingLeaves' import ReplayCanvasPanel from './ReplayCanvas' +import { buildBracket, parentPosition } from './bracket' const numberFormat = new Intl.NumberFormat('en-GB') const dateFormat = new Intl.DateTimeFormat('en-GB', { @@ -3714,43 +3715,6 @@ function tournamentStatusLabel(status) { return raw.charAt(0).toUpperCase() + raw.slice(1) } -function sideFromMatch(match, context = {}) { - const bracket = String(match?.type_bracket || '').toLowerCase() - if (context.hasLoserSide && bracket.includes('semifinal')) return 'loser' - const side = String(match?.side || '').toLowerCase() - if (side) return side - if (bracket.includes('swiss')) return 'swiss' - if (bracket.includes('group')) return 'group' - if (bracket.includes('looser') || bracket.includes('loser')) return 'loser' - if (bracket.includes('final') || bracket.includes('semifinal')) return 'final' - if (bracket.includes('winner')) return 'winner' - return 'matches' -} - -function sideLabel(side) { - const labels = { - winner: 'Winner bracket', - loser: 'Loser bracket', - final: 'Finals', - group: 'Group matches', - swiss: 'Swiss matches', - matches: 'Matches', - } - return labels[side] || tournamentStatusLabel(side) -} - -function sidePriority(side) { - return { winner: 0, final: 1, loser: 2, group: 3, swiss: 4, matches: 5 }[side] ?? 6 -} - -function compareTournamentMatches(a, b) { - const roundA = a.round ?? Number.MAX_SAFE_INTEGER - const roundB = b.round ?? Number.MAX_SAFE_INTEGER - const posA = a.position ?? Number.MAX_SAFE_INTEGER - const posB = b.position ?? Number.MAX_SAFE_INTEGER - return roundA - roundB || posA - posB || String(a.match_id).localeCompare(String(b.match_id)) -} - function TournamentsPage({ navigate }) { const [state, setState] = useState({ status: 'loading', data: null, error: null }) @@ -3812,56 +3776,6 @@ function TournamentsPage({ navigate }) { ) } -function groupMatchesBySide(matches) { - const bySide = new Map() - const context = { - hasLoserSide: matches.some((match) => { - const bracket = String(match?.type_bracket || '').toLowerCase() - const side = String(match?.side || '').toLowerCase() - return bracket.includes('looser') || bracket.includes('loser') || side === 'loser' - }), - } - matches.forEach((match) => { - const side = sideFromMatch(match, context) - if (!bySide.has(side)) bySide.set(side, []) - bySide.get(side).push(match) - }) - return [...bySide.entries()] - .map(([raw, sideMatches]) => ({ - raw, - label: sideLabel(raw), - isGroup: raw === 'group' || raw === 'swiss', - matches: [...sideMatches].sort(compareTournamentMatches), - })) - .sort((a, b) => sidePriority(a.raw) - sidePriority(b.raw)) -} - -function roundsForSide(matches) { - const byRound = new Map() - matches.forEach((match) => { - const key = match.round ?? 'matches' - if (!byRound.has(key)) byRound.set(key, []) - byRound.get(key).push(match) - }) - return [...byRound.entries()] - .sort(([a], [b]) => { - if (a === 'matches') return 1 - if (b === 'matches') return -1 - return Number(a) - Number(b) - }) - .map(([round, roundMatches]) => ({ - round, - matches: [...roundMatches].sort(compareTournamentMatches), - })) -} - -function roundLabel(side, round, index, total) { - if (side === 'final' && total === 1) return 'Final' - if (round === 'matches') return 'Matches' - if (side === 'final') return index === total - 1 ? 'Final' : `Round ${Number(round) + 1}` - return `Round ${Number(round) + 1}` -} - function TournamentMatchCard({ match, navigate }) { const winner = displayTeamName(match.winner_name).toLowerCase() const teamA = displayTeamName(match.team_a_name) @@ -3926,32 +3840,98 @@ function TournamentMatchCard({ match, navigate }) { ) } +// Map each match to the match it feeds into in the next column, so we can draw a +// connector between them. Parent is found by slot position (faithful to the real +// tree even with byes hidden); falls back to proportional index if the exact +// parent slot was itself a hidden bye. +function feederParent(match, columnIndex, columns) { + const cur = columns[columnIndex] + const next = columns[columnIndex + 1] + if (!next) return null + const pPos = parentPosition(Number(match.position), cur.matches.length, next.matches.length) + const exact = next.matches.find((m) => Number(m.position) === pPos) + if (exact) return exact + const order = cur.matches.indexOf(match) + const idx = Math.min(next.matches.length - 1, Math.floor((order * next.matches.length) / cur.matches.length)) + return next.matches[idx] || null +} + function TournamentBracketSide({ side, navigate }) { - const rounds = roundsForSide(side.matches) + const gridRef = useRef(null) + const nodeRefs = useRef(new Map()) + const [connectors, setConnectors] = useState({ width: 0, height: 0, paths: [] }) + + const nodeKey = (columnIndex, match) => `${columnIndex}:${match.match_id}` + + useLayoutEffect(() => { + const grid = gridRef.current + if (!grid) return undefined + + const measure = () => { + const gridBox = grid.getBoundingClientRect() + const boxOf = (key) => { + const el = nodeRefs.current.get(key) + if (!el) return null + const b = el.getBoundingClientRect() + return { + left: b.left - gridBox.left, + right: b.right - gridBox.left, + mid: b.top - gridBox.top + b.height / 2, + } + } + const paths = [] + for (let c = 0; c < side.columns.length - 1; c += 1) { + for (const match of side.columns[c].matches) { + const parent = feederParent(match, c, side.columns) + if (!parent) continue + const from = boxOf(nodeKey(c, match)) + const to = boxOf(nodeKey(c + 1, parent)) + if (!from || !to) continue + const midX = (from.right + to.left) / 2 + paths.push(`M ${from.right} ${from.mid} H ${midX} V ${to.mid} H ${to.left}`) + } + } + setConnectors({ width: grid.scrollWidth, height: grid.scrollHeight, paths }) + } + + measure() + const observer = new ResizeObserver(measure) + observer.observe(grid) + return () => observer.disconnect() + }, [side]) + return (
- {roundLabel(side.raw, round.round, roundIndex, rounds.length)} + {column.label}