// stats.js — "Stats" tab. Tournament-to-date aggregates derived ONLY from data // the project already has (results.json scores/status + optional per-match // stats, matches.json phase). Counts finished matches only, consistent with // computeStandings (live/scheduled ignored). Built as the evolving foundation // for the post-tournament stats screen (see .agents/stats-screen-plan.md): // sections gate on data so player/award/editorial blocks slot in later. import { getData, flagSrc, navigateTo } from './app.js'; import { getBracketTree } from './bracket.js'; import { getFavorites } from './storage.js'; import { openMatchModal } from './modal.js'; import { t, translatePhase } from './i18n.js'; // "Goals by stage" collapses all 12 groups into one bucket; knockout phases // keep their own. Order used to render the chart left-to-right. const STAGE_ORDER = ['Round of 32', 'Round of 16', 'Quarterfinals', 'Semifinals', 'Third Place', 'Final']; // "Goals by round" is finer: the group stage is split into its 3 matchdays // (derived per group), then each knockout round stands alone — a goals-over-time // view distinct from goals-by-stage (which lumps all group games together). const ROUND_ORDER = ['MD1', 'MD2', 'MD3', ...STAGE_ORDER]; // Per-team table: all 48 teams, 8 per page (6 fixed pages). Sortable columns — // existing standings.* labels are reused for the abbreviations the user already // knows from the Groups tab; the two new ones carry a full-name title tooltip. const PAGE_SIZE = 8; const COLUMNS = [ { key: 'played', label: 'standings.played', tip: 'tip.played' }, { key: 'won', label: 'standings.won', tip: 'tip.won' }, { key: 'drawn', label: 'standings.drawn', tip: 'tip.drawn' }, { key: 'lost', label: 'standings.lost', tip: 'tip.lost' }, { key: 'gf', label: 'standings.gf', tip: 'tip.gf' }, { key: 'ga', label: 'standings.ga', tip: 'tip.ga' }, { key: 'gd', label: 'standings.gd', tip: 'tip.gd' }, { key: 'points', label: 'standings.pts', tip: 'tip.pts' }, { key: 'gpg', label: 'stats.colGpg', tip: 'tip.gpg' }, { key: 'cleanSheets', label: 'stats.colCS', tip: 'tip.cs' }, ]; // Sub-nav sections (graceful-degradation contract, stats-screen-plan.md §0.1): a // section renders — and its sub-nav chip appears — only when `available(model)` // holds. Otherwise it is omitted from the DOM entirely (no placeholder, no "—", // no "coming soon") and the nav never points at emptiness. Later stages flip // `available` and supply `body` for players/records/comparator/archive; the same // code base thus renders a coherent, "full" screen with only today's data and // lights up sections as each data layer arrives. const SECTIONS = [ { id: 'overview', navKey: 'stats.navOverview', available: () => true, body: overviewHTML }, { id: 'teams', navKey: 'stats.navTeams', available: () => true, body: teamsSectionHTML }, { id: 'players', navKey: 'stats.navPlayers', available: () => false, body: () => '' }, { id: 'records', navKey: 'stats.navRecords', available: () => true, body: recordsSectionHTML }, { id: 'comparator', navKey: 'stats.navComparator', available: (m) => m.finishedCount > 0, body: comparatorSectionHTML }, { id: 'archive', navKey: 'stats.navArchive', available: () => false, body: () => '' }, ]; // Metrics shown as diverging bars in the team comparator — all non-negative so // the mirrored bars read cleanly (GD is excluded; it's GF/GA derived). Reuses // the standings.* abbreviations the user already knows. const CMP_METRICS = [ { key: 'played', label: 'standings.played' }, { key: 'won', label: 'standings.won' }, { key: 'gf', label: 'standings.gf' }, { key: 'ga', label: 'standings.ga' }, { key: 'cleanSheets', label: 'stats.colCS' }, { key: 'points', label: 'standings.pts' }, ]; let model = null; // table interaction state — survives langchange re-renders. Default on load is // the canonical final ranking (page 1); like the bracket keeps its zoom. let sortKey = 'rank'; let sortDir = 'asc'; let teamPage = 0; // comparator selection (team ids) — survives langchange like the table state let cmpA = null; let cmpB = null; function stageOf(phase) { return phase.startsWith('Group ') ? 'Group' : phase; } // Tournament-wide team aggregation over finished matches (group + knockout). // computeStandings() only covers group matches, so this is its own pass. // possession/shots/cards are gated per-match: a finished match without the // optional `stats` object simply doesn't contribute (no visible distortion). function aggregateTeams(finished, resultByMatchId) { const rows = new Map(); const row = (id) => { if (!rows.has(id)) { rows.set(id, { teamId: id, played: 0, won: 0, drawn: 0, lost: 0, gf: 0, ga: 0, cleanSheets: 0, possSum: 0, possCount: 0, shots: 0, cards: 0, }); } return rows.get(id); }; for (const m of finished) { const r = resultByMatchId.get(m.id); const home = row(m.homeTeam); const away = row(m.awayTeam); applySide(home, r.homeScore, r.awayScore); applySide(away, r.awayScore, r.homeScore); if (r.stats) { const s = r.stats; if (s.possession) { home.possSum += s.possession.home; home.possCount += 1; away.possSum += s.possession.away; away.possCount += 1; } if (s.shots) { home.shots += s.shots.home; away.shots += s.shots.away; } if (s.cards) { home.cards += s.cards.home; away.cards += s.cards.away; } } } return rows; } function applySide(row, gf, ga) { row.played += 1; row.gf += gf; row.ga += ga; if (ga === 0) row.cleanSheets += 1; if (gf > ga) row.won += 1; else if (gf === ga) row.drawn += 1; else row.lost += 1; } function buildStatsModel() { const { matches, resultByMatchId } = getData(); const finished = matches.filter((m) => resultByMatchId.get(m.id)?.status === 'finished'); let totalGoals = 0; let draws = 0; let decisive = 0; let biggestMargin = 0; const byStage = new Map(); const byRound = new Map(); const groupMatchday = computeGroupMatchdays(matches); for (const m of finished) { const r = resultByMatchId.get(m.id); const total = r.homeScore + r.awayScore; totalGoals += total; if (r.homeScore === r.awayScore) draws += 1; else decisive += 1; biggestMargin = Math.max(biggestMargin, Math.abs(r.homeScore - r.awayScore)); const stage = stageOf(m.phase); const bucket = byStage.get(stage) ?? { goals: 0, count: 0 }; bucket.goals += total; bucket.count += 1; byStage.set(stage, bucket); // finer round bucket: group → its matchday, knockout → the stage itself const roundKey = m.phase.startsWith('Group ') ? `MD${groupMatchday.get(m.id)}` : stage; const rb = byRound.get(roundKey) ?? { goals: 0, count: 0 }; rb.goals += total; rb.count += 1; byRound.set(roundKey, rb); } const agg = aggregateTeams(finished, resultByMatchId); let cleanSheets = 0; for (const r of agg.values()) cleanSheets += r.cleanSheets; // one row per team for ALL 48 (teams that haven't played yet are real zeros, // not gaps), with the derived columns the table needs. const teamStats = getData().teams.map((team) => { const a = agg.get(team.id); const gf = a?.gf ?? 0; const ga = a?.ga ?? 0; const won = a?.won ?? 0; const drawn = a?.drawn ?? 0; const played = a?.played ?? 0; return { teamId: team.id, played, won, drawn, lost: a?.lost ?? 0, gf, ga, gd: gf - ga, points: won * 3 + drawn, cleanSheets: a?.cleanSheets ?? 0, gpg: played ? gf / played : 0, }; }); const verdict = computeVerdict(); assignRanks(teamStats); return { totalMatches: matches.length, finishedCount: finished.length, totalGoals, avgGoals: finished.length ? totalGoals / finished.length : 0, draws, decisive, biggestMargin, cleanSheets, byStage, byRound, verdict, teamStats, leaders: computeLeaders(teamStats), records: computeRecords(finished, resultByMatchId, verdict), }; } // Matchday (1–3) for every group match, derived per group: a 4-team group plays // two games per matchday, so sorting a group's six fixtures by kickoff and // chunking into pairs reproduces the official matchdays (no stored field). function computeGroupMatchdays(matches) { const byGroup = new Map(); for (const m of matches) { if (!m.phase.startsWith('Group ')) continue; if (!byGroup.has(m.phase)) byGroup.set(m.phase, []); byGroup.get(m.phase).push(m); } const matchday = new Map(); for (const list of byGroup.values()) { list.sort((a, b) => `${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`) || a.id - b.id); list.forEach((m, i) => matchday.set(m.id, Math.floor(i / 2) + 1)); } return matchday; } // The tournament verdict — REAL results only. The bracket tree's champion can be // a user simulation; gate on the FINAL node carrying a real finished result // (decide() sets winner from real results first, so !simulated means it's real). // Third/fourth come from the third-place match the same way; each is independent // so the podium degrades gracefully if (somehow) only the final is in. function computeVerdict() { const tree = getBracketTree(); const finalNode = tree.nodesByRef.get('FINAL'); if (!finalNode || finalNode.simulated || finalNode.result?.status !== 'finished' || !finalNode.winner) { return null; } const verdict = { champion: finalNode.winner, runnerUp: finalNode.loser }; const third = tree.third; if (third && !third.simulated && third.result?.status === 'finished' && third.winner) { verdict.third = third.winner; verdict.fourth = third.loser; } return verdict; } // Canonical final ranking 1–48 (stats-screen-plan.md §6.5): primary key is the // deepest stage REACHED (champion → runner-up → 3rd → 4th → QF → R16 → R32 → // group), then points → GD → GF → id. Reproducible and stable; each team carries // its rank, so the table can sort by any column yet still show this # identity. const GROUP_TIER = 7; function assignRanks(teamStats) { const tiers = computeRankTiers(); const ranked = [...teamStats].sort((a, b) => (tiers.get(a.teamId) ?? GROUP_TIER) - (tiers.get(b.teamId) ?? GROUP_TIER) || b.points - a.points || b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId)); ranked.forEach((row, i) => { row.rank = i + 1; }); } // Phase-reached tier per team, from REAL knockout results only (a simulated pick // never affects the ranking). Champion 0, runner-up 1, 3rd 2, 4th 3, then losers // by round (QF 4, R16 5, R32 6). Absent → group tier (7) via the default above. function computeRankTiers() { const tree = getBracketTree(); const tier = new Map(); const set = (id, value) => { if (id && !tier.has(id)) tier.set(id, value); }; const finalNode = tree.nodesByRef.get('FINAL'); if (finalNode && !finalNode.simulated && finalNode.result?.status === 'finished' && finalNode.winner) { set(finalNode.winner, 0); set(finalNode.loser, 1); } const third = tree.third; if (third && !third.simulated && third.result?.status === 'finished' && third.winner) { set(third.winner, 2); set(third.loser, 3); } const roundTier = { QF: 4, R16: 5, R32: 6 }; for (const round of tree.rounds) { const value = roundTier[round.id]; if (value === undefined) continue; for (const node of round.nodes) { if (!node.simulated && node.result?.status === 'finished' && node.loser) set(node.loser, value); } } return tier; } // Auto-derived team records over finished matches. Each is null when its data // isn't there yet, so the cards degrade away individually (§0.1). function computeRecords(finished, resultByMatchId, verdict) { let biggestWin = null; for (const m of finished) { const r = resultByMatchId.get(m.id); const margin = Math.abs(r.homeScore - r.awayScore); if (margin === 0) continue; const total = r.homeScore + r.awayScore; if (!biggestWin || margin > biggestWin.margin || (margin === biggestWin.margin && total > biggestWin.total)) { const homeWon = r.homeScore > r.awayScore; biggestWin = { matchId: m.id, margin, total, winnerId: homeWon ? m.homeTeam : m.awayTeam, loserId: homeWon ? m.awayTeam : m.homeTeam, score: homeWon ? `${r.homeScore}-${r.awayScore}` : `${r.awayScore}-${r.homeScore}`, }; } } // longest run of consecutive wins by any team, in chronological order const order = [...finished].sort((a, b) => `${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`) || a.id - b.id); const current = new Map(); let longestWinStreak = null; for (const m of order) { const r = resultByMatchId.get(m.id); const homeWin = r.homeScore > r.awayScore || (r.homeScore === r.awayScore && r.penalties && r.penalties.home > r.penalties.away); const awayWin = r.awayScore > r.homeScore || (r.homeScore === r.awayScore && r.penalties && r.penalties.away > r.penalties.home); for (const [teamId, won] of [[m.homeTeam, homeWin], [m.awayTeam, awayWin]]) { const run = won ? (current.get(teamId) ?? 0) + 1 : 0; current.set(teamId, run); if (won && (!longestWinStreak || run > longestWinStreak.count)) longestWinStreak = { teamId, count: run }; } } // highest-scoring match (most combined goals; tie → bigger margin) let highestScoringMatch = null; for (const m of finished) { const r = resultByMatchId.get(m.id); const total = r.homeScore + r.awayScore; const margin = Math.abs(r.homeScore - r.awayScore); if (!highestScoringMatch || total > highestScoringMatch.total || (total === highestScoringMatch.total && margin > highestScoringMatch.margin)) { highestScoringMatch = { matchId: m.id, total, margin, homeTeam: m.homeTeam, awayTeam: m.awayTeam, score: `${r.homeScore}-${r.awayScore}` }; } } return { biggestWin, highestScoringMatch, longestWinStreak: longestWinStreak && longestWinStreak.count >= 2 ? longestWinStreak : null, championPath: computeChampionPath(verdict), }; } // The champion's knockout route (R32 → Final) with each result, for the path // card. Null unless there's a real champion (verdict present). function computeChampionPath(verdict) { if (!verdict) return null; const tree = getBracketTree(); const champ = verdict.champion; const path = []; for (const round of tree.rounds) { const node = round.nodes.find((n) => n.winner === champ && (n.home.teamId === champ || n.away.teamId === champ)); if (!node || !node.result) continue; const side = node.home.teamId === champ ? 'home' : 'away'; const r = node.result; path.push({ matchId: node.match?.id ?? null, phase: node.phase, opponentId: side === 'home' ? node.away.teamId : node.home.teamId, gf: side === 'home' ? r.homeScore : r.awayScore, ga: side === 'home' ? r.awayScore : r.homeScore, pens: r.penalties ? (side === 'home' ? `${r.penalties.home}-${r.penalties.away}` : `${r.penalties.away}-${r.penalties.home}`) : null, }); } return path.length ? path : null; } // Leader cards in the Teams section. Each rotates through the teams TIED on its // headline metric — grouped by that metric's value ALONE (decision 2026-06-19), // so e.g. every team level on goals-for shares the "Best attack" card. `cmp` // orders within the group (leader first), so the team shown first is unchanged. const LEADER_CARDS = [ { id: 'bestAttack', labelKey: 'stats.bestAttack', metric: 'gf', cmp: (a, b) => b.gf - a.gf || b.gd - a.gd }, { id: 'bestDefense', labelKey: 'stats.bestDefense', metric: 'ga', cmp: (a, b) => a.ga - b.ga || b.cleanSheets - a.cleanSheets || b.gd - a.gd }, { id: 'mostCleanSheets', labelKey: 'stats.mostCleanSheets', metric: 'cleanSheets', cmp: (a, b) => b.cleanSheets - a.cleanSheets || a.ga - b.ga }, { id: 'mostWins', labelKey: 'stats.mostWins', metric: 'won', cmp: (a, b) => b.won - a.won || b.gd - a.gd || b.gf - a.gf }, { id: 'mostConceded', labelKey: 'stats.mostConceded', metric: 'ga', cmp: (a, b) => b.ga - a.ga || a.gd - b.gd }, { id: 'bestGoalDiff', labelKey: 'stats.bestGoalDiff', metric: 'gd', cmp: (a, b) => b.gd - a.gd || b.gf - a.gf }, ]; // Each card carries the FULL tied group (same value on its headline metric), // ordered by the card's tiebreakers; the carousel rotates through it. Only teams // that have played count (a 0-game team's empty record never "leads"). Null // before any match finishes → the whole leaders strip degrades away. function computeLeaders(teamStats) { const played = teamStats.filter((row) => row.played > 0); if (!played.length) return null; return LEADER_CARDS.map(({ id, labelKey, metric, cmp }) => { const sorted = [...played].sort(cmp); const best = sorted[0][metric]; return { id, labelKey, metric, group: sorted.filter((row) => row[metric] === best) }; }); } // ---------------------------------------------------------------- render export function initStats() { installImageFallback(); render(); // labels re-render on language change; the derived model never changes at // runtime (data is static per page load) so it is reused. document.addEventListener('langchange', render); // new published results change the aggregates → rebuild the memoized model document.addEventListener('datachange', () => { model = null; render(); }); // favorites change elsewhere (schedule/groups/modal) → re-render the table so // the gold favorite-row highlight stays in sync (no model rebuild needed). document.addEventListener('favchange', renderTeamTable); } function render() { if (!model) model = buildStatsModel(); clearLeaderTimers(); // drop any carousel intervals from the previous render const root = document.getElementById('stats-root'); const sections = SECTIONS.filter((section) => section.available(model)); root.innerHTML = heroHTML() + subNavHTML(sections) + sections.map((section) => `
${section.body(model)}
`).join('') + footerHTML(); root.querySelector('#stats-see-matches')?.addEventListener('click', () => navigateTo('matches')); const teamsHost = root.querySelector('#stats-teams-table'); if (teamsHost) { teamsHost.addEventListener('click', onTeamTableClick); renderTeamTable(); } // record cards / champion-path rows that reference a match open it in the modal for (const el of root.querySelectorAll('[data-record-match]')) { const open = () => openMatchModal(Number(el.dataset.recordMatch)); el.addEventListener('click', open); el.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); } }); } // comparator selects → update the chosen side, re-render just the bars panel const cmpAEl = root.querySelector('#cmp-a'); const cmpBEl = root.querySelector('#cmp-b'); if (cmpAEl && cmpBEl) { cmpAEl.addEventListener('change', () => { cmpA = cmpAEl.value; refreshComparator(); }); cmpBEl.addEventListener('change', () => { cmpB = cmpBEl.value; refreshComparator(); }); } setupCountUps(root); setupLeaderCarousels(root); setupSubNav(root, sections); } // ----------------------------------------------------------- sub-nav function subNavHTML(sections) { if (sections.length < 2) return ''; // a lone section needs no navigation const chips = sections.map((section, i) => ` ${t(section.navKey)}`).join(''); // chips live in an inner scroll track so the edge-fade mask never clips the // pill's background/rounded ends (only the chips fade at the edges). return ``; } let spyScrollHandler = null; let subnavResizeHandler = null; // While a chip-click smooth-scroll is in flight, the page-scroll spy must NOT // fight it: early in the animation the viewport is still over the old section, // so the spy would flip the active chip back and then forward again (a visible // jump). Suppress spy updates until the programmatic scroll has settled. let suppressSpyUntil = 0; function setupSubNav(root, sections) { const nav = root.querySelector('.stats-subnav'); if (!nav) return; const track = nav.querySelector('.stats-subnav-track'); const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // chip → smooth-scroll to the section WITHOUT touching location.hash: the tab // router (app.js) listens on hashchange, so a real #fragment would route to // an unknown tab and bounce the user to Home. preventDefault keeps us in-tab. nav.addEventListener('click', (event) => { const chip = event.target.closest('.stats-subnav-chip'); if (!chip) return; event.preventDefault(); suppressSpyUntil = Date.now() + (reduce ? 0 : 700); // hold the spy off during the animated scroll document.getElementById(`stats-${chip.dataset.section}`) ?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' }); setActiveChip(nav, chip.dataset.section); }); // edge fades while the chip track overflows horizontally (mirrors the header // tabs): a fade shows only on a side that still has chips to scroll toward. track?.addEventListener('scroll', () => updateSubnavFades(nav), { passive: true }); // scrollspy: active = the last section whose heading has scrolled under the // sticky sub-nav line; at the page bottom the last section always wins (a short // final section may never reach the line — the classic scrollspy edge case an // IntersectionObserver band leaves unlit). Reading getBoundingClientRect on a // handful of sections per frame is cheap and always correct on short pages. const ids = sections.map((section) => section.id); const updateSpy = () => { if (Date.now() < suppressSpyUntil) return; // a chip-click scroll owns the active chip const headerH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-h')) || 64; const line = headerH + 80; // just beneath the sticky sub-nav let activeId = ids[0]; for (const id of ids) { if (document.getElementById(`stats-${id}`)?.getBoundingClientRect().top <= line) activeId = id; } if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) { activeId = ids[ids.length - 1]; // bottom reached → last section } setActiveChip(nav, activeId); }; if (spyScrollHandler) window.removeEventListener('scroll', spyScrollHandler); let raf = 0; spyScrollHandler = () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; updateSpy(); }); }; window.addEventListener('scroll', spyScrollHandler, { passive: true }); // re-evaluate the fades when the viewport width changes the track's overflow if (subnavResizeHandler) window.removeEventListener('resize', subnavResizeHandler); subnavResizeHandler = () => updateSubnavFades(nav); window.addEventListener('resize', subnavResizeHandler, { passive: true }); updateSpy(); updateSubnavFades(nav); } // Toggle the edge-fade mask classes on the pill based on the inner track's // horizontal overflow (the mask lives on the track, so the pill stays crisp). function updateSubnavFades(nav) { const track = nav.querySelector('.stats-subnav-track'); if (!track) return; const overflowing = track.scrollWidth - track.clientWidth > 1; const atStart = track.scrollLeft <= 1; const atEnd = track.scrollLeft >= track.scrollWidth - track.clientWidth - 1; nav.classList.toggle('fade-left', overflowing && !atStart); nav.classList.toggle('fade-right', overflowing && !atEnd); } function setActiveChip(nav, id) { const track = nav.querySelector('.stats-subnav-track'); for (const chip of nav.querySelectorAll('.stats-subnav-chip')) { const on = chip.dataset.section === id; chip.classList.toggle('active', on); chip.setAttribute('aria-current', on ? 'true' : 'false'); } // keep the active chip visible when the track scrolls horizontally on mobile // (only moves the track's own scroll, never the page). const active = nav.querySelector('.stats-subnav-chip.active'); if (active && track) track.scrollLeft = active.offsetLeft - (track.clientWidth - active.clientWidth) / 2; updateSubnavFades(nav); } // ----------------------------------------------------------- flags // Flag that degrades to a 3-letter monogram if the SVG is missing — never // a broken-image icon (graceful degradation §0.3). Used everywhere the stats // screen shows a flag so the fallback is uniform. function flagImg(team, w, h, cls = 'flag') { return ``; } let fallbackInstalled = false; function installImageFallback() { if (fallbackInstalled) return; fallbackInstalled = true; // error events don't bubble → listen in the capture phase. Only opted-in // images (data-monogram) are touched, so other views are unaffected. document.addEventListener('error', (event) => { const img = event.target; if (!(img instanceof HTMLImageElement) || !img.dataset.monogram) return; const span = document.createElement('span'); span.className = 'flag-fallback'; span.style.width = `${img.getAttribute('width')}px`; span.style.height = `${img.getAttribute('height')}px`; span.textContent = img.dataset.monogram; img.replaceWith(span); }, true); } // The hero becomes the tournament's verdict (champion + podium) once the FINAL // has a real result; until then it falls back to the live "in progress" // aggregate hero, so the screen stays correct even if merged before the Cup ends. function heroHTML() { return model.verdict ? verdictHeroHTML() : aggregateHeroHTML(); } function aggregateHeroHTML() { const m = model; const progress = t('stats.heroProgress') .replace('{x}', String(m.finishedCount)) .replace('{y}', String(m.totalMatches)); return `

${t('stats.heroTitle')}${progress}

${heroTilesHTML()}
`; } function verdictHeroHTML() { const v = model.verdict; const team = (id) => getData().teamById.get(id); const champion = team(v.champion); const places = [ { label: t('stats.runnerUp'), rank: '2', id: v.runnerUp }, v.third ? { label: t('stats.thirdPlace'), rank: '3', id: v.third } : null, v.fourth ? { label: t('stats.fourthPlace'), rank: '4', id: v.fourth } : null, ].filter(Boolean); return `

${t('stats.verdictTitle')}

${flagImg(champion, 92, 61, 'flag verdict-flag')} ${champion.name} ${t('bracket.champion')}
${places.map((p) => `
${flagImg(team(p.id), 36, 24)} ${team(p.id).name} ${p.label}
`).join('')}
${heroTilesHTML()}
`; } function heroTilesHTML() { const m = model; const tiles = [ { value: m.totalGoals, decimals: 0, label: t('stats.tileGoals') }, { value: Number(m.avgGoals.toFixed(2)), decimals: 2, label: t('stats.tileAvg') }, { value: m.biggestMargin, decimals: 0, label: t('stats.tileBiggestMargin') }, { value: m.cleanSheets, decimals: 0, label: t('stats.tileCleanSheets') }, ]; return tiles.map((tile) => `
${tile.decimals ? '0.00' : '0'} ${tile.label}
`).join(''); } function overviewHTML() { const m = model; const cards = [ { value: String(m.finishedCount), sub: `/ ${m.totalMatches}`, label: t('stats.played') }, { value: String(m.decisive), label: t('stats.decisive') }, { value: String(m.draws), label: t('stats.draws') }, ]; return `

${t('stats.overviewTitle')}

${cards.map((card) => `
${card.value}${card.sub ? `${card.sub}` : ''} ${card.label}
`).join('')}
${goalsByStageHTML()} ${goalsByRoundHTML()}`; } function footerHTML() { return `

`; } function goalsByStageHTML() { const order = ['Group', ...STAGE_ORDER].filter((stage) => model.byStage.has(stage)); if (!order.length) return ''; const max = Math.max(...order.map((stage) => model.byStage.get(stage).goals)); const rows = order.map((stage) => { const bucket = model.byStage.get(stage); const pct = max ? Math.round((bucket.goals / max) * 100) : 0; const label = stage === 'Group' ? t('stats.stageGroup') : translatePhase(stage); return `
${label}
${bucket.goals}
`; }).join(''); return `

${t('stats.goalsByPhase')}

${rows}
`; } // Finer companion to goals-by-stage: group matchdays + each knockout round. // Hidden until ≥2 rounds have data, so it never shows a lone bar that just // duplicates the goals-by-stage "Group" bar early in the tournament. function goalsByRoundHTML() { const order = ROUND_ORDER.filter((round) => model.byRound.has(round)); if (order.length < 2) return ''; const max = Math.max(...order.map((round) => model.byRound.get(round).goals)); const rows = order.map((round) => { const bucket = model.byRound.get(round); const pct = max ? Math.round((bucket.goals / max) * 100) : 0; const label = round.startsWith('MD') ? `${t('stats.matchday')} ${round.slice(2)}` : translatePhase(round); return `
${label}
${bucket.goals}
`; }).join(''); return `

${t('stats.goalsByRound')}

${rows}
`; } // ----------------------------------------------------- team statistics function teamsSectionHTML() { return `

${t('stats.teamStatsTitle')}

${leadersHTML()} ${teamRecordsHTML()}
${legendHTML(COLUMNS)}`; } // Team-level cards in the Teams section: longest win streak + the champion's // path (post-final). Match-level records live in the Records section. Each // degrades away individually when its data is null. function teamRecordsHTML() { const rec = model.records; const cards = []; if (rec.longestWinStreak) cards.push(streakCardHTML(rec.longestWinStreak)); const grid = cards.length ? `
${cards.join('')}
` : ''; return grid + (rec.championPath ? championPathHTML(rec.championPath) : ''); } function biggestWinCardHTML(win) { const winner = getData().teamById.get(win.winnerId); const loser = getData().teamById.get(win.loserId); return ` `; } function streakCardHTML(streak) { const team = getData().teamById.get(streak.teamId); return `
${t('stats.winStreak')} ${flagImg(team, 26, 17)} ${streak.count} ${team.name}
`; } function championPathHTML(path) { const rows = path.map((step) => { const opp = getData().teamById.get(step.opponentId); const pens = step.pens ? ` (${t('status.pens')} ${step.pens})` : ''; const clickable = step.matchId != null; const attrs = clickable ? `data-record-match="${step.matchId}" role="button" tabindex="0" aria-label="${translatePhase(step.phase)}: ${step.gf}–${step.ga} ${opp.name}"` : ''; return `
${translatePhase(step.phase)} ${step.gf}–${step.ga}${pens} ${flagImg(opp, 20, 13)} ${opp.name}
`; }).join(''); return `
${t('stats.championPath')} ${rows}
`; } // ----------------------------------------------------- records section // Match/tournament records + the "format-48 debuts" band. Match record cards // degrade away individually; the debuts band is always meaningful (format facts), // so this section (and its sub-nav chip) is always present. function recordsSectionHTML() { const rec = model.records; const cards = []; if (rec.biggestWin) cards.push(biggestWinCardHTML(rec.biggestWin)); // skip the high-score card when it's the very same match as the biggest win // (early in the tournament they often coincide); they diverge as it goes on. if (rec.highestScoringMatch && rec.highestScoringMatch.matchId !== rec.biggestWin?.matchId) { cards.push(highScoreCardHTML(rec.highestScoringMatch)); } const grid = cards.length ? `
${cards.join('')}
` : ''; return `

${t('stats.recordsTitle')}

${grid} ${formatDebutsHTML()}`; } function highScoreCardHTML(rec) { const home = getData().teamById.get(rec.homeTeam); const away = getData().teamById.get(rec.awayTeam); return ` `; } // "Format debuts" band — the firsts of the 48-team era. Mostly static format // facts (always true); the champion fact lights up once the verdict is in. function formatDebutsHTML() { const data = getData(); const facts = [ { value: String(data.teams.length), label: t('stats.debutTeams') }, { value: String(model.totalMatches), label: t('stats.debutMatches') }, { value: String(Object.keys(data.groups).length), label: t('stats.debutGroups') }, { value: translatePhase('Round of 32'), label: t('stats.debutR32'), small: true }, { value: '8', label: t('stats.debutThird') }, ]; if (model.verdict) { facts.push({ value: data.teamById.get(model.verdict.champion).name, label: t('stats.debutChampion'), small: true }); } return `

${t('stats.formatDebutsTitle')}

${facts.map((f) => `
${f.value} ${f.label}
`).join('')}
`; } // --------------------------------------------------- comparator section // Default the two sides to the top-2 ranked teams; the choice then survives // langchange (module-level cmpA/cmpB), like the table sort. function ensureComparatorDefaults() { if (cmpA && cmpB) return; const byRank = [...model.teamStats].sort((a, b) => a.rank - b.rank); cmpA = cmpA ?? byRank[0]?.teamId; cmpB = cmpB ?? byRank[1]?.teamId; } function comparatorSectionHTML() { ensureComparatorDefaults(); const teams = [...getData().teams].sort((a, b) => a.name.localeCompare(b.name)); const options = (selected) => teams .map((team) => ``).join(''); return `

${t('stats.comparatorTitle')}

${t('hero.vs')}
${comparatorBarsHTML()}
`; } // Diverging mirrored bars: A grows leftward from the center label, B rightward. // Each row scales to max(a,b) so the longer bar is the higher value. function comparatorBarsHTML() { const byId = new Map(model.teamStats.map((row) => [row.teamId, row])); const a = byId.get(cmpA); const b = byId.get(cmpB); const teamA = getData().teamById.get(cmpA); const teamB = getData().teamById.get(cmpB); const header = `
${flagImg(teamA, 28, 19)} ${teamA.name}
${teamB.name} ${flagImg(teamB, 28, 19)}
`; const rows = CMP_METRICS.map((metric) => { const av = a[metric.key]; const bv = b[metric.key]; const max = Math.max(av, bv, 1); return `
${av}
${t(metric.label)}
${bv}
`; }).join(''); return header + rows; } // Re-render only the bars panel on a selection change (keeps the selects' // focus/scroll and replays the grow animation on the new bars). function refreshComparator() { const panel = document.getElementById('cmp-panel'); if (panel) panel.innerHTML = comparatorBarsHTML(); } // Compact abbreviation key — hidden on desktop (the hover tooltip covers it // there), shown on small screens where hover doesn't fire. function legendHTML(columns) { const pairs = columns .map((col) => `${t(col.label)} = ${t(col.tip)}`) .join(''); return `

${pairs}

`; } const ROTATE_MS = 3500; // auto-advance cadence for tied-leader carousels const DOTS_MAX = 8; // above this many tied teams, dots give way to "i / n" function leadersHTML() { const leaders = model.leaders; if (!leaders) return ''; return `
${leaders.map(leaderCardHTML).join('')}
`; } function leaderValueText(metric, value) { return metric === 'gd' && value > 0 ? `+${value}` : `${value}`; } function leaderTeamHTML(row) { const team = getData().teamById.get(row.teamId); return `${flagImg(team, 30, 20)}${team.name}`; } // A leader card. Single-team group → a plain static card (identical to before). // A tie → arrows + an indicator (dots up to DOTS_MAX, else an "i / n" counter); // setupLeaderCarousels() wires the rotation. The big value is shared by the whole // group (all tied on the metric), so only the flag+name swap as it rotates. function leaderCardHTML({ id, labelKey, metric, group }) { const label = t(labelKey); const value = leaderValueText(metric, group[0][metric]); if (group.length < 2) { return `
${label}
${leaderTeamHTML(group[0])}
${value}
`; } const indicator = group.length <= DOTS_MAX ? `` : ``; return `
${label}
${leaderTeamHTML(group[0])}
${value} ${indicator}
`; } // Tied-leader carousels. Timers are tracked module-level and cleared at the top // of render() so a re-render (langchange/datachange) never leaves an interval // firing on detached DOM (cf. gotcha #6 — never double-schedule). let leaderTimers = []; function clearLeaderTimers() { for (const id of leaderTimers) clearInterval(id); leaderTimers = []; } // Auto-advance pauses on hover/focus and is disabled entirely under // prefers-reduced-motion (arrows still work). A manual arrow click restarts the // cadence implicitly: while the pointer/focus is on the card it stays paused, and // leaving the card starts a fresh full interval. function setupLeaderCarousels(root) { const leaders = model.leaders; if (!leaders) return; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; for (const leader of leaders) { if (leader.group.length < 2) continue; // single team: static, no timer const card = root.querySelector(`.leader-card[data-leader="${leader.id}"]`); if (!card) continue; const teamHost = card.querySelector('.leader-team'); const dots = card.querySelectorAll('.leader-dot'); const counter = card.querySelector('.leader-counter'); const group = leader.group; let idx = 0; let timer = null; let paused = false; const show = (i) => { idx = (i + group.length) % group.length; teamHost.innerHTML = leaderTeamHTML(group[idx]); dots.forEach((dot, di) => dot.classList.toggle('active', di === idx)); if (counter) counter.textContent = `${idx + 1} / ${group.length}`; }; const stop = () => { if (!timer) return; clearInterval(timer); leaderTimers = leaderTimers.filter((x) => x !== timer); timer = null; }; const start = () => { if (reduce || paused || timer) return; timer = setInterval(() => show(idx + 1), ROTATE_MS); leaderTimers.push(timer); }; const pause = () => { paused = true; stop(); }; const resume = () => { paused = false; start(); }; card.querySelector('.leader-prev')?.addEventListener('click', () => show(idx - 1)); card.querySelector('.leader-next')?.addEventListener('click', () => show(idx + 1)); card.addEventListener('mouseenter', pause); card.addEventListener('mouseleave', resume); card.addEventListener('focusin', pause); card.addEventListener('focusout', resume); start(); } } function sortedTeamStats() { const dir = sortDir === 'asc' ? 1 : -1; if (sortKey === 'rank') { return [...model.teamStats].sort((a, b) => (a.rank - b.rank) * dir); } return [...model.teamStats].sort((a, b) => { const primary = (a[sortKey] - b[sortKey]) * dir; if (primary) return primary; return a.rank - b.rank; // canonical rank is the stable tiebreak }); } function renderTeamTable() { const host = document.getElementById('stats-teams-table'); if (!host) return; const sorted = sortedTeamStats(); const pages = Math.ceil(sorted.length / PAGE_SIZE); teamPage = Math.max(0, Math.min(teamPage, pages - 1)); const start = teamPage * PAGE_SIZE; host.innerHTML = tableHTML(sorted.slice(start, start + PAGE_SIZE)) + paginationHTML(pages); } // One sortable header cell; `aria` falls back to the visible label. function sortHeaderHTML(key, label, tip, cls, aria = label) { const active = key === sortKey; const ariaSort = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'; const arrow = active ? `` : ''; return ` `; } function tableHTML(rows) { const rankHead = sortHeaderHTML('rank', '#', t('tip.rank'), 'col-rank', t('stats.rankCol')); const head = COLUMNS.map((col) => sortHeaderHTML(col.key, t(col.label), t(col.tip), 'col-num')).join(''); const favs = new Set(getFavorites()); const body = rows.map((row) => { const team = getData().teamById.get(row.teamId); const cells = COLUMNS.map((col) => { const value = col.key === 'gpg' ? row.gpg.toFixed(2) : col.key === 'gd' ? fmtGd(row.gd) : row[col.key]; return `${value}`; }).join(''); const classes = [row.played === 0 ? 'row-idle' : '', favs.has(row.teamId) ? 'row-fav' : ''].filter(Boolean).join(' '); return ` ${row.rank} ${flagImg(team, 22, 15)} ${team.name} ${cells} `; }).join(''); return `
${rankHead} ${head} ${body}
${t('stats.teamStatsTitle')}
${t('standings.team')}
`; } function paginationHTML(pages) { if (pages <= 1) return ''; const nums = Array.from({ length: pages }, (_, p) => ` `).join(''); return ` `; } function onTeamTableClick(event) { const sortBtn = event.target.closest('.col-sort'); if (sortBtn) { const key = sortBtn.dataset.sort; if (key === sortKey) sortDir = sortDir === 'desc' ? 'asc' : 'desc'; else { sortKey = key; sortDir = key === 'rank' ? 'asc' : 'desc'; } teamPage = 0; renderTeamTable(); return; } const pageBtn = event.target.closest('.page-btn'); if (pageBtn && !pageBtn.disabled) { teamPage = Number(pageBtn.dataset.page); renderTeamTable(); } } function fmtGd(gd) { return gd > 0 ? `+${gd}` : String(gd); } // ------------------------------------------------------------- count-up function fmt(value, decimals) { return decimals ? value.toFixed(decimals) : String(Math.round(value)); } function setupCountUps(root) { const els = [...root.querySelectorAll('[data-countup]')]; if (!els.length) return; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduce) { for (const el of els) el.textContent = fmt(Number(el.dataset.countup), Number(el.dataset.decimals) || 0); return; } // animate each tile when it first scrolls into view — the panel is hidden // until the Stats tab is opened, so this fires on arrival, not at load. const io = new IntersectionObserver((entries, obs) => { for (const entry of entries) { if (!entry.isIntersecting) continue; animateCount(entry.target); obs.unobserve(entry.target); } }, { threshold: 0.4 }); for (const el of els) io.observe(el); } function animateCount(el) { const target = Number(el.dataset.countup); const decimals = Number(el.dataset.decimals) || 0; const duration = 900; const start = performance.now(); const step = (now) => { const p = Math.min(1, (now - start) / duration); const eased = 1 - (1 - p) ** 3; el.textContent = fmt(target * eased, decimals); if (p < 1) requestAnimationFrame(step); else el.textContent = fmt(target, decimals); }; requestAnimationFrame(step); }