world-2026-hub/assets/js/bracket.js

782 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// bracket.js — knockout bracket. R32 slots come from bracket-config.json
// (group positions resolved via computeStandings(), best-third slots via
// thirdPlaceAssignment); every later round is generated by sequential pairing
// of winners (nodes 0-1 → next 0, 2-3 → next 1, …). SF losers feed the
// third-place match. Unresolvable slots render as placeholders, unplayed
// matches as TBD. Interactions land in step 8, simulation in step 9.
import { getData, flagSrc } from './app.js';
import { get as storageGet, set as storageSet, getFavorites } from './storage.js';
import { computeStandings, isGroupFinished } from './groups.js';
import { t, translatePhase } from './i18n.js';
import { openMatchModal } from './modal.js';
const ROUNDS = [
{ id: 'R32', phase: 'Round of 32', size: 16 },
{ id: 'R16', phase: 'Round of 16', size: 8 },
{ id: 'QF', phase: 'Quarterfinals', size: 4 },
{ id: 'SF', phase: 'Semifinals', size: 2 },
{ id: 'FINAL', phase: 'Final', size: 1 },
];
// ---------------------------------------------------------------- tree
let cachedTree = null;
// results are static per page load; simulation (step 9) will call this
export function invalidateBracket() {
cachedTree = null;
}
export function getBracketTree() {
if (!cachedTree) cachedTree = buildTree();
return cachedTree;
}
function buildTree() {
const { matches, resultByMatchId, bracketConfig } = getData();
const simulation = storageGet('simulation', {});
const standings = computeStandings();
const matchByRef = new Map(matches.filter((m) => m.bracketRef).map((m) => [m.bracketRef, m]));
const nodesByRef = new Map();
const rounds = [];
let previous = null;
for (const round of ROUNDS) {
const nodes = [];
for (let i = 0; i < round.size; i += 1) {
const ref = round.id === 'FINAL' ? 'FINAL' : `${round.id}-${i + 1}`;
const node = {
ref,
round: round.id,
phase: round.phase,
match: matchByRef.get(ref) ?? null,
home: null,
away: null,
winner: null,
loser: null,
result: null,
};
if (round.id === 'R32') {
const entry = bracketConfig.round32[i];
node.home = configSlot(entry.home, standings, bracketConfig);
node.away = configSlot(entry.away, standings, bracketConfig);
} else {
node.home = previous[i * 2].winner ? { teamId: previous[i * 2].winner } : { ph: { kind: 'tbd' } };
node.away = previous[i * 2 + 1].winner ? { teamId: previous[i * 2 + 1].winner } : { ph: { kind: 'tbd' } };
}
decide(node, resultByMatchId);
applySimulation(node, simulation);
nodes.push(node);
nodesByRef.set(ref, node);
}
rounds.push({ ...round, nodes });
previous = nodes;
}
const [sf1, sf2] = rounds.find((r) => r.id === 'SF').nodes;
const third = {
ref: 'THIRD-PLACE',
round: 'THIRD',
phase: 'Third Place',
match: matchByRef.get('THIRD-PLACE') ?? null,
home: sf1.loser ? { teamId: sf1.loser } : { ph: { kind: 'tbd' } },
away: sf2.loser ? { teamId: sf2.loser } : { ph: { kind: 'tbd' } },
winner: null,
loser: null,
result: null,
};
decide(third, resultByMatchId);
applySimulation(third, simulation);
nodesByRef.set('THIRD-PLACE', third);
return { rounds, third, nodesByRef, champion: nodesByRef.get('FINAL').winner };
}
function configSlot(spec, standings, config) {
if (spec.type === 'third') {
const group = config.thirdPlaceAssignment?.[String(spec.slot)];
if (group && isGroupFinished(group)) return { teamId: standings[group][2].teamId };
return { ph: { kind: 'third', n: spec.slot } };
}
if (isGroupFinished(spec.ref)) return { teamId: standings[spec.ref][spec.pos - 1].teamId };
return { ph: { kind: spec.pos === 1 ? 'groupWinner' : 'groupRunnerUp', g: spec.ref } };
}
// real finished results only — the simulation overlay is applySimulation()'s
// job and always loses to a real result
function decide(node, resultByMatchId) {
const result = node.match ? resultByMatchId.get(node.match.id) : null;
node.result = result ?? null;
node.simulated = false;
node.simScore = null;
if (!result || result.status !== 'finished' || !node.home.teamId || !node.away.teamId) return;
let side = result.homeScore > result.awayScore ? 'home'
: result.homeScore < result.awayScore ? 'away'
: null;
if (!side && result.penalties) {
side = result.penalties.home > result.penalties.away ? 'home' : 'away';
}
if (!side) return;
node.winner = node[side].teamId;
node.loser = node[side === 'home' ? 'away' : 'home'].teamId;
}
function isSimulatable(node) {
const realLocked = node.result && node.result.status !== 'scheduled';
return Boolean(node.match) && !realLocked && Boolean(node.home.teamId) && Boolean(node.away.teamId);
}
// User simulation overlays matches without a real final result. Entries whose
// winner no longer matches either resolved team (stale picks after upstream
// changes) are silently ignored — same validation the prediction import uses.
function applySimulation(node, simulation) {
if (node.winner || !node.home.teamId || !node.away.teamId) return;
if (node.result?.status === 'finished') return;
const entry = simulation[node.ref];
if (!entry || (entry.winner !== node.home.teamId && entry.winner !== node.away.teamId)) return;
node.winner = entry.winner;
node.loser = entry.winner === node.home.teamId ? node.away.teamId : node.home.teamId;
node.simulated = true;
const parsed = /^(\d+)-(\d+)$/.exec(entry.score ?? '');
node.simScore = parsed ? { home: Number(parsed[1]), away: Number(parsed[2]) } : null;
}
// ------------------------------------------------------ display helpers
function slotDisplay(slot) {
if (slot?.teamId) {
const team = getData().teamById.get(slot.teamId);
return { team, label: team.name };
}
const ph = slot?.ph ?? { kind: 'tbd' };
switch (ph.kind) {
case 'groupWinner': return { team: null, label: t('bracket.groupWinner').replace('{g}', ph.g) };
case 'groupRunnerUp': return { team: null, label: t('bracket.groupRunnerUp').replace('{g}', ph.g) };
case 'third': return { team: null, label: t('bracket.bestThird').replace('{n}', ph.n) };
default: return { team: null, label: t('app.tbd') };
}
}
// Shared by schedule.js and modal.js: given a match (group or knockout) or a
// bracketRef string, returns { home, away } as { team: Team|null, label }.
export function resolveBracketTeams(matchOrRef) {
const ref = typeof matchOrRef === 'string' ? matchOrRef : matchOrRef.bracketRef;
if (!ref) {
const { teamById } = getData();
const make = (id) => {
const team = teamById.get(id) ?? null;
return { team, label: team?.name ?? t('app.tbd') };
};
return { home: make(matchOrRef.homeTeam), away: make(matchOrRef.awayTeam) };
}
const node = getBracketTree().nodesByRef.get(ref);
if (!node) {
const tbd = { team: null, label: t('app.tbd') };
return { home: tbd, away: tbd };
}
return { home: slotDisplay(node.home), away: slotDisplay(node.away) };
}
// ------------------------------------------------ shared favorites helper
// Matches involving a favorited team — shared by schedule.js and bracket.js.
// Uses resolved slots so knockout matches count once their teams are known.
export function getFavoriteMatches(matches, favorites) {
if (!favorites.length) return [];
const favSet = new Set(favorites);
return matches.filter((match) => {
const slots = resolveBracketTeams(match);
return favSet.has(slots.home.team?.id) || favSet.has(slots.away.team?.id);
});
}
// --------------------------------------------------- challenge (step 12)
// For every knockout match with a real finished result, compares the actual
// winner with the user's simulated pick. Recalculated on every render — no
// persistence of its own.
export function calculateChallengeScore(simulation, results, bracketTree) {
const resultById = new Map(results.map((r) => [r.matchId, r]));
const byPhaseCount = {};
let correct = 0;
let total = 0;
for (const node of bracketTree.nodesByRef.values()) {
if (!node.match || !node.home.teamId || !node.away.teamId) continue;
const result = resultById.get(node.match.id);
if (result?.status !== 'finished') continue;
let side = result.homeScore > result.awayScore ? 'home'
: result.homeScore < result.awayScore ? 'away'
: null;
if (!side && result.penalties) {
side = result.penalties.home > result.penalties.away ? 'home' : 'away';
}
if (!side) continue;
const realWinner = node[side].teamId;
total += 1;
byPhaseCount[node.phase] ??= { correct: 0, total: 0 };
byPhaseCount[node.phase].total += 1;
if (simulation[node.ref]?.winner === realWinner) {
correct += 1;
byPhaseCount[node.phase].correct += 1;
}
}
const byPhase = Object.fromEntries(
Object.entries(byPhaseCount).map(([phase, c]) => [phase, `${c.correct}/${c.total}`]),
);
return { correct, total, byPhase };
}
// ------------------------------------------- share prediction (step 12)
export function getShareableLink() {
const url = new URL(location.href);
url.searchParams.set('prediction', btoa(JSON.stringify(getSimulation())));
return url.toString();
}
// Reads ?prediction= on load. Applies only if every key exists in the current
// bracket tree and the user confirms; invalid/incompatible data is silently
// ignored. The param is removed from the URL either way.
export function loadPredictionFromURL() {
const param = new URLSearchParams(location.search).get('prediction');
if (!param) return;
const cleanURL = new URL(location.href);
cleanURL.searchParams.delete('prediction');
history.replaceState(null, '', cleanURL);
try {
const data = JSON.parse(atob(param));
if (typeof data !== 'object' || data === null || Array.isArray(data)) return;
const entries = Object.entries(data);
if (!entries.length) return;
const tree = getBracketTree();
const valid = entries.every(([ref, entry]) =>
tree.nodesByRef.has(ref) && entry && typeof entry.winner === 'string');
if (!valid) return;
if (window.confirm(t('share.confirm'))) {
storageSet('simulation', data);
refreshAfterSimChange();
}
} catch {
// malformed base64/JSON — ignore silently per spec
}
}
// -------------------------------------------------------------- render
export function initBracket() {
render();
document.addEventListener('langchange', render);
document.addEventListener('favchange', render);
loadPredictionFromURL();
const root = document.getElementById('bracket-root');
const activate = (el) => {
if (simMode) {
const node = getBracketTree().nodesByRef.get(el.dataset.ref);
if (node && isSimulatable(node)) {
openSimEditor(el.dataset.ref);
return;
}
}
openMatchModal(Number(el.dataset.matchId));
};
root.addEventListener('click', (event) => {
const node = event.target.closest('[data-match-id]');
if (node) activate(node);
});
root.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const node = event.target.closest('[data-match-id]');
if (node) {
event.preventDefault();
activate(node);
}
});
}
function challengeCardHTML(challenge) {
const phases = Object.entries(challenge.byPhase)
.map(([phase, score]) => `<span class="challenge-phase">${translatePhase(phase)}: ${score}</span>`)
.join('');
const summary = t('challenge.correct')
.replace('{x}', challenge.correct)
.replace('{y}', challenge.total);
return `
<div class="challenge-card glass">
<span class="challenge-title">${t('challenge.title')}</span>
<span class="challenge-score">${summary}</span>
${phases}
</div>`;
}
function render() {
const tree = getBracketTree();
const simulation = getSimulation();
const challenge = calculateChallengeScore(simulation, getData().results, tree);
const hasPicks = Object.keys(simulation).length > 0;
document.getElementById('bracket-root').innerHTML = `
${challenge.total ? challengeCardHTML(challenge) : ''}
<div class="bracket-toolbar">
<div class="bracket-tools-left">
<button class="zoom-btn sim-toggle ${simMode ? 'active' : ''}" id="sim-toggle"
aria-pressed="${simMode}">${t('sim.mode')}</button>
<button class="zoom-btn" id="sim-reset">${t('sim.reset')}</button>
<button class="zoom-btn" id="share-pred" ${hasPicks ? '' : 'disabled'}>${t('share.button')}</button>
</div>
<div class="bracket-tools-right">
<button class="zoom-btn" id="zoom-out" aria-label="${t('bracket.zoomOut')}"></button>
<button class="zoom-btn zoom-reset" id="zoom-reset" aria-label="${t('bracket.zoomReset')}">100%</button>
<button class="zoom-btn" id="zoom-in" aria-label="${t('bracket.zoomIn')}">+</button>
</div>
</div>
${simMode ? `<p class="sim-note">${t('sim.hint')}</p>` : ''}
<div class="bracket-wrap" id="bracket-wrap">
<div class="bracket-zoom" id="bracket-zoom">
<div class="bracket ${simMode ? 'sim-on' : ''}" id="bracket-canvas">
${tree.rounds.filter((r) => r.id !== 'FINAL').map(roundColumnHTML).join('')}
${finalColumnHTML(tree)}
</div>
</div>
</div>`;
document.getElementById('sim-toggle').addEventListener('click', () => {
simMode = !simMode;
render();
});
document.getElementById('sim-reset').addEventListener('click', () => {
storageSet('simulation', {});
refreshAfterSimChange();
});
document.getElementById('share-pred').addEventListener('click', async (event) => {
const btn = event.currentTarget;
const link = getShareableLink();
try {
await navigator.clipboard.writeText(link);
const original = btn.textContent;
btn.textContent = t('share.copied');
setTimeout(() => { btn.textContent = original; }, 2000);
} catch {
window.prompt(t('share.button'), link); // clipboard unavailable — let the user copy manually
}
});
initInteractions();
}
function roundColumnHTML(round) {
const pairs = [];
for (let i = 0; i < round.nodes.length; i += 2) {
pairs.push(`
<div class="bracket-pair">
<div class="bracket-slot">${matchNodeHTML(round.nodes[i])}</div>
<div class="bracket-slot">${matchNodeHTML(round.nodes[i + 1])}</div>
</div>`);
}
return `
<div class="bracket-round" data-round="${round.id}">
<h3 class="bracket-round-title">${translatePhase(round.phase)}</h3>
<div class="bracket-matches">${pairs.join('')}</div>
</div>`;
}
function finalColumnHTML(tree) {
const finalNode = tree.rounds.find((r) => r.id === 'FINAL').nodes[0];
const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null;
return `
<div class="bracket-round bracket-final-col" data-round="FINAL">
<h3 class="bracket-round-title">${translatePhase('Final')}</h3>
<div class="bracket-matches">
<div class="bracket-slot bracket-champion-slot">
<div class="champion-box ${champion ? 'has-champion' : ''}">
<span class="champion-trophy" aria-hidden="true">🏆</span>
<span class="champion-label">${t('bracket.champion')}</span>
<span class="champion-name">${champion ? champion.label : t('app.tbd')}</span>
</div>
</div>
<div class="bracket-slot">${matchNodeHTML(finalNode)}</div>
<div class="bracket-slot bracket-third-slot">
<div class="third-place-block">
<h4>${translatePhase('Third Place')}</h4>
${matchNodeHTML(tree.third)}
</div>
</div>
</div>
</div>`;
}
function matchNodeHTML(node) {
const home = slotDisplay(node.home);
const away = slotDisplay(node.away);
const live = node.result?.status === 'live';
const favorites = getFavorites();
const hasFav = favorites.includes(node.home.teamId) || favorites.includes(node.away.teamId);
const classes = [
'bracket-match',
live ? 'is-live' : '',
node.simulated ? 'is-sim' : '',
hasFav ? 'has-fav' : '',
simMode && isSimulatable(node) ? 'simulatable' : '',
].filter(Boolean).join(' ');
const interactive = node.match
? `data-match-id="${node.match.id}" tabindex="0" role="button"
aria-label="${home.label} ${t('hero.vs')} ${away.label}${translatePhase(node.phase)}"`
: '';
return `
<article class="${classes}" data-ref="${node.ref}" ${interactive}>
${node.simulated ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
${teamRowHTML(home, node, 'home')}
${teamRowHTML(away, node, 'away')}
</article>`;
}
// ----------------------------------------------------- simulation (step 9)
let simMode = false;
function getSimulation() {
return storageGet('simulation', {});
}
function refreshAfterSimChange() {
invalidateBracket();
render();
document.dispatchEvent(new CustomEvent('simchange'));
}
function ensureSimDialog() {
if (document.getElementById('sim-dialog')) return;
document.getElementById('modal-root').insertAdjacentHTML(
'beforeend',
'<dialog class="match-modal sim-modal" id="sim-dialog"></dialog>',
);
const dialog = document.getElementById('sim-dialog');
dialog.addEventListener('click', (event) => {
if (event.target === dialog) dialog.close();
});
}
function openSimEditor(ref) {
ensureSimDialog();
const dialog = document.getElementById('sim-dialog');
const node = getBracketTree().nodesByRef.get(ref);
const existing = getSimulation()[ref] ?? null;
const home = slotDisplay(node.home);
const away = slotDisplay(node.away);
const [scoreHome, scoreAway] = (existing?.score ?? '').split('-');
let selected = existing?.winner ?? null;
const teamButton = (slot, display) => `
<button class="sim-team ${selected === slot.teamId ? 'active' : ''}" data-team="${slot.teamId}">
<img class="flag" src="${flagSrc(display.team)}" alt="" width="34" height="23">
<span>${display.label}</span>
</button>`;
dialog.innerHTML = `
<div class="modal-content">
<header class="modal-top">
<p class="modal-phase">${t('sim.title')}${translatePhase(node.phase)}</p>
<button class="modal-close" data-close aria-label="${t('modal.close')}">✕</button>
</header>
<p class="sim-hint">${t('sim.pickWinner')}</p>
<div class="sim-teams">
${teamButton(node.home, home)}
${teamButton(node.away, away)}
</div>
<div class="sim-scores">
<label>${home.label}
<input class="filter-control" type="number" id="sim-score-home" min="0" max="99"
inputmode="numeric" value="${scoreHome ?? ''}">
</label>
<label>${away.label}
<input class="filter-control" type="number" id="sim-score-away" min="0" max="99"
inputmode="numeric" value="${scoreAway ?? ''}">
</label>
</div>
<div class="sim-actions">
<button class="link-btn" id="sim-clear" ${existing ? '' : 'hidden'}>${t('sim.clear')}</button>
<button class="btn-primary" id="sim-save" ${selected ? '' : 'disabled'}>${t('sim.save')}</button>
</div>
</div>`;
const saveBtn = dialog.querySelector('#sim-save');
const homeInput = dialog.querySelector('#sim-score-home');
const awayInput = dialog.querySelector('#sim-score-away');
const select = (teamId) => {
selected = teamId;
for (const btn of dialog.querySelectorAll('.sim-team')) {
btn.classList.toggle('active', btn.dataset.team === teamId);
}
saveBtn.disabled = !selected;
};
for (const btn of dialog.querySelectorAll('.sim-team')) {
btn.addEventListener('click', () => select(btn.dataset.team));
}
// an unequal score implies the winner; a draw needs an explicit pick (pens)
const onScoreInput = () => {
const h = homeInput.value === '' ? null : Number(homeInput.value);
const a = awayInput.value === '' ? null : Number(awayInput.value);
if (h === null || a === null || h === a) return;
select(h > a ? node.home.teamId : node.away.teamId);
};
homeInput.addEventListener('input', onScoreInput);
awayInput.addEventListener('input', onScoreInput);
dialog.setAttribute('aria-label', `${t('sim.title')}${home.label} ${t('hero.vs')} ${away.label}`);
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
dialog.querySelector('#sim-clear').addEventListener('click', () => {
const simulation = getSimulation();
delete simulation[ref];
storageSet('simulation', simulation);
dialog.close();
refreshAfterSimChange();
});
saveBtn.addEventListener('click', () => {
if (!selected) return;
const h = homeInput.value === '' ? (selected === node.home.teamId ? 1 : 0) : Number(homeInput.value);
const a = awayInput.value === '' ? (selected === node.away.teamId ? 1 : 0) : Number(awayInput.value);
const simulation = getSimulation();
simulation[ref] = { winner: selected, score: `${h}-${a}` };
storageSet('simulation', simulation);
dialog.close();
refreshAfterSimChange();
});
dialog.showModal();
}
// ------------------------------------------------- interactions (step 8)
const ROUND_ORDER = ['R32', 'R16', 'QF', 'SF', 'FINAL'];
// zoom level survives re-renders (language switch) but not reloads
const view = { scale: 1, natW: 0, natH: 0 };
const MIN_SCALE = 0.4;
const MAX_SCALE = 2;
function initInteractions() {
const wrap = document.getElementById('bracket-wrap');
const zoomBox = document.getElementById('bracket-zoom');
const canvas = document.getElementById('bracket-canvas');
view.natW = 0; // re-measure lazily — panel may be hidden right now (offsetWidth 0)
if (view.scale !== 1) applyScale(wrap, zoomBox, canvas);
updateZoomLabel();
const ensureMeasured = () => {
if (view.natW) return true;
if (!canvas.offsetWidth) return false;
view.natW = canvas.offsetWidth;
view.natH = canvas.offsetHeight;
return true;
};
const setScale = (next, cx, cy) => {
if (!ensureMeasured()) return;
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, next));
if (scale === view.scale) return;
const rect = wrap.getBoundingClientRect();
const px = (cx - rect.left + wrap.scrollLeft) / view.scale;
const py = (cy - rect.top + wrap.scrollTop) / view.scale;
view.scale = scale;
applyScale(wrap, zoomBox, canvas);
wrap.scrollLeft = px * scale - (cx - rect.left);
wrap.scrollTop = py * scale - (cy - rect.top);
updateZoomLabel();
};
const zoomAtCenter = (factor) => {
const rect = wrap.getBoundingClientRect();
setScale(view.scale * factor, rect.left + rect.width / 2, rect.top + rect.height / 2);
};
document.getElementById('zoom-in').addEventListener('click', () => zoomAtCenter(1.25));
document.getElementById('zoom-out').addEventListener('click', () => zoomAtCenter(1 / 1.25));
document.getElementById('zoom-reset').addEventListener('click', () => {
if (!ensureMeasured()) return;
view.scale = 1;
applyScale(wrap, zoomBox, canvas);
updateZoomLabel();
});
wrap.addEventListener('wheel', (event) => {
event.preventDefault();
setScale(view.scale * (event.deltaY < 0 ? 1.12 : 1 / 1.12), event.clientX, event.clientY);
}, { passive: false });
// drag to pan (1 pointer) + pinch to zoom (2 pointers)
const pointers = new Map();
let dragging = false;
let dragMoved = false;
let start = null;
let pinch = null;
wrap.addEventListener('pointerdown', (event) => {
pointers.set(event.pointerId, event);
// IMPORTANT: do NOT setPointerCapture here — capturing retargets the
// eventual click to the wrap, which kills match-node clicks (modal and
// simulation). Capture only once a real drag/pinch begins.
if (pointers.size === 1) {
dragging = true;
dragMoved = false;
start = { x: event.clientX, y: event.clientY, left: wrap.scrollLeft, top: wrap.scrollTop };
} else if (pointers.size === 2) {
dragging = false;
for (const p of pointers.values()) {
try { wrap.setPointerCapture(p.pointerId); } catch { /* pointer already gone */ }
}
const [a, b] = [...pointers.values()];
pinch = { dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), scale: view.scale };
}
});
wrap.addEventListener('pointermove', (event) => {
if (!pointers.has(event.pointerId)) return;
pointers.set(event.pointerId, event);
if (pointers.size === 2 && pinch) {
const [a, b] = [...pointers.values()];
const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
setScale(pinch.scale * (dist / pinch.dist), (a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2);
} else if (dragging) {
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
if (!dragMoved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
dragMoved = true;
try { wrap.setPointerCapture(event.pointerId); } catch { /* pointer already gone */ }
wrap.classList.add('dragging');
}
if (dragMoved) {
wrap.scrollLeft = start.left - dx;
wrap.scrollTop = start.top - dy;
}
}
});
const endPointer = (event) => {
pointers.delete(event.pointerId);
if (pointers.size < 2) pinch = null;
if (pointers.size === 0) {
dragging = false;
wrap.classList.remove('dragging');
}
};
wrap.addEventListener('pointerup', endPointer);
wrap.addEventListener('pointercancel', endPointer);
// a real drag must not fire the match-node click underneath
wrap.addEventListener('click', (event) => {
if (dragMoved) {
event.stopPropagation();
event.preventDefault();
dragMoved = false;
}
}, true);
// hover/focus path highlight
canvas.addEventListener('mouseover', (event) => {
const node = event.target.closest('.bracket-match');
if (node) showPath(node.dataset.ref);
});
canvas.addEventListener('mouseout', () => clearPath());
canvas.addEventListener('focusin', (event) => {
const node = event.target.closest('.bracket-match');
if (node) showPath(node.dataset.ref);
});
canvas.addEventListener('focusout', () => clearPath());
}
function applyScale(wrap, zoomBox, canvas) {
if (view.natW) {
zoomBox.style.width = `${view.natW * view.scale}px`;
zoomBox.style.height = `${view.natH * view.scale}px`;
}
canvas.style.transform = view.scale === 1 ? '' : `scale(${view.scale})`;
if (view.scale === 1) {
zoomBox.style.width = '';
zoomBox.style.height = '';
}
}
function updateZoomLabel() {
const label = document.getElementById('zoom-reset');
if (label) label.textContent = `${Math.round(view.scale * 100)}%`;
}
// ----------------------------------------------------- path highlight
function refOf(roundIndex, nodeIndex) {
const id = ROUND_ORDER[roundIndex];
return id === 'FINAL' ? 'FINAL' : `${id}-${nodeIndex + 1}`;
}
// full path = the hovered node, every feeder below it, and its winner's
// route up to the final
function pathRefs(ref) {
const refs = new Set([ref]);
if (ref === 'THIRD-PLACE') {
refs.add('SF-1');
refs.add('SF-2');
return refs;
}
const [roundId, num] = ref === 'FINAL' ? ['FINAL', '1'] : ref.split('-');
const r = ROUND_ORDER.indexOf(roundId);
if (r < 0) return refs;
let i = Number(num) - 1;
for (let rr = r, ii = i; rr < ROUND_ORDER.length - 1; rr += 1) {
ii = Math.floor(ii / 2);
refs.add(refOf(rr + 1, ii));
}
const stack = [[r, i]];
while (stack.length) {
const [cr, ci] = stack.pop();
if (cr === 0) continue;
for (const child of [ci * 2, ci * 2 + 1]) {
refs.add(refOf(cr - 1, child));
stack.push([cr - 1, child]);
}
}
return refs;
}
function showPath(ref) {
const canvas = document.getElementById('bracket-canvas');
clearPath();
canvas.classList.add('has-path');
for (const r of pathRefs(ref)) {
const el = canvas.querySelector(`[data-ref="${r}"]`);
if (!el) continue;
el.classList.add('path-on');
el.closest('.bracket-slot')?.classList.add('path-on');
el.closest('.bracket-pair')?.classList.add('path-on');
}
}
function clearPath() {
const canvas = document.getElementById('bracket-canvas');
canvas.classList.remove('has-path');
for (const el of canvas.querySelectorAll('.path-on')) el.classList.remove('path-on');
}
function teamRowHTML(display, node, side) {
const { result } = node;
let score = '';
if (result && result.status !== 'scheduled' && node.home.teamId && node.away.teamId) {
const goals = side === 'home' ? result.homeScore : result.awayScore;
const pens = result.penalties
? `<small>(${side === 'home' ? result.penalties.home : result.penalties.away})</small>`
: '';
score = `<span class="bt-score">${goals}${pens}</span>`;
} else if (node.simulated && node.simScore) {
score = `<span class="bt-score sim">${node.simScore[side]}</span>`;
}
const isWinner = node.winner !== null && node[side].teamId === node.winner;
const flag = display.team
? `<img class="flag" src="${flagSrc(display.team)}" alt="" width="20" height="14" loading="lazy">`
: '<span class="bt-flag-ph" aria-hidden="true"></span>';
return `
<div class="bracket-team ${isWinner ? 'winner' : ''} ${display.team ? '' : 'tbd'}">
${flag}
<span class="bt-name">${display.label}</span>
${score}
</div>`;
}