// 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, matchDateUTC } from './app.js'; import { get as storageGet, set as storageSet, getFavorites } from './storage.js'; import { computeStandings, isGroupFinished } from './groups.js'; import { t, translatePhase, getLocale } 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); document.addEventListener('datachange', render); // tree already invalidated by the poll → rebuilds document.addEventListener('timemodechange', render); // kickoff microlines follow the Local/Stadium toggle // no explicit view choice → default follows the breakpoint (mobile = rounds pager) window.matchMedia('(max-width: 767px)').addEventListener('change', () => { if (!VIEW_IDS.includes(storageGet('prefs', {}).bracketView)) 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]) => `${translatePhase(phase)}: ${score}`) .join(''); const summary = t('challenge.correct') .replace('{x}', challenge.correct) .replace('{y}', challenge.total); return `
${t('challenge.title')} ${summary} ${phases}
`; } // ------------------------------------------- wallchart layout engine // Center-out "wallchart": R32 1–8 flow left→center, 9–16 right→center, // with the Final + champion centerpiece in the middle column and the // third-place match beneath it. All positions are computed here (absolute // px inside the canvas); connectors are generated as SVG bezier paths from // the same numbers, so cards and lines can never drift apart. This engine // replaced the old flex-column + CSS-stub system (its equal-height // invariant could not express a mirrored right half). const GEO = { gap: 42, // horizontal gap between adjacent columns centerGap: 46, // SF column ↔ center column rowGap: 20, // vertical gap between R32 cards padX: 28, padTop: 62, // leaves room for the round title row padBottom: 30, tiers: { R32: { w: 184, h: 78 }, R16: { w: 196, h: 82 }, QF: { w: 208, h: 88 }, SF: { w: 220, h: 94 }, FINAL: { w: 264, h: 142 }, THIRD: { w: 208, h: 88 }, }, championH: 118, championGap: 20, // champion box ↔ final card thirdGap: 26, // final card ↔ third-place title thirdTitleH: 22, }; function refName(roundId, index) { return roundId === 'FINAL' ? 'FINAL' : `${roundId}-${index + 1}`; } function computeWallchartLayout() { const G = GEO; const { R32: t32, R16: t16, QF: tQF, SF: tSF, FINAL: tF, THIRD: tT } = G.tiers; const cards = new Map(); // ref → { x, y, w, h, tier, side } // column x positions, left → right (right half mirrors the left) const xL32 = G.padX; const xL16 = xL32 + t32.w + G.gap; const xLQF = xL16 + t16.w + G.gap; const xLSF = xLQF + tQF.w + G.gap; const xC = xLSF + tSF.w + G.centerGap; const xRSF = xC + tF.w + G.centerGap; const xRQF = xRSF + tSF.w + G.gap; const xR16 = xRQF + tQF.w + G.gap; const xR32 = xR16 + t16.w + G.gap; const w = xR32 + t32.w + G.padX; const h = G.padTop + 8 * t32.h + 7 * G.rowGap + G.padBottom; for (let i = 0; i < 16; i += 1) { const left = i < 8; const row = left ? i : i - 8; cards.set(refName('R32', i), { x: left ? xL32 : xR32, y: G.padTop + row * (t32.h + G.rowGap), w: t32.w, h: t32.h, tier: 'R32', side: left ? 'L' : 'R', }); } // each later node sits at the vertical midpoint of its two feeders const place = (roundId, prevId, count, tier, xLeft, xRight) => { for (let i = 0; i < count; i += 1) { const a = cards.get(refName(prevId, i * 2)); const b = cards.get(refName(prevId, i * 2 + 1)); const cy = (a.y + a.h / 2 + b.y + b.h / 2) / 2; const left = i < count / 2; cards.set(refName(roundId, i), { x: left ? xLeft : xRight, y: cy - tier.h / 2, w: tier.w, h: tier.h, tier: roundId, side: left ? 'L' : 'R', }); } }; place('R16', 'R32', 8, t16, xL16, xR16); place('QF', 'R16', 4, tQF, xLQF, xRQF); place('SF', 'QF', 2, tSF, xLSF, xRSF); const sf1 = cards.get('SF-1'); const sf2 = cards.get('SF-2'); const midY = (sf1.y + sf1.h / 2 + sf2.y + sf2.h / 2) / 2; const final = { x: xC, y: midY - tF.h / 2, w: tF.w, h: tF.h, tier: 'FINAL', side: 'C' }; cards.set('FINAL', final); const champion = { x: xC, y: final.y - G.championGap - G.championH, w: tF.w, h: G.championH, }; const thirdTitle = { x: xC + (tF.w - tT.w) / 2, y: final.y + tF.h + G.thirdGap, w: tT.w }; cards.set('THIRD-PLACE', { x: thirdTitle.x, y: thirdTitle.y + G.thirdTitleH, w: tT.w, h: tT.h, tier: 'THIRD', side: 'C', }); // connectors: two bezier flow lines into every non-R32 node const links = []; const addLink = (fromRef, toRef) => { const a = cards.get(fromRef); const b = cards.get(toRef); const rightward = a.side === 'L'; // left half flows →, right half flows ← const x1 = rightward ? a.x + a.w : a.x; const x2 = rightward ? b.x : b.x + b.w; const y1 = a.y + a.h / 2; const y2 = b.y + b.h / 2; const mx = (x1 + x2) / 2; links.push({ from: fromRef, to: toRef, tier: b.tier, d: `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`, }); }; for (const [roundId, prevId, count] of [['R16', 'R32', 8], ['QF', 'R16', 4], ['SF', 'QF', 2], ['FINAL', 'SF', 1]]) { for (let i = 0; i < count; i += 1) { addLink(refName(prevId, i * 2), refName(roundId, i)); addLink(refName(prevId, i * 2 + 1), refName(roundId, i)); } } // glowing stem from the final card up into the champion box const stemX = xC + tF.w / 2; const stem = `M ${stemX} ${final.y} L ${stemX} ${champion.y + champion.h}`; const ty = G.padTop - 40; const labels = [ { x: xL32, y: ty, w: t32.w, tier: 'R32', phase: 'Round of 32' }, { x: xL16, y: ty, w: t16.w, tier: 'R16', phase: 'Round of 16' }, { x: xLQF, y: ty, w: tQF.w, tier: 'QF', phase: 'Quarterfinals' }, { x: xLSF, y: ty, w: tSF.w, tier: 'SF', phase: 'Semifinals' }, { x: xC, y: ty, w: tF.w, tier: 'FINAL', phase: 'Final' }, { x: xRSF, y: ty, w: tSF.w, tier: 'SF', phase: 'Semifinals' }, { x: xRQF, y: ty, w: tQF.w, tier: 'QF', phase: 'Quarterfinals' }, { x: xR16, y: ty, w: t16.w, tier: 'R16', phase: 'Round of 16' }, { x: xR32, y: ty, w: t32.w, tier: 'R32', phase: 'Round of 32' }, ]; const scenery = { type: 'pitch', cx: stemX, cy: final.y + tF.h / 2, r: Math.min(230, (h - G.padTop) / 2.6) }; return { id: 'wallchart', w, h, cards, links, stem, labels, scenery, champion, thirdTitle }; } // --------------------------------------------- radial layout engine // Second chart view — the "orbit": circular flag tokens on concentric // rings converging on the trophy. Outermost ring = the 32 entrants; each // ring inward holds the winner slots of a round (a match's winner slot // doubles as its next match's participant). Elbow lines trace each team's // route; gold = real advancement, dashed blue = simulated pick. Clicking // any token opens its match (winner slots = the match they decide), so the // modal / sim-editor delegation is unchanged. Names and scores live in the // app tooltip (has-tip/data-tip) and the modal. const TGEO = { pad: 40, outerR: 388, // 32 entrant tokens outerD: 44, winners: { // ring where each round's winner slot sits (its diameter grows inward) R32: { r: 322, d: 48 }, R16: { r: 252, d: 54 }, QF: { r: 178, d: 60 }, SF: { r: 96, d: 68 }, // = the two finalists flanking the trophy }, championD: 84, glowR: 190, // radius of the gold glow pool behind the trophy thirdDy: 64, // third-place group below the circle thirdGap: 72, // distance between the two third-place tokens thirdD: 48, }; function computeRadialLayout() { const G = TGEO; const R = G.outerR + G.outerD / 2 + 8; // circle's outer edge const cx = G.pad + R; const cy = G.pad + R; const w = (G.pad + R) * 2; const thirdY = cy + R + G.thirdDy; const h = thirdY + G.thirdD / 2 + G.pad; return { id: 'radial', w, h, cx, cy, thirdY }; } // ------------------------------------------------------------- render // compact kickoff line ("Jul 4, 17:00") honoring the Local/Stadium toggle — // formatMatchTime()'s medium dateStyle (with year) is too wide for the cards function kickoffShort(match) { const stadium = getData().stadiumByName.get(match.stadium); const mode = storageGet('prefs', {}).timeMode ?? 'local'; const options = { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }; if (mode === 'stadium' && stadium?.timezone) options.timeZone = stadium.timezone; return new Intl.DateTimeFormat(getLocale(), options).format(matchDateUTC(match)); } // ------------------------------------------------------ view switching // Three views: 'rounds' (swipeable pager, mobile default), 'wallchart' // (desktop default) and 'radial'. Explicit choice persists in // wc2026_prefs.bracketView; without one the default follows the breakpoint. const VIEW_IDS = ['rounds', 'wallchart', 'radial']; function activeView() { const stored = storageGet('prefs', {}).bracketView; if (VIEW_IDS.includes(stored)) return stored; return window.matchMedia('(max-width: 767px)').matches ? 'rounds' : 'wallchart'; } function setBracketView(id) { const prefs = storageGet('prefs', {}); prefs.bracketView = id; storageSet('prefs', prefs); } function viewSwitchHTML(active) { const labels = { rounds: t('bracket.viewRounds'), wallchart: t('bracket.viewWallchart'), radial: t('bracket.viewRadial'), }; return `
${VIEW_IDS.map((id) => ` `).join('')}
`; } function render() { const tree = getBracketTree(); const simulation = getSimulation(); const challenge = calculateChallengeScore(simulation, getData().results, tree); const hasPicks = Object.keys(simulation).length > 0; const viewId = activeView(); const chart = viewId !== 'rounds'; const L = chart ? (viewId === 'radial' ? computeRadialLayout() : computeWallchartLayout()) : null; // the fit observer from the previous render watches removed DOM — reset if (fitRO) { fitRO.disconnect(); fitRO = null; } document.getElementById('bracket-root').innerHTML = ` ${challenge.total ? challengeCardHTML(challenge) : ''}
${viewSwitchHTML(viewId)}
${chart ? `
` : ''}
${simMode ? `

${t('sim.hint')}

` : ''} ${chart ? chartHTML(tree, L, viewId) : pagerHTML(tree)}`; for (const btn of document.querySelectorAll('#bracket-root .view-btn')) { btn.addEventListener('click', () => { if (btn.dataset.view === viewId) return; setBracketView(btn.dataset.view); render(); }); } 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 } }); if (chart) initInteractions(L); else initPager(tree); } function chartHTML(tree, L, viewId) { const inner = viewId === 'radial' ? radialInnerHTML(tree, L) : wallchartInnerHTML(tree, L); return `
${inner}
`; } function wallchartInnerHTML(tree, L) { let cardsHTML = ''; for (const round of tree.rounds) { for (const node of round.nodes) cardsHTML += matchNodeHTML(node, L.cards.get(node.ref)); } cardsHTML += matchNodeHTML(tree.third, L.cards.get('THIRD-PLACE')); return ` ${sceneryHTML(L)} ${linksHTML(L)} ${L.labels.map((lb) => ` ${translatePhase(lb.phase)}`).join('')} ${cardsHTML} ${centerHTML(tree, L)}`; } // -------------------------------------------------- radial (orbit) view // short tooltip line for a match: score when played, kickoff otherwise function matchTip(node) { if (!node.match) return ''; const home = slotDisplay(node.home); const away = slotDisplay(node.away); const { result } = node; if (result && result.status !== 'scheduled' && node.home.teamId && node.away.teamId) { const pens = result.penalties ? ` (${result.penalties.home}-${result.penalties.away})` : ''; return `${home.label} ${result.homeScore}–${result.awayScore} ${away.label}${pens}`; } if (node.simulated && node.simScore) { return `${home.label} ${node.simScore.home}–${node.simScore.away} ${away.label} · ${t('sim.chip')}`; } return `${home.label} ${t('hero.vs')} ${away.label} · ${kickoffShort(node.match)}`; } function radialTokenHTML(pos, d, display, node, opts = {}) { const classes = [ 'bk-token', opts.ring ? `tk-${opts.ring}` : '', display.team ? '' : 'tk-tbd', opts.out ? 'tk-out' : '', opts.winner ? (node.simulated ? 'tk-sim' : 'tk-winner') : '', opts.live ? 'tk-live' : '', opts.fav ? 'tk-fav' : '', opts.slot && simMode && isSimulatable(node) ? 'simulatable' : '', ].filter(Boolean).join(' '); const tip = opts.slot ? matchTip(node) : display.label; const interactive = node.match ? `data-match-id="${node.match.id}" tabindex="0" role="button" aria-label="${matchTip(node) || display.label} — ${translatePhase(node.phase)}"` : ''; const flag = display.team ? `` : ''; return `
${flag}
`; } function radialInnerHTML(tree, L) { const G = TGEO; const { cx, cy } = L; const favorites = getFavorites(); const roundsById = new Map(tree.rounds.map((r) => [r.id, r])); const polar = (slot, n, r) => { const th = ((slot + 0.5) / n) * 2 * Math.PI; return { x: cx + r * Math.sin(th), y: cy - r * Math.cos(th), th, r }; }; const tokens = []; const lines = []; const dots = []; // elbow: out of the participant radially, then straight to the winner slot const addLine = (from, to, node, sideTeamId, fromRef) => { const rMid = to.r != null ? to.r + (from.r - (to.r ?? 0)) * 0.5 : from.r * 0.5; const bend = { x: cx + rMid * Math.sin(from.th), y: cy - rMid * Math.cos(from.th) }; const adv = Boolean(node.winner && node.winner === sideTeamId); const state = adv ? (node.simulated ? 'is-sim' : 'is-adv') : ''; lines.push(` `); dots.push(``); }; // entrants (outer ring) + one winner slot per match, ring by ring inward const winnerPos = new Map(); roundsById.get('R32').nodes.forEach((node, m) => { const wPos = polar(m, 16, G.winners.R32.r); winnerPos.set(node.ref, wPos); [['home', 2 * m], ['away', 2 * m + 1]].forEach(([side, slot]) => { const pos = polar(slot, 32, G.outerR); const teamId = node[side].teamId; tokens.push(radialTokenHTML(pos, G.outerD, slotDisplay(node[side]), node, { ring: 'ent', out: Boolean(node.winner && teamId && node.winner !== teamId), fav: favorites.includes(teamId), })); addLine(pos, wPos, node, teamId, node.ref); }); }); const winnerSlot = (node, ring, pos) => { const display = node.winner ? slotDisplay({ teamId: node.winner }) : { team: null, label: t('app.tbd') }; return radialTokenHTML(pos, G.winners[ring].d, display, node, { ring: ring.toLowerCase(), slot: true, // this token IS the match's winner slot — sim affordance lives here winner: Boolean(node.winner), live: node.result?.status === 'live', fav: Boolean(node.winner) && favorites.includes(node.winner), }); }; roundsById.get('R32').nodes.forEach((node) => tokens.push(winnerSlot(node, 'R32', winnerPos.get(node.ref)))); for (const [roundId, prevId, n, ring] of [['R16', 'R32', 8, 'R16'], ['QF', 'R16', 4, 'QF'], ['SF', 'QF', 2, 'SF']]) { roundsById.get(roundId).nodes.forEach((node, i) => { const wPos = polar(i, n, G.winners[ring].r); winnerPos.set(node.ref, wPos); for (const [side, childRef] of [['home', refName(prevId, i * 2)], ['away', refName(prevId, i * 2 + 1)]]) { addLine(winnerPos.get(childRef), wPos, node, node[side].teamId, childRef); } tokens.push(winnerSlot(node, ring, wPos)); }); } // trophy center = the FINAL's winner slot const finalNode = tree.nodesByRef.get('FINAL'); const centerPos = { x: cx, y: cy, th: 0, r: 0 }; addLine(winnerPos.get('SF-1'), centerPos, finalNode, finalNode.home.teamId, 'SF-1'); addLine(winnerPos.get('SF-2'), centerPos, finalNode, finalNode.away.teamId, 'SF-2'); const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null; const simChampion = Boolean(tree.champion && finalNode.simulated); const centerTip = matchTip(finalNode); const centerHTMLStr = `
${simChampion ? `${t('sim.chip')}` : ''} ${champion?.team ? `` : ''} ${champion ? `${champion.label}` : ''}
`; // third place: a small labeled pair below the circle (SF losers) const third = tree.third; const thirdTip = matchTip(third); const thirdToken = (side) => { const display = slotDisplay(third[side]); const win = Boolean(third.winner && third[side].teamId === third.winner); const flag = display.team ? `` : ''; return ` ${flag}`; }; const thirdHTML = `
${translatePhase('Third Place')} ${thirdToken('home')}${thirdToken('away')}
`; const orbits = [G.outerR, G.winners.R32.r, G.winners.R16.r, G.winners.QF.r] .map((r) => ``).join(''); return ` ${tokens.join('')} ${centerHTMLStr} ${thirdHTML}`; } // faint pitch geometry echoing the wallchart layout: halfway line through // the center column, center circle around the Final — the stadium floor. // (The radial view draws its own orbit scenery inside radialInnerHTML.) function sceneryHTML(L) { const { cx, cy, r } = L.scenery; return ` `; } function linksHTML(L) { const paths = L.links.map((link) => ` `).join(''); return ` `; } // champion centerpiece + third-place block (center column). // A simulated champion renders in the sim-blue treatment with a SIM chip — // a user's picks must never be mistakable for a real result (same rule the // stats verdict follows). function centerHTML(tree, L) { const finalNode = tree.nodesByRef.get('FINAL'); const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null; const simChampion = Boolean(tree.champion && finalNode?.simulated); const flag = champion?.team ? `` : ''; const c = L.champion; const tt = L.thirdTitle; return `
${simChampion ? `${t('sim.chip')}` : ''} ${t('bracket.champion')} ${champion ? `${flag}${champion.label}` : t('app.tbd')}
${translatePhase('Third Place')}`; } function matchNodeHTML(node, pos) { 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', `bk-${pos.tier.toLowerCase()}`, 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 `
${node.simulated ? `${t('sim.chip')}` : ''}
${teamRowHTML(home, node, 'home')} ${teamRowHTML(away, node, 'away')}
${metaHTML(node)}
`; } // status fragment shared by chart microlines and pager cards function statusLine(node) { const status = node.result?.status; if (status === 'live') return `${t('hero.live')}`; if (status === 'finished') return `${t('bracket.ft')}`; return node.match ? `${kickoffShort(node.match)}` : ''; } // microline at the foot of a card: kickoff (upcoming) / LIVE pulse / FT. // The Final's hero card also carries its venue. function metaHTML(node) { if (!node.match) return ''; const venue = node.ref === 'FINAL' ? `${node.match.stadium} · ${node.match.city}` : ''; return `
${venue}${statusLine(node)}
`; } // ------------------------------------------------------- rounds pager // Round-by-round view: chip buttons switch the visible page (R32 → R16 → // QF → SF → Finals) — button navigation only, no horizontal scrolling. // Cards are the fuller variant (venue + city). Same data-ref/data-match-id // contract, so the root delegation (modal + sim editor) works unchanged. let pagerIndex = null; // survives re-renders (langchange etc.), not reloads const PAGER_PAGES = [ { id: 'R32', phase: 'Round of 32' }, { id: 'R16', phase: 'Round of 16' }, { id: 'QF', phase: 'Quarterfinals' }, { id: 'SF', phase: 'Semifinals' }, { id: 'FINALS', phase: 'Final' }, ]; // first round that still has an unfinished match — the page worth opening on function firstOpenPage(tree) { for (let i = 0; i < 4; i += 1) { const round = tree.rounds.find((r) => r.id === PAGER_PAGES[i].id); if (round.nodes.some((n) => n.result?.status !== 'finished')) return i; } return 4; } function pcardHTML(node, hero = false) { 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 = [ 'bk-pcard', hero ? 'bk-pcard-hero' : '', 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)}"` : ''; const top = node.match ? `
${node.match.stadium} · ${node.match.city} ${statusLine(node)}
` : ''; return `
${node.simulated ? `${t('sim.chip')}` : ''} ${top} ${teamRowHTML(home, node, 'home')} ${teamRowHTML(away, node, 'away')}
`; } function pagerFinalsHTML(tree) { const finalNode = tree.nodesByRef.get('FINAL'); const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null; const simChampion = Boolean(tree.champion && finalNode?.simulated); const flag = champion?.team ? `` : ''; return `
${simChampion ? `${t('sim.chip')}` : ''} ${t('bracket.champion')} ${champion ? `${flag}${champion.label}` : t('app.tbd')}
${pcardHTML(finalNode, true)}

${translatePhase('Third Place')}

${pcardHTML(tree.third)}`; } function pagerHTML(tree) { const roundsById = new Map(tree.rounds.map((r) => [r.id, r])); const chips = PAGER_PAGES.map((page, i) => ` `).join(''); const pages = PAGER_PAGES.map((page) => { const body = page.id === 'FINALS' ? `
${pagerFinalsHTML(tree)}
` : `
${roundsById.get(page.id).nodes.map((n) => pcardHTML(n)).join('')}
`; return `
${body}
`; }).join(''); return `
${pages}
`; } function initPager(tree) { const pages = [...document.querySelectorAll('#bk-pages .bk-page')]; const chips = [...document.querySelectorAll('#bk-pager-nav .bk-page-btn')]; if (pagerIndex === null) pagerIndex = firstOpenPage(tree); const show = (i) => { pagerIndex = Math.max(0, Math.min(PAGER_PAGES.length - 1, i)); pages.forEach((page, k) => { page.hidden = k !== pagerIndex; }); chips.forEach((chip, k) => { chip.classList.toggle('active', k === pagerIndex); chip.setAttribute('aria-pressed', String(k === pagerIndex)); }); }; chips.forEach((chip, i) => chip.addEventListener('click', () => show(i))); show(pagerIndex); } // ----------------------------------------------------- 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', '', ); 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) => ` `; dialog.innerHTML = ` `; 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 state survives re-renders (language switch) but not reloads. // The fit scale (whole chart visible) is the resting point and the zoom // label's "100%"; it's recomputed by a ResizeObserver because the panel is // usually hidden when render() first runs (clientWidth 0 — gotcha class). const view = { scale: 1, fit: 0, natW: 0, natH: 0, userZoomed: false }; const MAX_SCALE = 2; let fitRO = null; function initInteractions(layout) { const wrap = document.getElementById('bracket-wrap'); const zoomBox = document.getElementById('bracket-zoom'); const canvas = document.getElementById('bracket-canvas'); if (view.layoutId !== layout.id) { // wallchart ↔ radial: fresh fit, not inherited zoom view.layoutId = layout.id; view.userZoomed = false; view.fit = 0; } view.natW = layout.w; // geometry is computed, not measured — always known view.natH = layout.h; if (view.fit) { if (!view.userZoomed) view.scale = view.fit; applyScale(wrap, zoomBox, canvas); } updateZoomLabel(); if (fitRO) fitRO.disconnect(); fitRO = new ResizeObserver(() => { const cw = wrap.clientWidth; const ch = wrap.clientHeight; if (cw <= 0 || ch <= 0) return; // panel hidden — wait for the tab to open view.fit = Math.min(cw / view.natW, ch / view.natH, 1); if (!view.userZoomed || view.scale < view.fit) view.scale = Math.max(view.fit, view.userZoomed ? view.scale : 0); if (!view.userZoomed) view.scale = view.fit; applyScale(wrap, zoomBox, canvas); updateZoomLabel(); }); fitRO.observe(wrap); const setScale = (next, cx, cy) => { if (!view.fit) return; const scale = Math.min(MAX_SCALE, Math.max(view.fit, 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; view.userZoomed = true; 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 (!view.fit) return; view.scale = view.fit; view.userZoomed = false; 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 — any ref-carrying element (card or token) canvas.addEventListener('mouseover', (event) => { const node = event.target.closest('[data-ref]'); if (node) showPath(node.dataset.ref); }); canvas.addEventListener('mouseout', () => clearPath()); canvas.addEventListener('focusin', (event) => { const node = event.target.closest('[data-ref]'); if (node) showPath(node.dataset.ref); }); canvas.addEventListener('focusout', () => clearPath()); } function applyScale(wrap, zoomBox, canvas) { zoomBox.style.width = `${view.natW * view.scale}px`; zoomBox.style.height = `${view.natH * view.scale}px`; canvas.style.transform = `scale(${view.scale})`; } // 100% = the whole-chart fit, not natural card size function updateZoomLabel() { const label = document.getElementById('zoom-reset'); if (label) label.textContent = `${Math.round((view.scale / (view.fit || 1)) * 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'); const refs = pathRefs(ref); for (const r of refs) { // cards (wallchart), tokens/center/third group (radial) — anything owning the ref for (const el of canvas.querySelectorAll(`[data-ref="${r}"]`)) el.classList.add('path-on'); } // a connector lights up when both of its endpoints are on the path // (the champion stem carries FINAL→FINAL, so it follows the final) for (const path of canvas.querySelectorAll('.bk-links path')) { if (refs.has(path.dataset.from) && refs.has(path.dataset.to)) path.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 ? `(${side === 'home' ? result.penalties.home : result.penalties.away})` : ''; score = `${goals}${pens}`; } else if (node.simulated && node.simScore) { score = `${node.simScore[side]}`; } const isWinner = node.winner !== null && node[side].teamId === node.winner; const flag = display.team ? `` : ''; return `
${flag} ${display.label} ${score}
`; }