mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
782 lines
29 KiB
JavaScript
782 lines
29 KiB
JavaScript
// 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>`;
|
||
}
|