update bracket style
This commit is contained in:
+106
-138
@@ -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,11 +3993,13 @@ 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)
|
||||||
|
return (
|
||||||
|
<div key={side.key}>
|
||||||
<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="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{side.matches.map((match) => (
|
{matches.map((match) => (
|
||||||
<TournamentMatchCard
|
<TournamentMatchCard
|
||||||
key={`${match.type_bracket}-${match.match_id}`}
|
key={`${match.type_bracket}-${match.match_id}`}
|
||||||
match={match}
|
match={match}
|
||||||
@@ -4026,7 +4008,8 @@ function TournamentMatchList({ sides, 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 ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{hasStandings || listSides.length ? (
|
||||||
<h2 className="text-lg font-semibold">Playoffs</h2>
|
<h2 className="text-lg font-semibold">Playoffs</h2>
|
||||||
{elimSides.map((side) => (
|
) : null}
|
||||||
<TournamentBracketSide key={side.raw || 'bracket'} side={side} navigate={navigate} />
|
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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%);
|
||||||
|
|||||||
Reference in New Issue
Block a user