From 9465ee05d150c78421dae4bfb6419b1a67262254 Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Sun, 21 Jun 2026 01:06:40 -0700 Subject: [PATCH] meow --- frontend/src/App.jsx | 122 +++++++++++++++++++--------------------- frontend/src/bracket.js | 67 ++++++++++++++++++++++ frontend/src/styles.css | 13 ++++- 3 files changed, 137 insertions(+), 65 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 454783a..d0403f7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (

{side.label}

@@ -3917,23 +3910,26 @@ function TournamentBracketSide({ side, navigate }) { {side.columns.map((column, columnIndex) => (
-

+

{column.label}

-
- {column.matches.map((match) => ( -
{ - const key = nodeKey(columnIndex, match) - if (el) nodeRefs.current.set(key, el) - else nodeRefs.current.delete(key) - }} - > - -
- ))} +
+ {column.matches.map((match) => { + const key = layoutKey(columnIndex, match) + return ( +
{ + if (el) nodeRefs.current.set(key, el) + else nodeRefs.current.delete(key) + }} + style={{ top: layout.tops.get(key) ?? 0 }} + > + +
+ ) + })}
))} diff --git a/frontend/src/bracket.js b/frontend/src/bracket.js index 2c90396..a0b815a 100644 --- a/frontend/src/bracket.js +++ b/frontend/src/bracket.js @@ -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) } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bd16033..24d7c35 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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 {