update bracket style
This commit is contained in:
+116
-148
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user