world-2026-hub/assets/js/bracket.js
Lucas Kalil c246117545 feat(bracket): redesign with wallchart, radial orbit, and rounds pager
Center-out wallchart layout (computeWallchartLayout), radial orbit view with
circular flag tokens, and mobile rounds pager with button navigation (Steps 1–3).
Stadium-night art direction, SVG connectors, fit-to-chart zoom, escalating card
heat toward the Final, dual gold-real/blue-sim champion celebration paths.
Adds bracket view toggle with persistent wc2026_prefs.bracketView storage.
2026-07-03 22:09:49 -03:00

1381 lines
55 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, 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]) => `<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>`;
}
// ------------------------------------------- wallchart layout engine
// Center-out "wallchart": R32 18 flow left→center, 916 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 `
<div class="bk-viewswitch" role="group" aria-label="${t('bracket.viewLabel')}">
${VIEW_IDS.map((id) => `
<button class="view-btn ${id === active ? 'active' : ''}" data-view="${id}"
aria-pressed="${id === active}">${labels[id]}</button>`).join('')}
</div>`;
}
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) : ''}
<div class="bracket-toolbar">
<div class="bracket-tools-left">
${viewSwitchHTML(viewId)}
<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>
${chart ? `
<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>` : ''}
${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 `
<div class="bracket-wrap" id="bracket-wrap" style="aspect-ratio:${L.w}/${L.h}">
<div class="bracket-zoom" id="bracket-zoom">
<div class="bracket ${viewId} ${simMode ? 'sim-on' : ''}" id="bracket-canvas"
style="width:${L.w}px;height:${L.h}px">
${inner}
</div>
</div>
</div>`;
}
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) => `
<span class="bk-title bk-title-${lb.tier.toLowerCase()}"
style="left:${lb.x}px;top:${lb.y}px;width:${lb.w}px">
${translatePhase(lb.phase)}</span>`).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
? `<img src="${flagSrc(display.team)}" alt="" width="${d}" height="${d}" loading="lazy">`
: '';
return `
<article class="${classes} ${tip ? 'has-tip' : ''}" data-ref="${node.ref}" ${interactive}
${tip ? `data-tip="${tip}"` : ''}
style="left:${pos.x - d / 2}px;top:${pos.y - d / 2}px;width:${d}px;height:${d}px">
${flag}
</article>`;
}
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(`
<path class="bk-le ${state}" pathLength="1" data-from="${fromRef}" data-to="${node.ref}"
d="M ${from.x} ${from.y} L ${bend.x} ${bend.y} L ${to.x} ${to.y}"/>`);
dots.push(`<circle class="bk-dot ${state}" cx="${bend.x}" cy="${bend.y}" r="2.2"/>`);
};
// 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 = `
<article class="bk-center ${champion ? 'has-champion' : ''} ${simChampion ? 'is-sim' : ''}
${simMode && isSimulatable(finalNode) ? 'simulatable' : ''} ${centerTip ? 'has-tip' : ''}"
data-ref="FINAL" data-match-id="${finalNode.match.id}" tabindex="0" role="button"
aria-label="${centerTip}${translatePhase('Final')}" ${centerTip ? `data-tip="${centerTip}"` : ''}
style="left:${cx - G.championD / 2}px;top:${cy - G.championD / 2}px;width:${G.championD}px;height:${G.championD}px">
${simChampion ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
<span class="bk-center-trophy" aria-hidden="true">🏆</span>
${champion?.team ? `<img src="${flagSrc(champion.team)}" alt="" width="${G.championD}" height="${G.championD}">` : ''}
${champion ? `<span class="bk-center-name">${champion.label}</span>` : ''}
</article>`;
// 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
? `<img src="${flagSrc(display.team)}" alt="" width="${G.thirdD}" height="${G.thirdD}" loading="lazy">`
: '';
return `
<span class="bk-token ${display.team ? '' : 'tk-tbd'} ${win ? (third.simulated ? 'tk-sim' : 'tk-winner') : ''}"
style="width:${G.thirdD}px;height:${G.thirdD}px">${flag}</span>`;
};
const thirdHTML = `
<div class="bk-third-group ${thirdTip ? 'has-tip' : ''}" data-ref="THIRD-PLACE"
${third.match ? `data-match-id="${third.match.id}" tabindex="0" role="button"
aria-label="${thirdTip}${translatePhase(third.phase)}"` : ''}
${thirdTip ? `data-tip="${thirdTip}"` : ''}
style="left:${cx - 110}px;top:${L.thirdY - G.thirdD / 2 - 26}px;width:220px">
<span class="bk-third-title">${translatePhase('Third Place')}</span>
<span class="bk-third-tokens">${thirdToken('home')}${thirdToken('away')}</span>
</div>`;
const orbits = [G.outerR, G.winners.R32.r, G.winners.R16.r, G.winners.QF.r]
.map((r) => `<circle class="bk-orbit" cx="${cx}" cy="${cy}" r="${r}"/>`).join('');
return `
<svg class="bk-links" width="${L.w}" height="${L.h}" viewBox="0 0 ${L.w} ${L.h}" aria-hidden="true">
<defs>
<radialGradient id="bk-glow-grad">
<stop offset="0%" stop-color="rgba(212, 175, 55, 0.30)"/>
<stop offset="55%" stop-color="rgba(212, 175, 55, 0.10)"/>
<stop offset="100%" stop-color="rgba(212, 175, 55, 0)"/>
</radialGradient>
</defs>
<circle cx="${cx}" cy="${cy}" r="${G.glowR}" fill="url(#bk-glow-grad)"/>
${orbits}
<circle class="bk-orbit-accent" cx="${cx}" cy="${cy}" r="${G.winners.SF.r + 42}"/>
${lines.join('')}
${dots.join('')}
</svg>
${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 `
<svg class="bk-scenery" width="${L.w}" height="${L.h}" viewBox="0 0 ${L.w} ${L.h}" aria-hidden="true">
<line x1="${cx}" y1="0" x2="${cx}" y2="${L.h}"/>
<circle cx="${cx}" cy="${cy}" r="${r}"/>
<circle class="bk-scenery-dot" cx="${cx}" cy="${cy}" r="3.5"/>
</svg>`;
}
function linksHTML(L) {
const paths = L.links.map((link) => `
<path class="bk-l-${link.tier.toLowerCase()}" d="${link.d}" pathLength="1"
data-from="${link.from}" data-to="${link.to}"/>`).join('');
return `
<svg class="bk-links" width="${L.w}" height="${L.h}" viewBox="0 0 ${L.w} ${L.h}" aria-hidden="true">
${paths}
<path class="bk-stem" d="${L.stem}" pathLength="1" data-from="FINAL" data-to="FINAL"/>
</svg>`;
}
// 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
? `<img class="flag" src="${flagSrc(champion.team)}" alt="" width="26" height="18">`
: '';
const c = L.champion;
const tt = L.thirdTitle;
return `
<div class="bk-champion ${champion ? 'has-champion' : ''} ${simChampion ? 'is-sim' : ''}"
style="left:${c.x}px;top:${c.y}px;width:${c.w}px;height:${c.h}px">
${simChampion ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
<span class="champion-trophy" aria-hidden="true">🏆</span>
<span class="champion-label">${t('bracket.champion')}</span>
<span class="champion-name">${champion ? `${flag}<span>${champion.label}</span>` : t('app.tbd')}</span>
</div>
<span class="bk-third-title" style="left:${tt.x}px;top:${tt.y}px;width:${tt.w}px">
${translatePhase('Third Place')}</span>`;
}
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 `
<article class="${classes}" data-ref="${node.ref}" ${interactive}
style="left:${pos.x}px;top:${pos.y}px;width:${pos.w}px;height:${pos.h}px">
${node.simulated ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
<div class="bk-teams">
${teamRowHTML(home, node, 'home')}
${teamRowHTML(away, node, 'away')}
</div>
${metaHTML(node)}
</article>`;
}
// status fragment shared by chart microlines and pager cards
function statusLine(node) {
const status = node.result?.status;
if (status === 'live') return `<span class="bk-meta-live">${t('hero.live')}</span>`;
if (status === 'finished') return `<span class="bk-meta-ft">${t('bracket.ft')}</span>`;
return node.match ? `<span>${kickoffShort(node.match)}</span>` : '';
}
// 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'
? `<span class="bk-venue">${node.match.stadium} · ${node.match.city}</span>`
: '';
return `<div class="bk-meta">${venue}${statusLine(node)}</div>`;
}
// ------------------------------------------------------- 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
? `<div class="bk-pcard-top">
<span class="bk-pcard-venue">${node.match.stadium} · ${node.match.city}</span>
${statusLine(node)}
</div>`
: '';
return `
<article class="${classes}" data-ref="${node.ref}" ${interactive}>
${node.simulated ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
${top}
${teamRowHTML(home, node, 'home')}
${teamRowHTML(away, node, 'away')}
</article>`;
}
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
? `<img class="flag" src="${flagSrc(champion.team)}" alt="" width="26" height="18">`
: '';
return `
<div class="bk-champion bk-flow ${champion ? 'has-champion' : ''} ${simChampion ? 'is-sim' : ''}">
${simChampion ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
<span class="champion-trophy" aria-hidden="true">🏆</span>
<span class="champion-label">${t('bracket.champion')}</span>
<span class="champion-name">${champion ? `${flag}<span>${champion.label}</span>` : t('app.tbd')}</span>
</div>
${pcardHTML(finalNode, true)}
<h4 class="bk-page-sub">${translatePhase('Third Place')}</h4>
${pcardHTML(tree.third)}`;
}
function pagerHTML(tree) {
const roundsById = new Map(tree.rounds.map((r) => [r.id, r]));
const chips = PAGER_PAGES.map((page, i) => `
<button class="bk-page-btn" data-page="${i}" aria-pressed="false">
${translatePhase(page.phase)}</button>`).join('');
const pages = PAGER_PAGES.map((page) => {
const body = page.id === 'FINALS'
? `<div class="bk-page-list bk-page-finals">${pagerFinalsHTML(tree)}</div>`
: `<div class="bk-page-list">${roundsById.get(page.id).nodes.map((n) => pcardHTML(n)).join('')}</div>`;
return `<section class="bk-page" aria-label="${translatePhase(page.phase)}">${body}</section>`;
}).join('');
return `
<div class="bk-pager ${simMode ? 'sim-on' : ''}">
<nav class="bk-pager-nav" id="bk-pager-nav" aria-label="${t('bracket.viewRounds')}">${chips}</nav>
<div class="bk-pages" id="bk-pages">${pages}</div>
</div>`;
}
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',
'<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 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
? `<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>`;
}