Merge remote-tracking branch 'origin/main'
This commit is contained in:
+200
-41
@@ -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'
|
||||||
@@ -3771,9 +3771,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>
|
||||||
@@ -3794,37 +3799,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">
|
||||||
@@ -3838,7 +3880,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}
|
||||||
@@ -3847,6 +3893,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>
|
||||||
@@ -3858,12 +3905,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(() => {
|
||||||
@@ -3882,6 +3997,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
|
||||||
@@ -3896,7 +4014,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)
|
||||||
@@ -3904,26 +4023,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) => (
|
||||||
@@ -3934,9 +4081,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)
|
||||||
@@ -3944,7 +4092,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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -3952,7 +4100,7 @@ function TournamentBracketSide({ side, navigate }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</BracketViewport>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4055,6 +4203,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">
|
||||||
@@ -4115,7 +4268,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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user