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 Tree, { prewarmTreeCanvas } from '../Tree/Tree'
import FallingLeaves from '../Tree/FallingLeaves' import FallingLeaves from '../Tree/FallingLeaves'
import ReplayCanvasPanel from './ReplayCanvas' import ReplayCanvasPanel from './ReplayCanvas'
@@ -3753,9 +3753,14 @@ function TournamentsPage({ navigate }) {
type="button" type="button"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-lg font-semibold"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{tournament.name || `Tournament ${tournament.tournament_id}`} <p className="truncate text-lg font-semibold">
</p> {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"> <p className="text-xs text-text-soft">
TID {tournament.tournament_id} · {tournamentDateRange(tournament.date_start, tournament.date_end)} TID {tournament.tournament_id} · {tournamentDateRange(tournament.date_start, tournament.date_end)}
</p> </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 winner = displayTeamName(match.winner_name).toLowerCase()
const teamA = displayTeamName(match.team_a_name) const teamA = displayTeamName(match.team_a_name)
const teamB = displayTeamName(match.team_b_name) const teamB = displayTeamName(match.team_b_name)
const aWon = winner && teamA && winner === teamA.toLowerCase() const aWon = winner && teamA && winner === teamA.toLowerCase()
const bWon = winner && teamB && winner === teamB.toLowerCase() const bWon = winner && teamB && winner === teamB.toLowerCase()
const decided = Boolean(winner)
const battles = Array.isArray(match.battles) ? match.battles : [] const battles = Array.isArray(match.battles) ? match.battles : []
const fire = onHover || (() => {})
const lastKey = useRef('')
const teamRow = (name, score, won, emptyLabel = 'TBD') => ( // Hover a team name → light just that team's run (green). Hover anywhere else on
<div className="flex items-center justify-between gap-2"> // the card → light both teams' runs (winner green, loser red). A registered team
{name ? ( // that forfeited (technical walkover) shows gold rather than red.
<button const matchHighlight = () => {
className={`min-w-0 truncate text-left font-semibold transition hover:underline ${won ? 'text-win' : 'text-text'}`} const map = {}
onClick={(event) => { if (teamA) map[teamA.toLowerCase()] = !decided || aWon ? 'win' : 'loss'
event.stopPropagation() if (teamB) map[teamB.toLowerCase()] = !decided || bWon ? 'win' : 'loss'
navigate(teamPath(name)) return Object.keys(map).length ? map : null
}} }
type="button" const handleOver = (event) => {
> const teamEl = event.target.closest('[data-team]')
{name} const map = teamEl ? { [teamEl.dataset.team.toLowerCase()]: 'win' } : matchHighlight()
</button> const key = map ? Object.entries(map).map(([k, v]) => `${k}:${v}`).sort().join('|') : ''
) : ( if (key === lastKey.current) return
<span className="min-w-0 truncate font-semibold text-text-muted">{emptyLabel}</span> lastKey.current = key
)} fire(map)
<span className={`shrink-0 tabular-nums ${won ? 'text-win' : 'text-text-soft'}`}>{formatNumber(score)}</span> }
</div> 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' const emptyLabel = match.status === 'bye' ? 'BYE' : 'TBD'
return ( 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)} {teamRow(teamA, match.score_a, aWon, emptyLabel)}
<div className="mt-1">{teamRow(teamB, match.score_b, bWon, emptyLabel)}</div> <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"> <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 <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" 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} 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" type="button"
> >
G{index + 1} G{index + 1}
@@ -3829,6 +3875,7 @@ function TournamentMatchCard({ match, navigate }) {
<span <span
className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-muted opacity-70" 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} key={battle.session_hex}
title={`Game ${index + 1} — no replay held`}
> >
G{index + 1} G{index + 1}
</span> </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 gridRef = useRef(null)
const nodeRefs = useRef(new Map()) const nodeRefs = useRef(new Map())
// `tops` places each match; once placed we read back the DOM to draw connectors. // `tops` places each match; once placed we read back the DOM to draw connectors.
const [layout, setLayout] = useState({ tops: new Map(), height: 0 }) 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. // Pass 1: measure card heights, then position every match centred on its feeders.
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -3864,6 +3979,9 @@ function TournamentBracketSide({ side, navigate }) {
}, [side]) }, [side])
// Pass 2: with matches positioned, read real edges and draw elbow connectors. // 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(() => { useLayoutEffect(() => {
const grid = gridRef.current const grid = gridRef.current
if (!grid) return if (!grid) return
@@ -3878,7 +3996,8 @@ function TournamentBracketSide({ side, navigate }) {
mid: b.top - gridBox.top + b.height / 2, 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 (let c = 0; c < side.columns.length - 1; c += 1) {
for (const match of side.columns[c].matches) { for (const match of side.columns[c].matches) {
const parent = feederParent(match, c, side.columns) const parent = feederParent(match, c, side.columns)
@@ -3886,26 +4005,54 @@ function TournamentBracketSide({ side, navigate }) {
const from = boxOf(layoutKey(c, match)) const from = boxOf(layoutKey(c, match))
const to = boxOf(layoutKey(c + 1, parent)) const to = boxOf(layoutKey(c + 1, parent))
if (!from || !to) continue if (!from || !to) continue
fed.add(parent.match_id)
const midX = (from.right + to.left) / 2 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]) }, [side, layout])
const active = Boolean(highlight)
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-violet">{side.label}</h3>
<div className="overflow-x-auto pb-2"> <BracketViewport>
<div className="tournament-bracket-grid" ref={gridRef}> <div className={`tournament-bracket-grid${active ? ' has-trace' : ''}`} ref={gridRef}>
<svg <svg
aria-hidden="true" aria-hidden="true"
className="tournament-bracket-lines" className="tournament-bracket-lines"
width={connectors.width}
height={connectors.height} height={connectors.height}
width={connectors.width}
> >
{connectors.paths.map((d, index) => ( {connectors.lines.map((line, index) => {
<path d={d} key={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> </svg>
{side.columns.map((column, columnIndex) => ( {side.columns.map((column, columnIndex) => (
@@ -3916,9 +4063,10 @@ function TournamentBracketSide({ side, navigate }) {
<div className="tournament-round-track" style={{ height: layout.height || undefined }}> <div className="tournament-round-track" style={{ height: layout.height || undefined }}>
{column.matches.map((match) => { {column.matches.map((match) => {
const key = layoutKey(columnIndex, match) const key = layoutKey(columnIndex, match)
const traced = traceClass(teamsOf(match), highlight)
return ( return (
<div <div
className="tournament-match-node" className={`tournament-match-node${traced}`}
key={`${match.type_bracket}-${match.match_id}`} key={`${match.type_bracket}-${match.match_id}`}
ref={(el) => { ref={(el) => {
if (el) nodeRefs.current.set(key, el) if (el) nodeRefs.current.set(key, el)
@@ -3926,7 +4074,7 @@ function TournamentBracketSide({ side, navigate }) {
}} }}
style={{ top: layout.tops.get(key) ?? 0 }} style={{ top: layout.tops.get(key) ?? 0 }}
> >
<TournamentMatchCard match={match} navigate={navigate} /> <TournamentMatchCard match={match} navigate={navigate} onHover={onHover} />
</div> </div>
) )
})} })}
@@ -3934,7 +4082,7 @@ function TournamentBracketSide({ side, navigate }) {
</div> </div>
))} ))}
</div> </div>
</div> </BracketViewport>
</div> </div>
) )
} }
@@ -4037,6 +4185,11 @@ function TournamentDetailPage({ tournamentId, navigate }) {
const listSides = sides.filter((side) => side.kind === 'list') const listSides = sides.filter((side) => side.kind === 'list')
const standings = data?.standings || [] const standings = data?.standings || []
const hasStandings = standings.length > 0 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 ( return (
<section className="space-y-6 pt-24 sm:pt-28"> <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> <h2 className="text-lg font-semibold">Playoffs</h2>
) : null} ) : null}
{bracketSides.map((side) => ( {bracketSides.map((side) => (
<TournamentBracketSide key={side.key} side={side} navigate={navigate} /> <TournamentBracketSide
highlight={highlight}
key={side.key}
navigate={navigate}
onHover={onHover}
side={side}
/>
))} ))}
</div> </div>
) : null} ) : null}
+136 -3
View File
@@ -831,11 +831,39 @@ h3 {
font-size: 0.62rem; font-size: 0.62rem;
} }
/* The bracket reads as a campaign map you drag around. The viewport breaks the
page's max-width so there's room to manoeuvre; the surface carries a faint
tactical grid that pans with the bracket (it lives on the scrolling content). */
.bracket-viewport {
position: relative;
width: 100vw;
margin-left: calc(50% - 50vw);
max-height: 78vh;
overflow: auto;
overscroll-behavior: contain;
cursor: grab;
border-block: 1px solid var(--color-border);
background-color: color-mix(in srgb, var(--color-bg) 86%, #000);
}
.bracket-viewport.is-grabbing {
cursor: grabbing;
}
.tournament-bracket-grid { .tournament-bracket-grid {
position: relative; position: relative;
display: flex; display: flex;
gap: 2.6rem; gap: 2.6rem;
min-width: max-content; min-width: max-content;
padding: 3rem clamp(1.5rem, 5vw, 5rem);
--grid-line: color-mix(in srgb, var(--color-border) 38%, transparent);
--grid-dot: color-mix(in srgb, var(--color-fury-violet) 16%, transparent);
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px),
radial-gradient(var(--grid-dot) 1.5px, transparent 1.6px);
background-size: 116px 116px, 116px 116px, 116px 116px;
background-position: 0 0, 0 0, 58px 58px;
} }
/* Connector layer: absolutely sized to the full bracket, drawn behind the cards. /* Connector layer: absolutely sized to the full bracket, drawn behind the cards.
@@ -850,18 +878,55 @@ h3 {
pointer-events: none; pointer-events: none;
} }
/* Each connector is two stacked strokes: a steady base rail plus a brighter
dashed tracer that flows toward the winner (left → right, the path's own
direction). */
.tournament-bracket-lines path { .tournament-bracket-lines path {
fill: none; fill: none;
stroke: color-mix(in srgb, var(--color-border) 60%, var(--color-fury-violet)); stroke-linecap: round;
}
.bracket-line-base {
stroke: color-mix(in srgb, var(--color-border) 55%, var(--color-fury-violet));
stroke-width: 1.5px; stroke-width: 1.5px;
} }
.bracket-line-flow {
stroke: color-mix(in srgb, var(--color-fury-violet) 92%, white);
stroke-width: 1.5px;
stroke-dasharray: 7 17;
opacity: 0.7;
animation: bracketFlow 1.4s linear infinite;
}
@keyframes bracketFlow {
to {
stroke-dashoffset: -24;
}
}
/* Bye stub: a short rail + tag on the incoming side of a match whose feeders
were all byes, so a top-seed match doesn't read as "appearing from nowhere". */
.bracket-bye-stub {
stroke: color-mix(in srgb, var(--color-text-muted) 60%, transparent);
stroke-width: 1.5px;
stroke-dasharray: 3 4;
}
.bracket-bye-tag {
fill: var(--color-text-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.tournament-round-column { .tournament-round-column {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
width: 190px; width: 200px;
min-width: 190px; min-width: 200px;
flex-direction: column; flex-direction: column;
} }
@@ -878,6 +943,74 @@ h3 {
z-index: 1; z-index: 1;
left: 0; left: 0;
right: 0; right: 0;
transition: opacity 0.18s ease;
}
/* Hover a team → its whole run lights up; everything else recedes. */
.tournament-bracket-grid.has-trace .tournament-match-node:not(.is-traced):not(.is-traced-loss) {
opacity: 0.32;
}
.tournament-match-node.is-traced > * {
position: relative;
box-shadow: 0 0 0 1.5px var(--color-win), 0 0 18px -2px color-mix(in srgb, var(--color-win) 70%, transparent);
animation: bracketBreathe 1.6s ease-in-out infinite;
}
@keyframes bracketBreathe {
0%, 100% {
box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--color-win) 70%, transparent), 0 0 14px -4px color-mix(in srgb, var(--color-win) 55%, transparent);
}
50% {
box-shadow: 0 0 0 2px var(--color-win), 0 0 26px 0 color-mix(in srgb, var(--color-win) 75%, transparent);
}
}
.bracket-line-base.is-traced {
stroke: var(--color-win);
stroke-width: 2px;
}
.bracket-line-flow.is-traced {
stroke: color-mix(in srgb, var(--color-win) 75%, white);
opacity: 0.95;
}
/* Loser run: same treatment in the loss colour. */
.tournament-match-node.is-traced-loss > * {
position: relative;
animation: bracketBreatheLoss 1.6s ease-in-out infinite;
}
@keyframes bracketBreatheLoss {
0%, 100% {
box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--color-loss) 70%, transparent), 0 0 14px -4px color-mix(in srgb, var(--color-loss) 55%, transparent);
}
50% {
box-shadow: 0 0 0 2px var(--color-loss), 0 0 26px 0 color-mix(in srgb, var(--color-loss) 75%, transparent);
}
}
.bracket-line-base.is-traced-loss {
stroke: var(--color-loss);
stroke-width: 2px;
}
.bracket-line-flow.is-traced-loss {
stroke: color-mix(in srgb, var(--color-loss) 75%, white);
opacity: 0.95;
}
.tournament-bracket-grid.has-trace .tournament-bracket-lines path:not(.is-traced):not(.is-traced-loss) {
opacity: 0.18;
}
@media (prefers-reduced-motion: reduce) {
.bracket-line-flow,
.tournament-match-node.is-traced > *,
.tournament-match-node.is-traced-loss > * {
animation: none;
}
} }
@keyframes scrollPulse { @keyframes scrollPulse {