From 3a7b72b01b6adf11e8b0d2e45a1808c7eb2b6cd3 Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Sun, 21 Jun 2026 00:00:03 -0700 Subject: [PATCH] update bracket style --- frontend/src/App.jsx | 264 ++++++++++++++++++---------------------- frontend/src/bracket.js | 145 ++++++++++++++++++++++ frontend/src/styles.css | 41 +++---- 3 files changed, 281 insertions(+), 169 deletions(-) create mode 100644 frontend/src/bracket.js 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 (

{side.label}

-
- {rounds.map((round, roundIndex) => ( -
+
+ + {side.columns.map((column, columnIndex) => ( +

- {roundLabel(side.raw, round.round, roundIndex, rounds.length)} + {column.label}

- {round.matches.map((match) => ( + {column.matches.map((match) => (
0 ? 'tournament-match-node-left' : '', - roundIndex < rounds.length - 1 ? 'tournament-match-node-right' : '', - ].filter(Boolean).join(' ')} + className="tournament-match-node" key={`${match.type_bracket}-${match.match_id}`} + ref={(el) => { + const key = nodeKey(columnIndex, match) + if (el) nodeRefs.current.set(key, el) + else nodeRefs.current.delete(key) + }} > - +
))}
@@ -4013,20 +3993,23 @@ function TournamentStandings({ standings }) { function TournamentMatchList({ sides, navigate }) { return (
- {sides.map((side) => ( -
-

{side.label}

-
- {side.matches.map((match) => ( - - ))} + {sides.map((side) => { + const matches = side.columns.flatMap((column) => column.matches) + return ( +
+

{side.label}

+
+ {matches.map((match) => ( + + ))} +
-
- ))} + ) + })}
) } @@ -4053,10 +4036,11 @@ function TournamentDetailPage({ tournamentId, navigate }) { const data = state.data const matches = useMemo(() => data?.matches || [], [data]) const format = useMemo(() => tournamentFormatMeta(data?.format, matches), [data?.format, matches]) - const sides = useMemo(() => groupMatchesBySide(matches), [matches]) - const elimSides = sides.filter((side) => !side.isGroup) - const groupSides = sides.filter((side) => side.isGroup) + const { sides } = useMemo(() => buildBracket(matches), [matches]) + const bracketSides = sides.filter((side) => side.kind === 'bracket') + const listSides = sides.filter((side) => side.kind === 'list') const standings = data?.standings || [] + const hasStandings = standings.length > 0 return (
@@ -4101,44 +4085,28 @@ function TournamentDetailPage({ tournamentId, navigate }) {

) : null} - {format.mode === 'standings' && matches.length ? ( - <> - - - - ) : null} - - {format.mode === 'bracket' && matches.length ? ( -
- {elimSides.map((side) => ( - - ))} -
- ) : null} - - {format.mode === 'mixed' && matches.length ? ( + {matches.length ? (
- {groupSides.length ? ( + {hasStandings || listSides.length ? (
-

Group stage

- - + {bracketSides.length ?

Group stage

: null} + {hasStandings ? : null} + {listSides.length ? : null}
) : null} - {elimSides.length ? ( -
-

Playoffs

- {elimSides.map((side) => ( - + + {bracketSides.length ? ( +
+ {hasStandings || listSides.length ? ( +

Playoffs

+ ) : null} + {bracketSides.map((side) => ( + ))}
) : null}
) : null} - - {format.mode === 'matches' && matches.length ? ( - - ) : null}
) } diff --git a/frontend/src/bracket.js b/frontend/src/bracket.js new file mode 100644 index 0000000..2c90396 --- /dev/null +++ b/frontend/src/bracket.js @@ -0,0 +1,145 @@ +// Pure bracket-layout logic for the tournament detail page. +// +// The TSS API hands us authoritative matches, but `round` is numbered *per +// `type_bracket`* — the winner tree, the loser tree, and the special +// `LooserFinal`/`Semifinal`/`Final` stages each restart at round 0. Grouping by +// raw round therefore collapses unrelated stages into one column (e.g. the loser +// Semifinal landing in loser "Round 1"). This module maps each match to a +// (side, stage, round) coordinate so columns come out in true bracket order, and +// drops structural byes so they never render as "Team vs BYE" nodes. +// +// Kept framework-free so it can be unit-tested with plain node. + +function lower(value) { + return String(value || '').toLowerCase() +} + +export function cleanName(value) { + return String(value || '').trim() +} + +// A "structural bye" is a slot that exists only to pad the bracket: a team that +// advanced unopposed, or a placeholder whose feeders were themselves byes. We +// hide these. A match with one known team and NO winner yet is *not* a bye — +// it's a real pending matchup awaiting an upstream result (grand final, loser +// bracket final), so it stays. +export function isStructuralBye(match) { + const a = cleanName(match.team_a_name) + const b = cleanName(match.team_b_name) + if (a && b) return false + if (!a && !b) return true + return Boolean(cleanName(match.winner_name)) || match.status === 'bye' +} + +// Map a TSS `type_bracket` to a display side + stage. `stage` orders the special +// terminal columns after the numbered rounds within a side: +// winner: Winner rounds (0) → grand/championship Final (1) +// loser: Looser rounds (0) → LooserFinal (1) → Semifinal/lower final (2) +export function classifyBracket(typeBracket, hasLoserSide) { + const t = lower(typeBracket) + if (t.includes('swiss')) return { side: 'swiss', stage: 0 } + if (t.includes('group')) return { side: 'group', stage: 0 } + if (t.includes('looserfinal') || t.includes('loserfinal')) return { side: 'loser', stage: 1 } + if (t.includes('looser') || t.includes('loser')) return { side: 'loser', stage: 0 } + if (t.includes('semifinal')) { + // Double-elim: the Semifinal is the lower-bracket final (loser side). Single + // elim: it's just a normal winner-side stage. + return hasLoserSide ? { side: 'loser', stage: 2 } : { side: 'winner', stage: 0 } + } + if (t === 'final' || t.endsWith('final')) { + // Terminal column of the winner side (championship / grand final). + return { side: 'winner', stage: 1 } + } + if (t.includes('winner')) return { side: 'winner', stage: 0 } + return { side: 'winner', stage: 0 } +} + +const SIDE_ORDER = { winner: 0, loser: 1, group: 2, swiss: 3, matches: 4 } +const SIDE_LABELS = { + winner: 'Winner bracket', + loser: 'Loser bracket', + group: 'Group matches', + swiss: 'Swiss matches', + matches: 'Matches', +} + +function asInt(value, fallback) { + const n = Number(value) + return Number.isFinite(n) ? n : fallback +} + +function compareMatches(a, b) { + return asInt(a.position, Number.MAX_SAFE_INTEGER) - asInt(b.position, Number.MAX_SAFE_INTEGER) + || String(a.match_id).localeCompare(String(b.match_id)) +} + +// Label a bracket column. Numbered rounds read "Round N" by their position in +// the side; the terminal winner stage reads Final / Grand Final. +function columnLabel(side, stage, index, total, hasLoserSide) { + if (side === 'winner' && stage === 1) { + return hasLoserSide ? 'Grand Final' : 'Final' + } + return `Round ${index + 1}` +} + +// Build the ordered bracket model from authoritative match rows. +// Returns { sides: [{ key, label, kind, columns: [{ id, label, matches }] }] } +// where kind is 'bracket' (elimination, drawn as a tree) or 'list' (group/swiss, +// drawn as a flat card list). +export function buildBracket(matches) { + const cleaned = (matches || []).filter((m) => !isStructuralBye(m)) + + const hasLoserSide = cleaned.some((m) => { + const t = lower(m.type_bracket) + return t.includes('looser') || t.includes('loser') || lower(m.side) === 'loser' + }) + + // Group by (side, stage, round) → one column each. + const columnMap = new Map() + for (const match of cleaned) { + const { side, stage } = classifyBracket(match.type_bracket, hasLoserSide) + const round = asInt(match.round, 0) + const key = `${side}:${stage}:${round}` + if (!columnMap.has(key)) { + columnMap.set(key, { side, stage, round, matches: [] }) + } + columnMap.get(key).matches.push(match) + } + + // Bucket columns by side, ordered by (stage, round). + const sideMap = new Map() + for (const column of columnMap.values()) { + column.matches.sort(compareMatches) + if (!sideMap.has(column.side)) sideMap.set(column.side, []) + sideMap.get(column.side).push(column) + } + + const sides = [...sideMap.entries()] + .map(([key, columns]) => { + columns.sort((a, b) => a.stage - b.stage || a.round - b.round) + const isList = key === 'group' || key === 'swiss' || key === 'matches' + return { + key, + label: SIDE_LABELS[key] || key, + kind: isList ? 'list' : 'bracket', + columns: columns.map((column, index) => ({ + id: `${key}:${column.stage}:${column.round}`, + label: columnLabel(key, column.stage, index, columns.length, hasLoserSide), + matches: column.matches, + })), + } + }) + .sort((a, b) => (SIDE_ORDER[a.key] ?? 9) - (SIDE_ORDER[b.key] ?? 9)) + + return { sides, hasLoserSide } +} + +// Which match in the next column a given match feeds into, by slot position. +// Winner tree and loser "major" rounds halve (position → floor/2); loser "minor" +// rounds (drop-ins, same width) map identically. We infer halving vs identity +// from the column node counts. Returns the parent position, or null. +export function parentPosition(childPosition, currentCount, nextCount) { + if (childPosition == null) return null + if (nextCount >= currentCount) return childPosition + return Math.floor(childPosition / 2) +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f6a07e1..bd16033 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -832,13 +832,33 @@ h3 { } .tournament-bracket-grid { + position: relative; display: flex; gap: 2.6rem; min-width: max-content; } +/* Connector layer: absolutely sized to the full bracket, drawn behind the cards. + Paths are measured from real node positions in App.jsx, so they stay correct + with hidden byes and irregular loser-bracket round widths. */ +.tournament-bracket-lines { + position: absolute; + top: 0; + left: 0; + z-index: 0; + overflow: visible; + pointer-events: none; +} + +.tournament-bracket-lines path { + fill: none; + stroke: color-mix(in srgb, var(--color-border) 60%, var(--color-fury-violet)); + stroke-width: 1.5px; +} + .tournament-round-column { position: relative; + z-index: 1; display: flex; width: 190px; min-width: 190px; @@ -851,27 +871,6 @@ h3 { z-index: 1; } -.tournament-match-node::before, -.tournament-match-node::after { - position: absolute; - top: 50%; - z-index: 0; - display: block; - height: 1px; - width: 1.3rem; - background: color-mix(in srgb, var(--color-border) 78%, var(--color-fury-violet)); - content: ""; - pointer-events: none; -} - -.tournament-match-node-left::before { - right: calc(100% + 0.05rem); -} - -.tournament-match-node-right::after { - left: calc(100% + 0.05rem); -} - @keyframes scrollPulse { 0% { transform: translateY(-100%);