update bracket style

This commit is contained in:
FURRO404
2026-06-21 00:00:03 -07:00
parent 45142f280a
commit 3a7b72b01b
3 changed files with 281 additions and 169 deletions
+116 -148
View File
@@ -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 Tree, { prewarmTreeCanvas } from '../Tree/Tree'
import FallingLeaves from '../Tree/FallingLeaves' import FallingLeaves from '../Tree/FallingLeaves'
import ReplayCanvasPanel from './ReplayCanvas' import ReplayCanvasPanel from './ReplayCanvas'
import { buildBracket, parentPosition } from './bracket'
const numberFormat = new Intl.NumberFormat('en-GB') const numberFormat = new Intl.NumberFormat('en-GB')
const dateFormat = new Intl.DateTimeFormat('en-GB', { const dateFormat = new Intl.DateTimeFormat('en-GB', {
@@ -3714,43 +3715,6 @@ function tournamentStatusLabel(status) {
return raw.charAt(0).toUpperCase() + raw.slice(1) 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 }) { function TournamentsPage({ navigate }) {
const [state, setState] = useState({ status: 'loading', data: null, error: null }) 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 }) { function TournamentMatchCard({ match, navigate }) {
const winner = displayTeamName(match.winner_name).toLowerCase() const winner = displayTeamName(match.winner_name).toLowerCase()
const teamA = displayTeamName(match.team_a_name) 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 }) { 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 ( return (
<div> <div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3> <h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
<div className="overflow-x-auto pb-2"> <div className="overflow-x-auto pb-2">
<div className="tournament-bracket-grid"> <div className="tournament-bracket-grid" ref={gridRef}>
{rounds.map((round, roundIndex) => ( <svg
<div className="tournament-round-column" key={round.round}> aria-hidden="true"
className="tournament-bracket-lines"
width={connectors.width}
height={connectors.height}
>
{connectors.paths.map((d, index) => (
<path d={d} key={index} />
))}
</svg>
{side.columns.map((column, columnIndex) => (
<div className="tournament-round-column" key={column.id}>
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted"> <p className="text-xs font-semibold uppercase tracking-wide text-text-muted">
{roundLabel(side.raw, round.round, roundIndex, rounds.length)} {column.label}
</p> </p>
<div className="flex flex-1 flex-col justify-around gap-3"> <div className="flex flex-1 flex-col justify-around gap-3">
{round.matches.map((match) => ( {column.matches.map((match) => (
<div <div
className={[ className="tournament-match-node"
'tournament-match-node',
roundIndex > 0 ? 'tournament-match-node-left' : '',
roundIndex < rounds.length - 1 ? 'tournament-match-node-right' : '',
].filter(Boolean).join(' ')}
key={`${match.type_bracket}-${match.match_id}`} 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)
}}
> >
<TournamentMatchCard <TournamentMatchCard match={match} navigate={navigate} />
match={match}
navigate={navigate}
/>
</div> </div>
))} ))}
</div> </div>
@@ -4013,20 +3993,23 @@ function TournamentStandings({ standings }) {
function TournamentMatchList({ sides, navigate }) { function TournamentMatchList({ sides, navigate }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{sides.map((side) => ( {sides.map((side) => {
<div key={side.raw || 'matches'}> const matches = side.columns.flatMap((column) => column.matches)
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3> return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div key={side.key}>
{side.matches.map((match) => ( <h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
<TournamentMatchCard <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
key={`${match.type_bracket}-${match.match_id}`} {matches.map((match) => (
match={match} <TournamentMatchCard
navigate={navigate} key={`${match.type_bracket}-${match.match_id}`}
/> match={match}
))} navigate={navigate}
/>
))}
</div>
</div> </div>
</div> )
))} })}
</div> </div>
) )
} }
@@ -4053,10 +4036,11 @@ function TournamentDetailPage({ tournamentId, navigate }) {
const data = state.data const data = state.data
const matches = useMemo(() => data?.matches || [], [data]) const matches = useMemo(() => data?.matches || [], [data])
const format = useMemo(() => tournamentFormatMeta(data?.format, matches), [data?.format, matches]) const format = useMemo(() => tournamentFormatMeta(data?.format, matches), [data?.format, matches])
const sides = useMemo(() => groupMatchesBySide(matches), [matches]) const { sides } = useMemo(() => buildBracket(matches), [matches])
const elimSides = sides.filter((side) => !side.isGroup) const bracketSides = sides.filter((side) => side.kind === 'bracket')
const groupSides = sides.filter((side) => side.isGroup) const listSides = sides.filter((side) => side.kind === 'list')
const standings = data?.standings || [] const standings = data?.standings || []
const hasStandings = standings.length > 0
return ( return (
<section className="space-y-6 pt-24 sm:pt-28"> <section className="space-y-6 pt-24 sm:pt-28">
@@ -4101,44 +4085,28 @@ function TournamentDetailPage({ tournamentId, navigate }) {
</p> </p>
) : null} ) : null}
{format.mode === 'standings' && matches.length ? ( {matches.length ? (
<>
<TournamentStandings standings={standings} />
<TournamentMatchList sides={sides} navigate={navigate} />
</>
) : null}
{format.mode === 'bracket' && matches.length ? (
<div className="space-y-6">
{elimSides.map((side) => (
<TournamentBracketSide key={side.raw || 'bracket'} side={side} navigate={navigate} />
))}
</div>
) : null}
{format.mode === 'mixed' && matches.length ? (
<div className="space-y-8"> <div className="space-y-8">
{groupSides.length ? ( {hasStandings || listSides.length ? (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold">Group stage</h2> {bracketSides.length ? <h2 className="text-lg font-semibold">Group stage</h2> : null}
<TournamentStandings standings={standings} /> {hasStandings ? <TournamentStandings standings={standings} /> : null}
<TournamentMatchList sides={groupSides} navigate={navigate} /> {listSides.length ? <TournamentMatchList sides={listSides} navigate={navigate} /> : null}
</div> </div>
) : null} ) : null}
{elimSides.length ? (
<div className="space-y-4"> {bracketSides.length ? (
<h2 className="text-lg font-semibold">Playoffs</h2> <div className="space-y-6">
{elimSides.map((side) => ( {hasStandings || listSides.length ? (
<TournamentBracketSide key={side.raw || 'bracket'} side={side} navigate={navigate} /> <h2 className="text-lg font-semibold">Playoffs</h2>
) : null}
{bracketSides.map((side) => (
<TournamentBracketSide key={side.key} side={side} navigate={navigate} />
))} ))}
</div> </div>
) : null} ) : null}
</div> </div>
) : null} ) : null}
{format.mode === 'matches' && matches.length ? (
<TournamentMatchList sides={sides} navigate={navigate} />
) : null}
</section> </section>
) )
} }
+145
View File
@@ -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)
}
+20 -21
View File
@@ -832,13 +832,33 @@ h3 {
} }
.tournament-bracket-grid { .tournament-bracket-grid {
position: relative;
display: flex; display: flex;
gap: 2.6rem; gap: 2.6rem;
min-width: max-content; 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 { .tournament-round-column {
position: relative; position: relative;
z-index: 1;
display: flex; display: flex;
width: 190px; width: 190px;
min-width: 190px; min-width: 190px;
@@ -851,27 +871,6 @@ h3 {
z-index: 1; 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 { @keyframes scrollPulse {
0% { 0% {
transform: translateY(-100%); transform: translateY(-100%);