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 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,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 }) {
|
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 measure = () => {
|
const heights = new Map()
|
||||||
const gridBox = grid.getBoundingClientRect()
|
for (const [key, el] of nodeRefs.current) heights.set(key, el.offsetHeight)
|
||||||
const boxOf = (key) => {
|
const next = computeBracketLayout(side.columns, heights)
|
||||||
const el = nodeRefs.current.get(key)
|
setLayout({ tops: next.tops, height: next.height })
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
relayout()
|
||||||
measure()
|
const observer = new ResizeObserver(relayout)
|
||||||
const observer = new ResizeObserver(measure)
|
|
||||||
observer.observe(grid)
|
observer.observe(grid)
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [side])
|
}, [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 (
|
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-cyan">{side.label}</h3>
|
||||||
@@ -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) => {
|
||||||
<div
|
const key = layoutKey(columnIndex, match)
|
||||||
className="tournament-match-node"
|
return (
|
||||||
key={`${match.type_bracket}-${match.match_id}`}
|
<div
|
||||||
ref={(el) => {
|
className="tournament-match-node"
|
||||||
const key = nodeKey(columnIndex, match)
|
key={`${match.type_bracket}-${match.match_id}`}
|
||||||
if (el) nodeRefs.current.set(key, el)
|
ref={(el) => {
|
||||||
else nodeRefs.current.delete(key)
|
if (el) nodeRefs.current.set(key, el)
|
||||||
}}
|
else nodeRefs.current.delete(key)
|
||||||
>
|
}}
|
||||||
<TournamentMatchCard match={match} navigate={navigate} />
|
style={{ top: layout.tops.get(key) ?? 0 }}
|
||||||
</div>
|
>
|
||||||
))}
|
<TournamentMatchCard match={match} navigate={navigate} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user