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 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 (
<div>
<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="tournament-bracket-grid">
{rounds.map((round, roundIndex) => (
<div className="tournament-round-column" key={round.round}>
<div className="tournament-bracket-grid" ref={gridRef}>
<svg
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">
{roundLabel(side.raw, round.round, roundIndex, rounds.length)}
{column.label}
</p>
<div className="flex flex-1 flex-col justify-around gap-3">
{round.matches.map((match) => (
{column.matches.map((match) => (
<div
className={[
'tournament-match-node',
roundIndex > 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)
}}
>
<TournamentMatchCard
match={match}
navigate={navigate}
/>
<TournamentMatchCard match={match} navigate={navigate} />
</div>
))}
</div>
@@ -4013,20 +3993,23 @@ function TournamentStandings({ standings }) {
function TournamentMatchList({ sides, navigate }) {
return (
<div className="space-y-6">
{sides.map((side) => (
<div key={side.raw || 'matches'}>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{side.matches.map((match) => (
<TournamentMatchCard
key={`${match.type_bracket}-${match.match_id}`}
match={match}
navigate={navigate}
/>
))}
{sides.map((side) => {
const matches = side.columns.flatMap((column) => column.matches)
return (
<div key={side.key}>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{matches.map((match) => (
<TournamentMatchCard
key={`${match.type_bracket}-${match.match_id}`}
match={match}
navigate={navigate}
/>
))}
</div>
</div>
</div>
))}
)
})}
</div>
)
}
@@ -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 (
<section className="space-y-6 pt-24 sm:pt-28">
@@ -4101,44 +4085,28 @@ function TournamentDetailPage({ tournamentId, navigate }) {
</p>
) : null}
{format.mode === 'standings' && 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 ? (
{matches.length ? (
<div className="space-y-8">
{groupSides.length ? (
{hasStandings || listSides.length ? (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Group stage</h2>
<TournamentStandings standings={standings} />
<TournamentMatchList sides={groupSides} navigate={navigate} />
{bracketSides.length ? <h2 className="text-lg font-semibold">Group stage</h2> : null}
{hasStandings ? <TournamentStandings standings={standings} /> : null}
{listSides.length ? <TournamentMatchList sides={listSides} navigate={navigate} /> : null}
</div>
) : null}
{elimSides.length ? (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Playoffs</h2>
{elimSides.map((side) => (
<TournamentBracketSide key={side.raw || 'bracket'} side={side} navigate={navigate} />
{bracketSides.length ? (
<div className="space-y-6">
{hasStandings || listSides.length ? (
<h2 className="text-lg font-semibold">Playoffs</h2>
) : null}
{bracketSides.map((side) => (
<TournamentBracketSide key={side.key} side={side} navigate={navigate} />
))}
</div>
) : null}
</div>
) : null}
{format.mode === 'matches' && matches.length ? (
<TournamentMatchList sides={sides} navigate={navigate} />
) : null}
</section>
)
}