${row(0)}${row(1)}` };
}
// ---------- DOM ----------
/** Create the overlay and bind input once. Returns false when the bracket page is missing. */
function ensureDom() {
if (els) return true;
const viewport = document.getElementById('bracketViewport');
const canvas = document.getElementById('bracketCanvas');
if (!viewport || !canvas) return false;
const overlay = document.createElement('div');
overlay.className = 'bv-overlay';
overlay.innerHTML = '';
viewport.appendChild(overlay);
// groups and cups: the group stage is a page of group cards, not a bracket under the camera
const groups = document.createElement('div');
groups.className = 'bv-groups';
groups.hidden = true;
viewport.appendChild(groups);
els = {
viewport, canvas, overlay, groups,
markers: overlay.querySelector('.bv-markers'),
mag: overlay.querySelector('.bv-mag'),
selbar: overlay.querySelector('.bv-selbar')
};
bindInput();
if (typeof ResizeObserver === 'function') {
let raf = null;
new ResizeObserver(() => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => { raf = null; if (isActive() && (cur || !els.groups.hidden)) render(); });
}).observe(viewport);
}
return true;
}
const vp = () => ({ w: els.viewport.clientWidth, h: els.viewport.clientHeight });
/**
* True when the current tournament is drawn by this view (double or single elimination with a
* progression table for its size).
* @returns {boolean}
*/
function isActive() {
if (typeof tournament === 'undefined' || !tournament || !tournament.bracket) return false;
if (getFormat() === 'GROUPS' || getFormat() === 'SWISS') return true; // any number of players
const table = getFormat() === 'SE' ? SE_MATCH_PROGRESSION : DE_MATCH_PROGRESSION;
return !!table[tournament.bracketSize];
}
/** The layout to use: the format, and where the finals go (one setting for both formats). */
function finalsVariant() {
if (getFormat() === 'GROUPS') return 'cups'; // always the final in the middle: two cups have to fit
if (getFormat() === 'SWISS') return 'swiss'; // the rounds as columns, then the top four
const middle = typeof config !== 'undefined' && config.ui && config.ui.bracketFinals === 'middle';
// Cup and Plate: always back to back (Docs/CUP-AND-PLATE.md)
if (getFormat() === 'SE' && typeof tournament !== 'undefined' && tournament && tournament.plate) return 'plate';
if (getFormat() === 'SE') return middle ? 'se-middle' : 'se';
// with qualifiers, always the finals in the middle: with them on the right the backside sits
// straight beside round 1, where the qualifiers go (Docs/QUALIFIERS.md)
if (typeof tournament !== 'undefined' && tournament && tournament.qualifiers) return 'middle';
return middle ? 'middle' : 'right';
}
/**
* Draw the bracket for the current tournament. Called from renderCleanBracket() for
* double elimination. Keeps the camera, selection and Follow across re-renders of the
* same tournament; fits the whole bracket when the tournament, size, finals position
* or viewport size changes.
* @returns {void}
*/
function render() {
if (!ensureDom()) return;
const groupsFormat = getFormat() === 'GROUPS';
if (groupsFormat && gcShown() === 'groups') { renderGroups(); return; }
els.groups.hidden = true;
els.viewport.classList.remove('bv-groups-on');
const prog = getProgressionTable();
const size = groupsFormat ? ['A', 'B'].map(c => tournament.cups[c] ? tournament.cups[c].size : 0).join('/') : tournament.bracketSize;
const variant = finalsVariant();
const st = groupsFormat ? cupsStructure(prog) : getFormat() === 'SWISS' ? swissStructure(prog) : structure(prog);
let { w: vw, h: vh } = vp();
const hidden = vw < 100 || vh < 100; // page not shown yet; fit again once it is
if (hidden) { vw = 1400; vh = 800; }
const g = geometry(st, variant, vw, vh);
({ PITCH, GX, CENTER_GAP, FINALS_GAP } = g);
const L = layoutFor(st, variant, g);
const pos = L.pos;
const byId = {};
(typeof matches !== 'undefined' && Array.isArray(matches) ? matches : []).forEach(m => { byId[m.id] = m; });
const ids = st.ids.filter(id => byId[id]);
const M = {};
ids.forEach(id => { M[id] = viewOf(byId[id]); });
const host = document.getElementById('bracketMatches');
host.innerHTML = '';
const world = document.createElement('div');
world.className = 'bv-world';
world.style.setProperty('--w', W + 'px');
world.style.setProperty('--h', H + 'px');
world.style.setProperty('--fs', FS + 'px');
host.appendChild(world);
els.viewport.classList.add('bv-active');
els.overlay.hidden = false;
const lbl = (html, x, y, cls = 'bv-col-label') => {
const d = document.createElement('div');
d.className = cls; d.innerHTML = html; d.style.left = x + 'px'; d.style.top = y + 'px';
world.appendChild(d); return d;
};
// a label centred over a column (x is the column's left edge)
const lblMid = (html, x, y, cls) => { const d = lbl(html, x + W / 2, y, cls); d.style.transform = 'translateX(-50%)'; return d; };
const LABEL_GAP = 26; // every header sits the same distance above the topmost match of its round
if (variant === 'swiss') {
// the club name; each round over its column (with its byes, or when it is drawn); the top four
const club = escapeHtml((typeof config !== 'undefined' && config.clubName) || 'NewTon DC');
lbl(club, 0, -94, 'bv-club');
const sw = tournament.swiss, P = W + GX;
const nameOf = id => (players.find(p => String(p.id) === String(id)) || {}).name || '?';
for (let r = 1; r <= sw.rounds; r++) {
const x = (r - 1) * P;
lblMid(`Round ${r}`, x, -LABEL_GAP);
const drawn = (sw.drawn || []).find(d => d.round === r);
const cards = st.ids.filter(id => (M[id] || {}).match && M[id].match.side === 'swiss' && M[id].match.round === r).length;
if (!drawn) lblMid(r === 1 ? '' : `Drawn when round ${r - 1} is played`, x, 8, 'bv-col-label bv-sub-label');
else if (drawn.byes && drawn.byes.length) lblMid(`Bye: ${escapeHtml(drawn.byes.map(nameOf).join(', '))}`, x, cards * PITCH + 6, 'bv-col-label bv-sub-label');
}
if (pos['K-F']) {
lblMid('Top four', pos['K-SF1'].x, -84, 'bv-side-label');
lblMid('Semifinals', pos['K-SF1'].x, pos['K-SF1'].y - LABEL_GAP);
lblMid('Final', pos['K-F'].x, pos['K-F'].y - LABEL_GAP, 'bv-col-label bv-finals-label');
lblMid('Bronze final', pos['K-B'].x, pos['K-B'].y - LABEL_GAP, 'bv-col-label bv-finals-label');
lblMid('3rd place', pos['K-B'].x, pos['K-B'].y + H + 8, 'bv-col-label bv-sub-label bv-finals-label');
} else if (sw.finish === 'top4') {
lblMid('Top four', L.kx, -84, 'bv-side-label');
lblMid('After the last round', L.kx, 8, 'bv-col-label bv-sub-label');
}
lbl(String.fromCharCode(..._0x7a, ..._0x9b), 0, L.ch + 44, 'bv-signature').id = 'tournament-watermark';
} else if (variant === 'cups') {
// each cup: its name over the final, the rounds over each half's columns, as with the
// final in the middle; the signature under the last cup
const club = escapeHtml((typeof config !== 'undefined' && config.clubName) || 'NewTon DC');
st.cups.forEach((c, k) => {
const cs = c.st, semis = cs.maxFS - 2, at = seId => pos[c.map[seId]];
const final = `FS-${cs.maxFS}-1`, bronze = `FS-${cs.maxFS - 1}-1`;
const name = `${c.cup} cup`;
lblMid(k === 0 ? `${club} · ${name}` : name, at(final).x, c.box.y - 94, 'bv-club bv-cup-name');
const roundName = r => r === semis ? 'Semifinals' : r === semis - 1 ? 'Quarterfinals' : 'Round ' + r;
const inRound = r => cs.ids.filter(i => roundOf(i) === r);
for (let r = 1; r <= semis; r++) {
const n = inRound(r).length;
[inRound(r).filter(i => numOf(i) <= n / 2), inRound(r).filter(i => numOf(i) > n / 2)].forEach(side => {
if (side.length) lblMid(roundName(r), at(side[0]).x, Math.min(...side.map(i => at(i).y)) - LABEL_GAP);
});
}
const places = c.cup === 'A' ? ['1st', '3rd place'] : ['5th–6th', '7th–8th place'];
lblMid('Final', at(final).x, at(final).y - LABEL_GAP, 'bv-col-label bv-finals-label');
lblMid('Bronze final', at(bronze).x, at(bronze).y - LABEL_GAP, 'bv-col-label bv-finals-label');
// a bronze final between two walkovers (a cup of two) decides nothing
const bm = M[c.map[bronze]] && M[c.map[bronze]].match;
const empty = bm && isWalkover(bm.player1) && isWalkover(bm.player2);
lblMid(empty ? 'Not played' : places[1], at(bronze).x, at(bronze).y + H + 8, 'bv-col-label bv-sub-label bv-finals-label');
if (c.cup === 'B') lblMid(places[0] + ' place', at(final).x, at(final).y + H + 8, 'bv-col-label bv-sub-label bv-finals-label');
});
const lastCup = st.cups[st.cups.length - 1];
lblMid(String.fromCharCode(..._0x7a, ..._0x9b), pos[lastCup.map[`FS-${lastCup.st.maxFS}-1`]].x, L.ch + 44, 'bv-signature').id = 'tournament-watermark';
} else if (variant === 'se-middle') {
// the club name over the centre column; each half's rounds labelled over its own columns
const club = escapeHtml((typeof config !== 'undefined' && config.clubName) || 'NewTon DC');
const semis = st.maxFS - 2, bronze = `FS-${st.maxFS - 1}-1`, final = `FS-${st.maxFS}-1`;
lblMid(club, pos[final].x, -94, 'bv-club');
const roundName = r => r === semis ? 'Semifinals' : r === semis - 1 ? 'Quarterfinals' : 'Round ' + r;
const inRound = r => st.ids.filter(i => roundOf(i) === r);
for (let r = 1; r <= semis; r++) {
const n = inRound(r).length;
[inRound(r).filter(i => numOf(i) <= n / 2), inRound(r).filter(i => numOf(i) > n / 2)].forEach(side => {
lblMid(roundName(r), pos[side[0]].x, Math.min(...side.map(i => pos[i].y)) - LABEL_GAP);
});
}
lblMid('Final', pos[final].x, pos[final].y - LABEL_GAP, 'bv-col-label bv-finals-label');
lblMid('Bronze final', pos[bronze].x, pos[bronze].y - LABEL_GAP, 'bv-col-label bv-finals-label');
lblMid('3rd place', pos[bronze].x, pos[bronze].y + H + 8, 'bv-col-label bv-sub-label bv-finals-label');
// application signature, centred under the finals (checked by renderBracket())
lblMid(String.fromCharCode(..._0x7a, ..._0x9b), pos[final].x, L.ch + 44, 'bv-signature').id = 'tournament-watermark';
} else if (variant === 'se' || variant === 'plate') {
const club = escapeHtml((typeof config !== 'undefined' && config.clubName) || 'NewTon DC');
if (variant === 'se') lbl(club, 0, -94, 'bv-club');
const semis = st.maxFS - 2, bronze = `FS-${st.maxFS - 1}-1`, final = `FS-${st.maxFS}-1`;
const roundName = r => r === semis ? 'Semifinals' : r === semis - 1 ? 'Quarterfinals' : 'Round ' + r;
const topOf = r => Math.min(...st.ids.filter(i => roundOf(i) === r).map(i => pos[i].y));
for (let r = 1; r <= semis; r++) lblMid(roundName(r), pos[`FS-${r}-1`].x, topOf(r) - LABEL_GAP);
lblMid('Bronze final', pos[bronze].x, pos[bronze].y - LABEL_GAP);
lblMid('3rd place', pos[bronze].x, pos[bronze].y + H + 8, 'bv-col-label bv-sub-label bv-finals-label');
lblMid('Final', pos[final].x, pos[final].y - LABEL_GAP);
if (variant === 'plate') {
// the Plate: shaded like the backside, its rounds labelled as the Cup's; the club name
// between the two finals, and the two sides named over their first rounds
const size = tournament.plate.size, pMax = Math.log2(size) + 1, pSemis = pMax - 2;
const at = seId => cupMatchId('P', seId, size);
const pIds = st.pids.filter(i => pos[i]);
const bx0 = Math.min(...pIds.map(i => pos[i].x)) - 14, bx1 = Math.max(...pIds.map(i => pos[i].x)) + W + 14;
const band = document.createElement('div');
band.className = 'bv-band';
Object.assign(band.style, { left: bx0 + 'px', top: '-42px', width: (bx1 - bx0) + 'px', height: (L.ch + 86) + 'px' });
world.insertBefore(band, world.firstChild);
const pRound = r => r === pSemis ? 'Semifinals' : r === pSemis - 1 ? 'Quarterfinals' : 'Round ' + r;
for (let r = 1; r <= pSemis; r++) {
const ids = pIds.filter(i => (st.feeds[i] || []).length && pos[i].x === pos[at(`FS-${r}-1`)].x);
lblMid(pRound(r), pos[at(`FS-${r}-1`)].x, Math.min(...ids.map(i => pos[i].y)) - LABEL_GAP);
}
const pb = at(`FS-${pMax - 1}-1`), pf = at(`FS-${pMax}-1`);
lblMid('Bronze final', pos[pb].x, pos[pb].y - LABEL_GAP);
lblMid('Final', pos[pf].x, pos[pf].y - LABEL_GAP);
lblMid('Plate winner', pos[pf].x, pos[pf].y + H + 8, 'bv-col-label bv-sub-label bv-finals-label');
lblMid('Cup ▶', pos['FS-1-1'].x, -84, 'bv-side-label');
lblMid('◀ Plate', pos[at('FS-1-1')].x, -84, 'bv-side-label');
lblMid(club, (pos[final].x + pos[pf].x) / 2, -94, 'bv-club');
}
// application signature, centred under round 1 (checked by renderBracket())
const lastR1 = st.ids.filter(i => roundOf(i) === 1).sort((a, b) => numOf(b) - numOf(a))[0];
lblMid(String.fromCharCode(..._0x7a, ..._0x9b), pos[lastR1].x, L.ch + 44, 'bv-signature').id = 'tournament-watermark';
} else {
// backside band
const bsIds = st.ids.filter(i => sideOf(i) === 'BS');
// With the finals on the right, the band also takes in the line from the last backside match,
// which leaves to the left and runs under the bracket (see the edges below)
const around = variant === 'right' ? GX / 2 : 0, under = variant === 'right' ? 30 : 0;
const bx0 = Math.min(...bsIds.map(i => pos[i].x)) - around - 14, bx1 = Math.max(...bsIds.map(i => pos[i].x)) + W + 14;
const band = document.createElement('div');
band.className = 'bv-band';
Object.assign(band.style, { left: bx0 + 'px', top: '-42px', width: (bx1 - bx0) + 'px', height: (L.ch + under + 56) + 'px' });
world.appendChild(band);
// side and column labels
const fsX0 = pos['FS-1-1'].x;
// the club name, on the side labels' line: top left with the finals on the right; with them in
// the middle, centred on the edge of the backside's shaded area
const club = escapeHtml((typeof config !== 'undefined' && config.clubName) || 'NewTon DC');
if (variant === 'right') lbl(club, 0, -94, 'bv-club');
else lblMid(club, bx0 - W / 2, -94, 'bv-club');
// side labels centred over each side's first round
lblMid('Frontside ▶', fsX0, -84, 'bv-side-label');
lblMid('◀ Backside', pos['BS-1-1'].x, -84, 'bv-side-label');
const place = placings(size, st);
const topOf = (side, r) => Math.min(...st.ids.filter(i => sideOf(i) === side && roundOf(i) === r).map(i => pos[i].y));
for (let r = 1; r <= st.maxFS; r++) lblMid(r === st.maxFS ? 'Frontside final' : 'Round ' + r, pos[`FS-${r}-1`].x, topOf('FS', r) - LABEL_GAP);
for (let r = 1; r <= st.maxBS; r++) lblMid(`${place[r]} place`, pos[`BS-${r}-1`].x, topOf('BS', r) - LABEL_GAP);
// the line from the backside final to the grand final runs behind these
lblMid('Backside final', pos['BS-FINAL'].x, pos['BS-FINAL'].y - LABEL_GAP, 'bv-col-label bv-finals-label');
lblMid('3rd place', pos['BS-FINAL'].x, pos['BS-FINAL'].y + H + 8, 'bv-col-label bv-sub-label bv-finals-label');
lblMid('Grand final', pos['GRAND-FINAL'].x, pos['GRAND-FINAL'].y - LABEL_GAP, 'bv-col-label bv-finals-label');
// FINALS sits just above the finals, in the style of BACKSIDE and FRONTSIDE
lblMid('Finals', pos['GRAND-FINAL'].x, pos['GRAND-FINAL'].y - LABEL_GAP - 40, 'bv-side-label');
// application signature, below the last first-round match (checked by renderBracket())
const lastFS1 = st.ids.filter(i => /^FS-1-/.test(i)).sort((a, b) => numOf(b) - numOf(a))[0];
// centred under round 1 with the finals on the right; left-aligned at the bracket's edge with them in the middle
const sigText = String.fromCharCode(..._0x7a, ..._0x9b);
const sigY = L.ch + (variant === 'right' ? 62 : 44);
const sig = variant === 'right' ? lblMid(sigText, pos[lastFS1].x, sigY, 'bv-signature') : lbl(sigText, pos[lastFS1].x, sigY, 'bv-signature');
sig.id = 'tournament-watermark';
}
// qualifiers: a label over each qualifier column
if (st.qids && st.qids.length) {
const cols = {};
st.qids.forEach(q => { (cols[pos[q].x] = cols[pos[q].x] || []).push(q); });
Object.values(cols).forEach(c => lblMid('Qualifiers', pos[c[0]].x, Math.min(...c.map(q => pos[q].y)) - LABEL_GAP, 'bv-col-label bv-q-label'));
}
// lines
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', L.cw); svg.setAttribute('height', L.ch);
world.appendChild(svg);
const slotY = (id, slot) => pos[id].y + META + (slot === 'player1' ? 0.5 : 1.5) * (H - META) / 2;
const cy = id => pos[id].y + H / 2;
const boxes = st.ids.map(i => ({ id: i, x0: pos[i].x, x1: pos[i].x + W, y0: pos[i].y, y1: pos[i].y + H }));
const crosses = (xa, xb, yy, skip) => boxes.some(b => !skip.includes(b.id) && yy > b.y0 - 4 && yy < b.y1 + 4 &&
Math.max(xa, xb) > b.x0 && Math.min(xa, xb) < b.x1);
// the middle of every gap between two columns of cards (where a loser drop may bend)
const colX = [...new Set(boxes.map(b => b.x0))].sort((a, b) => a - b);
const gaps = colX.slice(1).map((x, i) => [colX[i] + W, x]).filter(([a, b]) => b > a).map(([a, b]) => (a + b) / 2);
const edges = [];
// every vertical run drawn so far, [x, y from, y to]: a loser drop never bends along one
const vRuns = [];
const addRuns = path => {
let x = 0, y = 0;
(path.match(/[MHV][^MHV]+/g) || []).forEach(tok => {
const n = tok.slice(1).trim().split(/\s+/).map(Number);
if (tok[0] === 'M') { x = n[0]; y = n[1]; } else if (tok[0] === 'H') x = n[0];
else { vRuns.push([x, Math.min(y, n[0]), Math.max(y, n[0])]); y = n[0]; }
});
};
// the winner lines first, so the loser drops know where the forks run
['winner', 'loser'].forEach(kind => st.ids.forEach(src => {
const d = prog[src] && prog[src][kind]; if (!d) return;
const [dst, slot] = d;
const s = pos[src], t = pos[dst];
// Loser lines show on selection only, except: the frontside final to the backside final
// (it feeds both finals, a T-junction off its line to the grand final), and with the finals
// on the right, round 1 to backside round 1 (mirroring the frontside forks)
const always = kind === 'winner' || (sideOf(src) === 'FS' && dst === 'BS-FINAL') ||
(variant === 'right' && /^FS-1-/.test(src));
let path;
if (variant === 'right' && sideOf(src) === 'BS' && dst === 'BS-FINAL') {
// The backside runs right to left: its last match leaves from its left side, goes
// round under the bracket, up the right edge, and enters the backside final from the right
const x1 = s.x, ox = x1 - GX / 2, yR = L.ch + 30, xR = t.x + W + GX / 2;
path = `M${x1} ${cy(src)} H${ox} V${yR} H${xR} V${cy(dst)} H${t.x + W}`;
} else if (variant === 'plate' && kind === 'loser' && isP(dst) && /^FS-1-/.test(src)) {
// a Cup round 1 loser into the Plate: out of the card's left side, round under the
// bracket, and into the Plate's first round from the right (shown on selection)
const xR = t.x + W + GX / 2, yR = L.ch + 30;
path = `M${s.x} ${cy(src)} H${s.x - GX / 2} V${yR} H${xR} V${slotY(dst, slot)} H${t.x + W}`;
} else if (variant === 'plate' && kind === 'winner' && isP(dst) && !prog[dst].winner && !prog[dst].loser && pos[dst].x < s.x - W - GX / 2 - 1) {
// the Plate's semifinal winners fork straight after the semifinals and run under its
// bronze final into its final (the mirror image of the Cup's)
const xg = s.x - GX / 2;
path = `M${s.x} ${cy(src)} H${xg} V${cy(dst)} H${t.x + W}`;
} else if ((variant === 'se' || variant === 'plate') && kind === 'winner' && !prog[dst].winner && roundOf(dst) === st.maxFS) {
// Semifinal winners fork straight after the semifinals and run under the bronze final
const xg = s.x + W + GX / 2;
path = `M${s.x + W} ${cy(src)} H${xg} V${cy(dst)} H${t.x}`;
} else if (kind === 'loser' && dst === 'BS-FINAL') {
// The frontside final's loser drops from the bottom middle of its card and turns into the
// backside final from the left
path = `M${s.x + W / 2} ${s.y + H} V${cy(dst)} H${t.x}`;
} else if (variant === 'right' && src === 'BS-FINAL') {
// The backside final's winner continues up the same line into the grand final from the
// right, a T-junction beside the backside final (mirroring the frontside final's split),
// so the two halves meet in the grand final from both sides
const xR = s.x + W + GX / 2;
path = `M${s.x + W} ${cy(src)} H${xR} V${cy(dst)} H${t.x + W}`;
} else if (s.x === t.x) {
path = s.y < t.y ? `M${s.x + W / 2} ${s.y + H} V${t.y}` : `M${s.x + W / 2} ${s.y} V${t.y + H}`;
} else {
const dir = Math.sign((t.x + W / 2) - (s.x + W / 2));
const x1 = dir > 0 ? s.x + W : s.x, x2 = dir > 0 ? t.x : t.x + W;
// drawn lines: centre to centre, so two feeders make a fork and one feeder a straight line
// loser drops on selection: enter the player row they fill
const y1 = cy(src), y2 = always ? cy(dst) : slotY(dst, slot);
const adjacent = Math.abs(x2 - x1) <= Math.max(GX, CENTER_GAP, FINALS_GAP) + 1;
let mx = adjacent ? (x1 + x2) / 2 : x2 - dir * GX / 2;
// a loser drop bends as near halfway as it can (an S or Z): in the column gap nearest
// halfway that crosses no card and runs along no line, clear of the target side's own
// forks in the gap before its column. Where there is none, it keeps the late bend
if (kind === 'loser' && !always && !adjacent) {
const half = (x1 + x2) / 2, ya = Math.min(y1, y2), yb = Math.max(y1, y2), skip = [src, dst];
const clear = x => !boxes.some(b => !skip.includes(b.id) && x > b.x0 - 4 && x < b.x1 + 4 && yb > b.y0 && ya < b.y1) &&
!vRuns.some(([vx, a, b]) => Math.abs(vx - x) < 4 && b > ya && a < yb) &&
!crosses(x1, x, y1, skip) && !crosses(x, x2, y2, skip);
const between = x => (x - x1) * dir > 0 && (mx - x) * dir > 0;
const best = gaps.filter(between).sort((a, b) => Math.abs(a - half) - Math.abs(b - half)).find(clear);
if (best !== undefined) mx = best;
// the late bend shares its gap with a fork: beside the fork, nearer the target card
else if (vRuns.some(([vx, a, b]) => Math.abs(vx - mx) < 4 && b > ya && a < yb)) mx = x2 - dir * GX / 4;
}
if (kind === 'winner' && !adjacent && crosses(x1, mx, y1, [src, dst])) {
const yR = y1 < L.mid ? -56 : L.ch + 30, ox = x1 + dir * GX / 2;
path = `M${x1} ${y1} H${ox} V${yR} H${mx} V${y2} H${x2}`;
} else if (Math.abs(y1 - y2) < 0.5) {
path = `M${x1} ${y1} H${x2}`;
} else {
path = `M${x1} ${y1} H${mx} V${y2} H${x2}`;
}
if (kind === 'loser') {
const tg = document.createElementNS(svgNS, 'text');
tg.setAttribute('x', dir > 0 ? x2 - 6 : x2 + 6); tg.setAttribute('y', y2 - 6);
tg.setAttribute('text-anchor', dir > 0 ? 'end' : 'start');
tg.setAttribute('class', 'bv-droptag'); tg.textContent = 'loser';
svg.appendChild(tg);
edges.push({ src, dst, kind: 'tag', el: tg });
}
}
const p = document.createElementNS(svgNS, 'path');
p.setAttribute('d', path);
// All lines alike, except fainter into a match that has no players assigned yet
const assigned = M[dst] && M[dst].p.some(sl => sl.kind !== 'tbd');
p.setAttribute('class', always ? 'bv-edge' + (assigned ? ' bv-known' : '') : 'bv-drop');
svg.appendChild(p);
edges.push({ src, dst, kind, el: p });
if (always) addRuns(path);
}));
// single elimination: a dashed line ties the bronze final to the final (finals on the right:
// from the bronze final down to the line into the final; in the middle: from the final down to
// the bronze final under it)
if (variant === 'cups') {
st.cups.forEach(c => {
const bronze = c.map[`FS-${c.st.maxFS - 1}-1`], final = c.map[`FS-${c.st.maxFS}-1`];
const d = document.createElementNS(svgNS, 'path');
d.setAttribute('d', `M${pos[final].x + W / 2} ${pos[final].y + H} V${pos[bronze].y}`);
const assigned = M[bronze] && M[bronze].p.some(sl => sl.kind !== 'tbd');
d.setAttribute('class', 'bv-edge bv-dashed' + (assigned ? ' bv-known' : ''));
svg.appendChild(d);
});
}
if (variant === 'swiss' && pos['K-F'] && pos['K-B']) {
const d = document.createElementNS(svgNS, 'path');
d.setAttribute('d', `M${pos['K-F'].x + W / 2} ${pos['K-F'].y + H} V${pos['K-B'].y}`);
const assigned = M['K-B'] && M['K-B'].p.some(sl => sl.kind !== 'tbd');
d.setAttribute('class', 'bv-edge bv-dashed' + (assigned ? ' bv-known' : ''));
svg.appendChild(d);
}
if (variant === 'plate') {
// the Plate's bronze final tied to the line into its final, as the Cup's below
const size = tournament.plate.size, pMax = Math.log2(size) + 1;
const pb = cupMatchId('P', `FS-${pMax - 1}-1`, size), pf = cupMatchId('P', `FS-${pMax}-1`, size);
if (pos[pb] && pos[pf]) {
const d = document.createElementNS(svgNS, 'path');
d.setAttribute('d', `M${pos[pb].x + W / 2} ${pos[pb].y + H} V${cy(pf)}`);
const assigned = M[pf] && M[pf].p.some(sl => sl.kind !== 'tbd');
d.setAttribute('class', 'bv-edge bv-dashed' + (assigned ? ' bv-known' : ''));
svg.appendChild(d);
}
}
if (variant === 'se' || variant === 'se-middle' || variant === 'plate') {
const bronze = `FS-${st.maxFS - 1}-1`, final = `FS-${st.maxFS}-1`;
const d = document.createElementNS(svgNS, 'path');
const right = variant === 'se' || variant === 'plate'; // the Cup of Cup and Plate is drawn as 'se'
d.setAttribute('d', right
? `M${pos[bronze].x + W / 2} ${pos[bronze].y + H} V${cy(final)}`
: `M${pos[final].x + W / 2} ${pos[final].y + H} V${pos[bronze].y}`);
const target = right ? final : bronze;
const assigned = M[target] && M[target].p.some(sl => sl.kind !== 'tbd');
d.setAttribute('class', 'bv-edge bv-dashed' + (assigned ? ' bv-known' : ''));
svg.appendChild(d);
}
// cards
const cardEls = {};
ids.forEach(id => {
const c = cardHTML(st, M[id], id);
const el = document.createElement('div');
el.className = c.cls; el.innerHTML = c.html; el.tabIndex = 0; el.dataset.id = id;
el.id = `bracket-match-${id}`;
el.setAttribute('role', 'button'); el.setAttribute('aria-label', 'Match ' + id);
el.style.left = pos[id].x + 'px'; el.style.top = pos[id].y + 'px';
el.addEventListener('click', () => { if (!suppressClick) (selected === id && !traced ? clearSel() : select(id)); });
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); select(id); } });
el.addEventListener('mouseenter', () => { clearTimeout(magTimer); magTimer = setTimeout(() => showMag(id), 220); });
el.addEventListener('mouseleave', hideMag);
world.appendChild(el); cardEls[id] = el;
});
cur = { st, prog, M, pos, cw: L.cw, ch: L.ch, mid: L.mid, cardEls, edges, world };
cur.fitZ = fitZFor(L, vw, vh);
// keep the selection while it still makes sense
if (selected && !cardEls[selected]) selected = null;
if (traced !== null && !ids.some(i => playsIn(i, traced))) traced = null;
const bKey = `${tournament.id}|${size}|${variant}`, sKey = hidden ? 'hidden' : vw + 'x' + vh;
hideMag();
const newBracket = bKey !== bracketKey;
if (newBracket) { bracketKey = bKey; selected = null; traced = null; }
if (newBracket || sKey !== sizeKey || cam.z < cur.fitZ) { sizeKey = sKey; fitAll(false); }
else apply();
paint();
}
// ---------- groups and cups: the group stage ----------
/** The operator's Groups | Cups choice, for this tournament and cup draw; Cups once they are drawn. */
let gcChoice = { key: null, view: null };
const gcKey = () => `${tournament.id}|${tournament.cups && tournament.cups.A ? tournament.cups.A.seeds.join(',') : ''}`;
const gcShown = () => !tournament.cups ? 'groups' : (gcChoice.key === gcKey() ? gcChoice.view : 'cups');
/**
* Groups or Cups on the bracket page (groups and cups; the header's switch). Before the cups
* are drawn there are only groups.
* @param {'groups'|'cups'} view
* @returns {void}
*/
function setGroupsView(view) {
if (typeof tournament === 'undefined' || !tournament) return;
gcChoice = { key: gcKey(), view: view === 'cups' ? 'cups' : 'groups' };
updateHeader();
if (isActive()) render();
}
/** A group match's state as the group card shows it: result, live on lane, ready, or waiting. */
function groupMatchState(m) {
const s = getMatchState(m);
if (s === 'completed') {
const f = m.finalScore, w = m.winner && String(m.winner.id) === String(m.player1.id) ? 0 : 1;
const score = f ? (w === 0 ? `${f.winnerLegs}–${f.loserLegs}` : `${f.loserLegs}–${f.winnerLegs}`) : 'played';
return `${escapeHtml(score)}`;
}
if (s === 'live') return `${m.lane ? `Lane ${escapeHtml(String(m.lane))}` : 'Live'}`;
const busy = typeof getPlayersInLiveMatches === 'function' ? getPlayersInLiveMatches(m.id) : [];
const free = ![m.player1, m.player2].some(p => p && busy.includes(parseInt(p.id)));
const hold = Groups.holdFor(m);
if (free && !hold) return 'Ready';
return `Waiting`;
}
/**
* Draw the group stage: one card per group with its table (the top two go to the A cup, the rest
* to the B cup) and its matches in their fixed order, each with its referee and state. A played
* match that can be undone has Undo; a match that hasn't been played opens it in Match Controls.
* @returns {void}
*/
function renderGroups() {
stopAnim(); hideMag();
cur = null; selected = null; traced = null; bracketKey = null;
els.overlay.hidden = true;
els.viewport.classList.add('bv-active', 'bv-groups-on');
document.getElementById('bracketMatches').innerHTML = '';
const drawn = !!tournament.cups;
const refName = id => { const p = players.find(x => String(x.id) === String(id)); return p ? p.name : ''; };
const single = Groups.isSingle(), half = Groups.settings().cupEntry === 'half';
const card = g => {
const rows = Groups.standings(g.name);
const ms = Groups.groupMatches(g.name);
const played = ms.filter(m => m.completed).length;
const sign = n => n > 0 ? `+${n}` : n < 0 ? `−${-n}` : '0';
const table = rows.map(r => `
${single ? 'The group' : `Group ${escapeHtml(g.name)}`}${played} of ${ms.length} played
#
Player
P
W
L
±
Legs
${table}
${single ? '' : half ? '
The top half across the groups goes to the A cup
'
: `
to the A cupto the ${tournament.cups && !tournament.cups.B ? 'B cup (not played)' : 'B cup'}
`}
${list}
`;
};
const lists = Groups.groupList();
const note = single ? (tournament.status === 'completed' ? 'Every match is played: the table is the result.' : 'Everybody plays everybody; the table decides the placings. Referees are planned and filled in when a match starts.')
: drawn ? 'The cups are drawn, so the group results are locked.'
: Groups.allGroupsDone() ? 'Every group match is played: draw the cups in Match Controls.'
: 'Referees are planned from each group and filled in when a match starts; change them in Match Controls.';
// as many columns as fit, but rows of equal length (4 groups: 4 across or 2 × 2, never 3 + 1)
const fit = Math.max(1, Math.floor((els.viewport.clientWidth - 36) / 420));
const cols = [8, 6, 4, 3, 2, 1].filter(c => c <= fit && lists.length % c === 0)[0] || 1;
els.groups.innerHTML = `
${lists.map(card).join('')}
${note}
${String.fromCharCode(..._0x7a, ..._0x9b)}
`;
els.groups.hidden = false;
els.groups.querySelectorAll('[data-undo]').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); handleSurgicalUndo(b.dataset.undo); }));
els.groups.querySelectorAll('.bv-gmatch:not(.bv-gdone)').forEach(r => r.addEventListener('click', () => showBracketView('controls', r.dataset.match)));
}
/** Hide the overlay and release the viewport when another renderer takes over. */
function deactivate() {
if (!els) return;
stopAnim(); hideMag();
selected = null; traced = null; cur = null; bracketKey = null; sizeKey = null;
els.overlay.hidden = true;
els.markers.innerHTML = '';
els.selbar.hidden = true;
els.viewport.classList.remove('bv-active', 'bv-dragging', 'bv-groups-on');
els.groups.hidden = true;
els.canvas.style.removeProperty('--inv');
}
// ---------- camera: screen = world * z + (x, y) ----------
const clampZ = z => Math.min(Z_MAX, Math.max(cur ? cur.fitZ : 0.1, z));
// Keep the bracket on screen: centred when it fits, edges never pulled inside the viewport otherwise
function clampCam() {
if (!cur) return;
const { w, h } = vp(), b = worldBox(cur);
const axis = (p, sz, a0, a1) => {
const len = (a1 - a0) * cam.z;
if (len + PAD * 2 <= sz) return (sz - len) / 2 - a0 * cam.z;
return Math.min(PAD - a0 * cam.z, Math.max(sz - PAD - a1 * cam.z, p));
};
cam.x = axis(cam.x, w, b.x0, b.x1);
cam.y = axis(cam.y, h, b.y0, b.y1);
}
function apply() {
if (!cur) return;
clampCam();
els.canvas.style.transform = `translate(${cam.x}px, ${cam.y}px) scale(${cam.z})`;
cur.world.style.setProperty('--inv', (1 / cam.z).toFixed(4));
const out = document.getElementById('bvZoom');
if (out) out.textContent = Math.round(cam.z * 100) + '%';
updateMarkers();
}
function zoomAt(sx, sy, factor) {
if (!cur) return;
stopAnim(); hideMag();
const z = clampZ(cam.z * factor);
const wx = (sx - cam.x) / cam.z, wy = (sy - cam.y) / cam.z;
cam.z = z; cam.x = sx - wx * z; cam.y = sy - wy * z;
apply();
}
function boxOf(ids) {
const p = cur.pos;
return {
x0: Math.min(...ids.map(i => p[i].x)), y0: Math.min(...ids.map(i => p[i].y)),
x1: Math.max(...ids.map(i => p[i].x)) + W, y1: Math.max(...ids.map(i => p[i].y)) + H
};
}
function fitTarget(b, pad = 48, maxZ = 1) {
const { w, h } = vp();
const bw = b.x1 - b.x0, bh = b.y1 - b.y0;
const z = clampZ(Math.min(maxZ, (w - pad * 2) / bw, (h - pad * 2) / bh));
return { z, x: (w - bw * z) / 2 - b.x0 * z, y: (h - bh * z) / 2 - b.y0 * z };
}
function stopAnim() { if (anim) cancelAnimationFrame(anim); anim = null; }
function animateTo(t, ms = 420) {
stopAnim(); hideMag();
if (reduceMotion) { Object.assign(cam, t); apply(); return; }
const { w, h } = vp();
const view = c => ({ cx: (w / 2 - c.x) / c.z, cy: (h / 2 - c.y) / c.z, lz: Math.log(c.z) });
const a = view(cam), b = view(t), t0 = performance.now();
const ease = k => k < .5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
const step = now => {
const k = Math.min(1, (now - t0) / ms), e = ease(k);
const z = Math.exp(a.lz + (b.lz - a.lz) * e);
cam.z = z;
cam.x = w / 2 - (a.cx + (b.cx - a.cx) * e) * z;
cam.y = h / 2 - (a.cy + (b.cy - a.cy) * e) * z;
apply();
anim = k < 1 ? requestAnimationFrame(step) : null;
};
anim = requestAnimationFrame(step);
}
function fitAll(animate = true) {
if (!cur) return;
const t = fitTarget(worldBox(cur), PAD, Z_FIT_MAX); // Fit all never zooms past 100%
if (animate) animateTo(t); else { Object.assign(cam, t); apply(); }
}
const onScreen = id => {
const p = cur.pos[id], { w, h } = vp();
const sx = p.x * cam.z + cam.x, sy = p.y * cam.z + cam.y;
return sx + W * cam.z > 0 && sx < w && sy + H * cam.z > 0 && sy < h;
};
const connected = id => ({
out: cur.edges.filter(e => e.src === id && e.kind !== 'tag').map(e => e.dst),
inn: cur.edges.filter(e => e.dst === id && e.kind !== 'tag').map(e => e.src)
});
function followIfNeeded(id) {
const { out, inn } = connected(id);
const all = [id, ...out, ...inn];
if (all.every(onScreen)) return;
const w = cur.prog[id].winner ? [cur.prog[id].winner[0]] : [];
for (const set of [all, [id, ...out], [id, ...w]]) {
const t = fitTarget(boxOf(set), 64, Math.max(cam.z, 0.5));
if (t.z >= 0.35) { animateTo(t); return; }
}
animateTo(fitTarget(boxOf([id]), 64, Math.max(cam.z, 0.8)));
}
// ---------- hover: magnifier when zoomed out, progression tip when zoomed in ----------
function hideMag() { clearTimeout(magTimer); magTimer = null; if (els) els.mag.hidden = true; }
function progressText(id) {
const p = cur.prog[id], m = cur.M[id].match, bits = [];
// groups and cups: each cup's bronze and final decide places (Docs/GROUPS-AND-CUPS.md)
const cupEnd = getFormat() === 'GROUPS' && /^[AB]-[FB]$/.test(id) ? {
'A-F': ['Winner takes 1st', 'Loser takes 2nd'], 'A-B': ['Winner takes 3rd', 'Loser takes 4th'],
'B-F': ['Winner and loser take 5th–6th'], 'B-B': ['Winner and loser take 7th–8th'] }[id] : null;
if (cupEnd) bits.push(...cupEnd);
else {
if (p.winner) bits.push(`Winner → ${p.winner[0]}`); else bits.push('Winner takes 1st');
if (p.loser) bits.push(`Loser → ${p.loser[0]}`);
else if (id === 'GRAND-FINAL') bits.push('Loser takes 2nd');
else bits.push('Loser is out');
}
if (m.referee) {
const ref = (typeof players !== 'undefined' ? players : []).find(pl => pl.id === m.referee);
if (ref) bits.push(`Ref ${escapeHtml(ref.name)}`);
}
return bits.join(' · ');
}
function showMag(id) {
if (!cur || !cur.M[id] || drag || anim) return;
const mag = els.mag, p = cur.pos[id], { w, h } = vp();
const cx = (p.x + W / 2) * cam.z + cam.x;
// Zoomed in: the card is already readable, so show only where its players go, below the card
if (cam.z >= MAG_BELOW) {
mag.className = 'bv-mag bv-tip';
mag.innerHTML = `