meow
This commit is contained in:
+59
-63
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import Tree, { prewarmTreeCanvas } from '../Tree/Tree'
|
||||
import FallingLeaves from '../Tree/FallingLeaves'
|
||||
import ReplayCanvasPanel from './ReplayCanvas'
|
||||
import { buildBracket, parentPosition } from './bracket'
|
||||
import { buildBracket, computeBracketLayout, feederParent, layoutKey } from './bracket'
|
||||
|
||||
const numberFormat = new Intl.NumberFormat('en-GB')
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
@@ -3840,66 +3840,59 @@ 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 }) {
|
||||
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 nodeKey = (columnIndex, match) => `${columnIndex}:${match.match_id}`
|
||||
|
||||
// Pass 1: measure card heights, then position every match centred on its feeders.
|
||||
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 })
|
||||
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 })
|
||||
}
|
||||
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
relayout()
|
||||
const observer = new ResizeObserver(relayout)
|
||||
observer.observe(grid)
|
||||
return () => observer.disconnect()
|
||||
}, [side])
|
||||
|
||||
// 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 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(layoutKey(c, match))
|
||||
const to = boxOf(layoutKey(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 })
|
||||
}, [side, layout])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
|
||||
@@ -3917,23 +3910,26 @@ function TournamentBracketSide({ side, navigate }) {
|
||||
</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="mb-3 text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
{column.label}
|
||||
</p>
|
||||
<div className="flex flex-1 flex-col justify-around gap-3">
|
||||
{column.matches.map((match) => (
|
||||
<div
|
||||
className="tournament-match-node"
|
||||
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 match={match} navigate={navigate} />
|
||||
</div>
|
||||
))}
|
||||
<div className="tournament-round-track" style={{ height: layout.height || undefined }}>
|
||||
{column.matches.map((match) => {
|
||||
const key = layoutKey(columnIndex, match)
|
||||
return (
|
||||
<div
|
||||
className="tournament-match-node"
|
||||
key={`${match.type_bracket}-${match.match_id}`}
|
||||
ref={(el) => {
|
||||
if (el) nodeRefs.current.set(key, el)
|
||||
else nodeRefs.current.delete(key)
|
||||
}}
|
||||
style={{ top: layout.tops.get(key) ?? 0 }}
|
||||
>
|
||||
<TournamentMatchCard match={match} navigate={navigate} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -143,3 +143,70 @@ export function parentPosition(childPosition, currentCount, nextCount) {
|
||||
if (nextCount >= currentCount) return childPosition
|
||||
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
@@ -863,12 +863,21 @@ h3 {
|
||||
width: 190px;
|
||||
min-width: 190px;
|
||||
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 {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@keyframes scrollPulse {
|
||||
|
||||
Reference in New Issue
Block a user