This commit is contained in:
FURRO404
2026-06-21 02:21:29 -07:00
parent b3bcaef57a
commit 80442f7816
2 changed files with 336 additions and 44 deletions
+200 -41
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import Tree, { prewarmTreeCanvas } from '../Tree/Tree'
import FallingLeaves from '../Tree/FallingLeaves'
import ReplayCanvasPanel from './ReplayCanvas'
@@ -3753,9 +3753,14 @@ function TournamentsPage({ navigate }) {
type="button"
>
<div className="min-w-0">
<p className="truncate text-lg font-semibold">
{tournament.name || `Tournament ${tournament.tournament_id}`}
</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p className="truncate text-lg font-semibold">
{tournament.name || `Tournament ${tournament.tournament_id}`}
</p>
<span className="shrink-0 rounded bg-surface px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-fury-violet">
{tournamentFormatMeta(tournament.format).label}
</span>
</div>
<p className="text-xs text-text-soft">
TID {tournament.tournament_id} · {tournamentDateRange(tournament.date_start, tournament.date_end)}
</p>
@@ -3776,37 +3781,74 @@ function TournamentsPage({ navigate }) {
)
}
function TournamentMatchCard({ match, navigate }) {
function TournamentMatchCard({ match, navigate, onHover }) {
const winner = displayTeamName(match.winner_name).toLowerCase()
const teamA = displayTeamName(match.team_a_name)
const teamB = displayTeamName(match.team_b_name)
const aWon = winner && teamA && winner === teamA.toLowerCase()
const bWon = winner && teamB && winner === teamB.toLowerCase()
const decided = Boolean(winner)
const battles = Array.isArray(match.battles) ? match.battles : []
const fire = onHover || (() => {})
const lastKey = useRef('')
const teamRow = (name, score, won, emptyLabel = 'TBD') => (
<div className="flex items-center justify-between gap-2">
{name ? (
<button
className={`min-w-0 truncate text-left font-semibold transition hover:underline ${won ? 'text-win' : 'text-text'}`}
onClick={(event) => {
event.stopPropagation()
navigate(teamPath(name))
}}
type="button"
>
{name}
</button>
) : (
<span className="min-w-0 truncate font-semibold text-text-muted">{emptyLabel}</span>
)}
<span className={`shrink-0 tabular-nums ${won ? 'text-win' : 'text-text-soft'}`}>{formatNumber(score)}</span>
</div>
)
// Hover a team name → light just that team's run (green). Hover anywhere else on
// the card → light both teams' runs (winner green, loser red). A registered team
// that forfeited (technical walkover) shows gold rather than red.
const matchHighlight = () => {
const map = {}
if (teamA) map[teamA.toLowerCase()] = !decided || aWon ? 'win' : 'loss'
if (teamB) map[teamB.toLowerCase()] = !decided || bWon ? 'win' : 'loss'
return Object.keys(map).length ? map : null
}
const handleOver = (event) => {
const teamEl = event.target.closest('[data-team]')
const map = teamEl ? { [teamEl.dataset.team.toLowerCase()]: 'win' } : matchHighlight()
const key = map ? Object.entries(map).map(([k, v]) => `${k}:${v}`).sort().join('|') : ''
if (key === lastKey.current) return
lastKey.current = key
fire(map)
}
const handleLeave = () => {
lastKey.current = ''
fire(null)
}
const teamRow = (name, score, won, emptyLabel = 'TBD') => {
const lost = decided && name && !won
const noShow = lost && match.status === 'technical'
const nameColor = won ? 'text-win' : noShow ? 'text-fury-violet' : lost ? 'text-loss' : 'text-text'
const scoreColor = won ? 'text-win' : noShow ? 'text-fury-violet' : lost ? 'text-loss' : 'text-text-soft'
return (
<div className="flex items-center justify-between gap-2">
{name ? (
<button
className={`min-w-0 truncate text-left font-semibold transition hover:underline ${nameColor}`}
data-team={name}
onClick={(event) => {
event.stopPropagation()
if (bracketPan.dragged) return
navigate(teamPath(name))
}}
type="button"
>
{name}
</button>
) : (
<span className="min-w-0 truncate font-semibold text-text-muted">{emptyLabel}</span>
)}
<span className={`shrink-0 tabular-nums ${scoreColor}`}>{formatNumber(score)}</span>
</div>
)
}
const emptyLabel = match.status === 'bye' ? 'BYE' : 'TBD'
return (
<div className="rounded-md border border-border bg-bg p-2.5 text-sm shadow-sm">
<div
className="rounded-md border border-border bg-bg p-2.5 text-sm shadow-sm"
onMouseLeave={handleLeave}
onMouseOver={handleOver}
>
{teamRow(teamA, match.score_a, aWon, emptyLabel)}
<div className="mt-1">{teamRow(teamB, match.score_b, bWon, emptyLabel)}</div>
<div className="mt-2 flex items-center justify-between gap-2 text-[10px] font-semibold uppercase tracking-wide text-text-muted">
@@ -3820,7 +3862,11 @@ function TournamentMatchCard({ match, navigate }) {
<button
className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-soft transition hover:text-text"
key={battle.session_hex}
onClick={() => navigate(gamePath(battle.session_hex))}
onClick={() => {
if (bracketPan.dragged) return
navigate(gamePath(battle.session_hex))
}}
title={`Game ${index + 1} — view replay`}
type="button"
>
G{index + 1}
@@ -3829,6 +3875,7 @@ function TournamentMatchCard({ match, navigate }) {
<span
className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-muted opacity-70"
key={battle.session_hex}
title={`Game ${index + 1} — no replay held`}
>
G{index + 1}
</span>
@@ -3840,12 +3887,80 @@ function TournamentMatchCard({ match, navigate }) {
)
}
function TournamentBracketSide({ side, navigate }) {
// Shared across the bracket: set true while a pan-drag is in progress so the
// team/replay buttons inside the canvas don't fire a navigation on release.
const bracketPan = { dragged: false }
function teamsOf(match) {
return [match?.team_a_name, match?.team_b_name]
.map((n) => displayTeamName(n).toLowerCase())
.filter(Boolean)
}
// Drag-anywhere pan surface. A tap still clicks; only motion past a small
// threshold pans (and suppresses the click). Move/up live on the window so a fast
// drag keeps panning even when the cursor leaves the canvas.
function BracketViewport({ children }) {
const ref = useRef(null)
const onPointerDown = (event) => {
const el = ref.current
if (!el || event.button !== 0) return
const start = { x: event.clientX, y: event.clientY, sl: el.scrollLeft, st: el.scrollTop, moved: false }
bracketPan.dragged = false
const onMove = (e) => {
const dx = e.clientX - start.x
const dy = e.clientY - start.y
if (!start.moved && Math.hypot(dx, dy) > 5) {
start.moved = true
bracketPan.dragged = true
el.classList.add('is-grabbing')
}
if (start.moved) {
el.scrollLeft = start.sl - dx
el.scrollTop = start.st - dy
e.preventDefault()
}
}
const onUp = () => {
el.classList.remove('is-grabbing')
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
// Keep `dragged` true through the click that fires right after pointerup so
// that click is suppressed, then clear it.
window.setTimeout(() => { bracketPan.dragged = false }, 0)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
return (
<div className="bracket-viewport" onPointerDown={onPointerDown} ref={ref}>
{children}
</div>
)
}
// What colour, if any, a team should glow: the highlight map is { teamLower:
// 'win' | 'loss' }. Winner-side runs glow green, loser runs red.
function traceClass(teams, highlight) {
if (!highlight) return ''
let result = ''
for (const team of teams) {
const tone = highlight[team]
if (tone === 'win') return ' is-traced'
if (tone === 'loss') result = ' is-traced-loss'
}
return result
}
function TournamentBracketSide({ side, navigate, highlight, onHover }) {
const gridRef = useRef(null)
const nodeRefs = useRef(new Map())
// `tops` places each match; once placed we read back the DOM to draw connectors.
const [layout, setLayout] = useState({ tops: new Map(), height: 0 })
const [connectors, setConnectors] = useState({ width: 0, height: 0, paths: [] })
const [connectors, setConnectors] = useState({ width: 0, height: 0, lines: [], byes: [] })
// Pass 1: measure card heights, then position every match centred on its feeders.
useLayoutEffect(() => {
@@ -3864,6 +3979,9 @@ function TournamentBracketSide({ side, navigate }) {
}, [side])
// Pass 2: with matches positioned, read real edges and draw elbow connectors.
// Each connector carries the teams on either end so a hovered team's whole run
// can be lit. Matches with no incoming connector (all feeders were byes) get a
// bye stub so they don't read as appearing from nowhere.
useLayoutEffect(() => {
const grid = gridRef.current
if (!grid) return
@@ -3878,7 +3996,8 @@ function TournamentBracketSide({ side, navigate }) {
mid: b.top - gridBox.top + b.height / 2,
}
}
const paths = []
const lines = []
const fed = new Set()
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)
@@ -3886,26 +4005,54 @@ function TournamentBracketSide({ side, navigate }) {
const from = boxOf(layoutKey(c, match))
const to = boxOf(layoutKey(c + 1, parent))
if (!from || !to) continue
fed.add(parent.match_id)
const midX = (from.right + to.left) / 2
paths.push(`M ${from.right} ${from.mid} H ${midX} V ${to.mid} H ${to.left}`)
lines.push({
d: `M ${from.right} ${from.mid} H ${midX} V ${to.mid} H ${to.left}`,
teams: teamsOf(match).filter((t) => teamsOf(parent).includes(t)),
})
}
}
setConnectors({ width: grid.scrollWidth, height: grid.scrollHeight, paths })
const byes = []
for (let c = 1; c < side.columns.length; c += 1) {
for (const match of side.columns[c].matches) {
if (fed.has(match.match_id)) continue
const box = boxOf(layoutKey(c, match))
if (box) byes.push({ x: box.left, y: box.mid })
}
}
setConnectors({ width: grid.scrollWidth, height: grid.scrollHeight, lines, byes })
}, [side, layout])
const active = Boolean(highlight)
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" ref={gridRef}>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-violet">{side.label}</h3>
<BracketViewport>
<div className={`tournament-bracket-grid${active ? ' has-trace' : ''}`} ref={gridRef}>
<svg
aria-hidden="true"
className="tournament-bracket-lines"
width={connectors.width}
height={connectors.height}
width={connectors.width}
>
{connectors.paths.map((d, index) => (
<path d={d} key={index} />
{connectors.lines.map((line, index) => {
const lit = traceClass(line.teams, highlight)
return (
<g key={index}>
<path className={`bracket-line-base${lit}`} d={line.d} />
<path className={`bracket-line-flow${lit}`} d={line.d} />
</g>
)
})}
{connectors.byes.map((bye, index) => (
<g key={`bye-${index}`}>
<path className="bracket-bye-stub" d={`M ${bye.x} ${bye.y} h -18`} />
<text className="bracket-bye-tag" textAnchor="end" x={bye.x - 24} y={bye.y + 3}>
bye
</text>
</g>
))}
</svg>
{side.columns.map((column, columnIndex) => (
@@ -3916,9 +4063,10 @@ function TournamentBracketSide({ side, navigate }) {
<div className="tournament-round-track" style={{ height: layout.height || undefined }}>
{column.matches.map((match) => {
const key = layoutKey(columnIndex, match)
const traced = traceClass(teamsOf(match), highlight)
return (
<div
className="tournament-match-node"
className={`tournament-match-node${traced}`}
key={`${match.type_bracket}-${match.match_id}`}
ref={(el) => {
if (el) nodeRefs.current.set(key, el)
@@ -3926,7 +4074,7 @@ function TournamentBracketSide({ side, navigate }) {
}}
style={{ top: layout.tops.get(key) ?? 0 }}
>
<TournamentMatchCard match={match} navigate={navigate} />
<TournamentMatchCard match={match} navigate={navigate} onHover={onHover} />
</div>
)
})}
@@ -3934,7 +4082,7 @@ function TournamentBracketSide({ side, navigate }) {
</div>
))}
</div>
</div>
</BracketViewport>
</div>
)
}
@@ -4037,6 +4185,11 @@ function TournamentDetailPage({ tournamentId, navigate }) {
const listSides = sides.filter((side) => side.kind === 'list')
const standings = data?.standings || []
const hasStandings = standings.length > 0
// Lit-up teams shared across both brackets, so a run lights in the winner
// bracket and wherever it dropped into the loser bracket at once. Shape:
// { teamLower: 'win' | 'loss' } or null.
const [highlight, setHighlight] = useState(null)
const onHover = useCallback((map) => setHighlight(map), [])
return (
<section className="space-y-6 pt-24 sm:pt-28">
@@ -4097,7 +4250,13 @@ function TournamentDetailPage({ tournamentId, navigate }) {
<h2 className="text-lg font-semibold">Playoffs</h2>
) : null}
{bracketSides.map((side) => (
<TournamentBracketSide key={side.key} side={side} navigate={navigate} />
<TournamentBracketSide
highlight={highlight}
key={side.key}
navigate={navigate}
onHover={onHover}
side={side}
/>
))}
</div>
) : null}