diff --git a/frontend/src/bracket.js b/frontend/src/bracket.js index a0b815a..351b443 100644 --- a/frontend/src/bracket.js +++ b/frontend/src/bracket.js @@ -167,46 +167,94 @@ export function layoutKey(columnIndex, match) { } // 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`. +// that feed into it — the classic bracket look. Pure: takes measured card heights, +// returns absolute `top` offsets keyed by `layoutKey`. +// +// Column 0 stacks top-to-bottom. Later columns centre each match on the mean of +// its feeders' centres. Matches whose feeders were all hidden byes (no anchor) +// are *extrapolated* from the nearest anchored sibling using the column's slot +// pitch — including above y=0 — rather than stacked at the top, which would shove +// the anchored matches away from their feeders and make the connectors fan and +// cross. A final per-side shift pulls any negative offsets back on-screen. 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 + const matches = column.matches + const hs = matches.map((m) => heightOf(c, m)) + const pitch = (hs.reduce((a, b) => a + b, 0) / Math.max(1, hs.length)) + gap + + // Anchor = mean of this match's feeders' centres (null when all were byes). + const childCenters = new Map() 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) + if (!childCenters.has(parent.match_id)) childCenters.set(parent.match_id, []) + childCenters.get(parent.match_id).push(center) + } + } + const center = matches.map((m) => { + const kids = childCenters.get(m.match_id) + return kids && kids.length ? kids.reduce((a, b) => a + b, 0) / kids.length : null + }) + + const firstAnchor = center.findIndex((v) => v != null) + if (firstAnchor === -1) { + // No anchors (column 0, or a fully-bye column): plain stack. + let cursor = 0 + matches.forEach((m, i) => { + center[i] = cursor + hs[i] / 2 + cursor += hs[i] + gap + }) + } else { + // Leading byes: extrapolate upward (may go negative — fixed by the shift). + for (let i = firstAnchor - 1; i >= 0; i -= 1) center[i] = center[i + 1] - pitch + // Trailing / interior byes: extend down / interpolate between anchors. + let lastAnchor = firstAnchor + for (let i = firstAnchor + 1; i < matches.length; i += 1) { + if (center[i] != null) { lastAnchor = i; continue } + let next = i + while (next < matches.length && center[next] == null) next += 1 + if (next >= matches.length) { + center[i] = center[lastAnchor] + (i - lastAnchor) * pitch + } else { + const span = next - lastAnchor + center[i] = center[lastAnchor] + ((center[next] - center[lastAnchor]) * (i - lastAnchor)) / span + } + } + // Resolve any residual overlap by nudging downward (rare; honours heights). + for (let i = 1; i < matches.length; i += 1) { + const minC = center[i - 1] + (hs[i - 1] + hs[i]) / 2 + gap + if (center[i] < minC) center[i] = minC } } - 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) + matches.forEach((m, i) => centers[c].set(m.match_id, center[i])) }) - return { tops, centers, height: Math.max(0, ...columnHeights) } + // Shift the whole side so the highest match sits at y=0, then size to content. + let minTop = Infinity + columns.forEach((column, c) => { + column.matches.forEach((m) => { + minTop = Math.min(minTop, centers[c].get(m.match_id) - heightOf(c, m) / 2) + }) + }) + const shift = Number.isFinite(minTop) && minTop < 0 ? -minTop : 0 + + const tops = new Map() + let height = 0 + columns.forEach((column, c) => { + column.matches.forEach((m) => { + const h = heightOf(c, m) + const top = centers[c].get(m.match_id) - h / 2 + shift + tops.set(layoutKey(c, m), top) + height = Math.max(height, top + h) + }) + }) + + return { tops, centers, height } }