This commit is contained in:
FURRO404
2026-06-21 01:06:40 -07:00
parent 3a7b72b01b
commit 9465ee05d1
3 changed files with 137 additions and 65 deletions
+30 -34
View File
@@ -2,7 +2,7 @@ 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' import { buildBracket, computeBracketLayout, feederParent, layoutKey } 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', {
@@ -3840,34 +3840,33 @@ 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 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.
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, paths: [] })
const nodeKey = (columnIndex, match) => `${columnIndex}:${match.match_id}` // Pass 1: measure card heights, then position every match centred on its feeders.
useLayoutEffect(() => { useLayoutEffect(() => {
const grid = gridRef.current const grid = gridRef.current
if (!grid) return undefined if (!grid) return undefined
const relayout = () => {
const heights = new Map()
for (const [key, el] of nodeRefs.current) heights.set(key, el.offsetHeight)
const next = computeBracketLayout(side.columns, heights)
setLayout({ tops: next.tops, height: next.height })
}
relayout()
const observer = new ResizeObserver(relayout)
observer.observe(grid)
return () => observer.disconnect()
}, [side])
const measure = () => { // Pass 2: with matches positioned, read real edges and draw elbow connectors.
useLayoutEffect(() => {
const grid = gridRef.current
if (!grid) return
const gridBox = grid.getBoundingClientRect() const gridBox = grid.getBoundingClientRect()
const boxOf = (key) => { const boxOf = (key) => {
const el = nodeRefs.current.get(key) const el = nodeRefs.current.get(key)
@@ -3884,21 +3883,15 @@ function TournamentBracketSide({ side, navigate }) {
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)
if (!parent) continue if (!parent) continue
const from = boxOf(nodeKey(c, match)) const from = boxOf(layoutKey(c, match))
const to = boxOf(nodeKey(c + 1, parent)) const to = boxOf(layoutKey(c + 1, parent))
if (!from || !to) continue if (!from || !to) continue
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}`) paths.push(`M ${from.right} ${from.mid} H ${midX} V ${to.mid} H ${to.left}`)
} }
} }
setConnectors({ width: grid.scrollWidth, height: grid.scrollHeight, paths }) setConnectors({ width: grid.scrollWidth, height: grid.scrollHeight, paths })
} }, [side, layout])
measure()
const observer = new ResizeObserver(measure)
observer.observe(grid)
return () => observer.disconnect()
}, [side])
return ( return (
<div> <div>
@@ -3917,23 +3910,26 @@ function TournamentBracketSide({ side, navigate }) {
</svg> </svg>
{side.columns.map((column, columnIndex) => ( {side.columns.map((column, columnIndex) => (
<div className="tournament-round-column" key={column.id}> <div className="tournament-round-column" key={column.id}>
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted"> <p className="mb-3 text-xs font-semibold uppercase tracking-wide text-text-muted">
{column.label} {column.label}
</p> </p>
<div className="flex flex-1 flex-col justify-around gap-3"> <div className="tournament-round-track" style={{ height: layout.height || undefined }}>
{column.matches.map((match) => ( {column.matches.map((match) => {
const key = layoutKey(columnIndex, match)
return (
<div <div
className="tournament-match-node" className="tournament-match-node"
key={`${match.type_bracket}-${match.match_id}`} key={`${match.type_bracket}-${match.match_id}`}
ref={(el) => { ref={(el) => {
const key = nodeKey(columnIndex, match)
if (el) nodeRefs.current.set(key, el) if (el) nodeRefs.current.set(key, el)
else nodeRefs.current.delete(key) else nodeRefs.current.delete(key)
}} }}
style={{ top: layout.tops.get(key) ?? 0 }}
> >
<TournamentMatchCard match={match} navigate={navigate} /> <TournamentMatchCard match={match} navigate={navigate} />
</div> </div>
))} )
})}
</div> </div>
</div> </div>
))} ))}
+67
View File
@@ -143,3 +143,70 @@ export function parentPosition(childPosition, currentCount, nextCount) {
if (nextCount >= currentCount) return childPosition if (nextCount >= currentCount) return childPosition
return Math.floor(childPosition / 2) return Math.floor(childPosition / 2)
} }
// The match in `columns[columnIndex + 1]` that a given match feeds into. Found by
// slot position (faithful to the real tree even with byes hidden); falls back to
// a proportional index if the exact parent slot was itself a hidden bye.
export 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
}
export function layoutKey(columnIndex, match) {
return `${columnIndex}:${match.match_id}`
}
// Position every match vertically so each one sits centred between the matches
// that feed into it — the classic bracket look. Column 0 is stacked top-to-bottom
// by card height; later columns take the mean of their feeders' centres (and are
// nudged down only as far as needed to avoid overlap). Pure: takes measured card
// heights, returns absolute `top` offsets keyed by `layoutKey`.
export function computeBracketLayout(columns, heights, gap = 16) {
const heightOf = (c, m) => heights.get(layoutKey(c, m)) || 92
const centers = columns.map(() => new Map())
const tops = new Map()
const columnHeights = []
columns.forEach((column, c) => {
let childrenCenters = null
if (c > 0) {
childrenCenters = new Map()
for (const child of columns[c - 1].matches) {
const parent = feederParent(child, c - 1, columns)
if (!parent) continue
const center = centers[c - 1].get(child.match_id)
if (center == null) continue
if (!childrenCenters.has(parent.match_id)) childrenCenters.set(parent.match_id, [])
childrenCenters.get(parent.match_id).push(center)
}
}
let cursor = 0
column.matches.forEach((match) => {
const h = heightOf(c, match)
const kids = childrenCenters && childrenCenters.get(match.match_id)
let center = kids && kids.length
? kids.reduce((a, b) => a + b, 0) / kids.length
: cursor + h / 2
let top = center - h / 2
if (top < cursor) top = cursor // never overlap the node above
center = top + h / 2
tops.set(layoutKey(c, match), top)
centers[c].set(match.match_id, center)
cursor = top + h + gap
})
columnHeights.push(cursor - gap)
})
return { tops, centers, height: Math.max(0, ...columnHeights) }
}
+11 -2
View File
@@ -863,12 +863,21 @@ h3 {
width: 190px; width: 190px;
min-width: 190px; min-width: 190px;
flex-direction: column; flex-direction: column;
gap: 0.75rem; }
/* Matches are positioned absolutely (computeBracketLayout in App.jsx) so each one
sits centred between the matches that feed it. Height is supplied inline from
the computed layout; before that first measurement it falls back to a min. */
.tournament-round-track {
position: relative;
min-height: 120px;
} }
.tournament-match-node { .tournament-match-node {
position: relative; position: absolute;
z-index: 1; z-index: 1;
left: 0;
right: 0;
} }
@keyframes scrollPulse { @keyframes scrollPulse {