world-2026-hub/assets/js/stats.js
Lucas Kalil 71f7490e0f feat(stats): rotate tied teams in leader cards + new metric cards
Leader cards (Best attack/defense, Most clean sheets) now rotate through every
team tied on the headline metric instead of showing only the top one, and three
new cards are added: Most wins, Most goals conceded, Best goal difference.

- group by the metric value alone; order within the group by existing tiebreakers
- auto-advance (3.5s), pause on hover/focus, off under prefers-reduced-motion
- discrete edge arrows with a full-height side click strip; wrap-around
- dots indicator up to 8 tied teams, else an i/n counter; single team = plain card
- bump APP_VERSION to v1.0.2
2026-06-19 10:03:51 -03:00

1173 lines
50 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.

// 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 (13) 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 148 (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 id="stats-${section.id}" class="stats-section" tabindex="-1" aria-label="${t(section.navKey)}">
${section.body(model)}
</section>`).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) => `
<a class="stats-subnav-chip${i === 0 ? ' active' : ''}" href="#stats-${section.id}"
data-section="${section.id}" aria-current="${i === 0 ? 'true' : 'false'}">${t(section.navKey)}</a>`).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 `<nav class="stats-subnav" aria-label="${t('stats.sectionsNav')}"><div class="stats-subnav-track">${chips}</div></nav>`;
}
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 <img> 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 `<img class="${cls}" src="${flagSrc(team)}" alt="" width="${w}" height="${h}" loading="lazy" data-monogram="${team.id}">`;
}
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 `
<section class="stats-hero glass slide-up">
<p class="hero-label">${t('stats.heroTitle')}<span class="hero-phase">${progress}</span></p>
<div class="stats-hero-tiles">${heroTilesHTML()}</div>
</section>`;
}
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 `
<section class="stats-hero stats-verdict glass slide-up">
<p class="hero-label">${t('stats.verdictTitle')}</p>
<div class="verdict-champion">
<span class="verdict-trophy" aria-hidden="true">🏆</span>
${flagImg(champion, 92, 61, 'flag verdict-flag')}
<span class="verdict-name">${champion.name}</span>
<span class="verdict-crown">${t('bracket.champion')}</span>
</div>
<div class="verdict-podium">
${places.map((p) => `
<div class="verdict-place">
<span class="verdict-rank" aria-hidden="true">${p.rank}</span>
${flagImg(team(p.id), 36, 24)}
<span class="verdict-place-name">${team(p.id).name}</span>
<span class="verdict-place-label">${p.label}</span>
</div>`).join('')}
</div>
<div class="stats-hero-tiles">${heroTilesHTML()}</div>
</section>`;
}
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) => `
<div class="stats-tile">
<span class="stats-tile-value" data-countup="${tile.value}" data-decimals="${tile.decimals}">${tile.decimals ? '0.00' : '0'}</span>
<span class="stats-tile-label">${tile.label}</span>
</div>`).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 `
<h2 class="section-title">${t('stats.overviewTitle')}</h2>
<div class="stats-overview-grid">
${cards.map((card) => `
<div class="stat-card glass">
<span class="stat-value">${card.value}${card.sub ? `<span class="stat-sub">${card.sub}</span>` : ''}</span>
<span class="stat-label">${card.label}</span>
</div>`).join('')}
</div>
${goalsByStageHTML()}
${goalsByRoundHTML()}`;
}
function footerHTML() {
return `
<p class="stats-more">
<button class="stats-link" id="stats-see-matches" type="button">${t('stats.seeAllMatches')} →</button>
</p>`;
}
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 `
<div class="chart-row">
<span class="chart-bar-label">${label}</span>
<div class="chart-track"><div class="chart-bar" style="width:${pct}%"></div></div>
<span class="chart-bar-val">${bucket.goals}</span>
</div>`;
}).join('');
return `
<h2 class="section-title">${t('stats.goalsByPhase')}</h2>
<div class="stats-chart glass">${rows}</div>`;
}
// 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 `
<div class="chart-row">
<span class="chart-bar-label">${label}</span>
<div class="chart-track"><div class="chart-bar" style="width:${pct}%"></div></div>
<span class="chart-bar-val">${bucket.goals}</span>
</div>`;
}).join('');
return `
<h2 class="section-title">${t('stats.goalsByRound')}</h2>
<div class="stats-chart glass">${rows}</div>`;
}
// ----------------------------------------------------- team statistics
function teamsSectionHTML() {
return `
<h2 class="section-title">${t('stats.teamStatsTitle')}</h2>
${leadersHTML()}
${teamRecordsHTML()}
<div id="stats-teams-table" class="stats-teams-table"></div>
${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 ? `<div class="stats-records-grid">${cards.join('')}</div>` : '';
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 `
<button type="button" class="record-card glass" data-record-match="${win.matchId}"
aria-label="${t('stats.biggestWin')}: ${winner.name} ${win.score} ${loser.name}">
<span class="record-label">${t('stats.biggestWin')}</span>
<span class="record-main">
${flagImg(winner, 26, 17)}
<span class="record-score">${win.score}</span>
${flagImg(loser, 26, 17)}
</span>
<span class="record-teams">${winner.name} <span class="record-vs">${t('hero.vs')}</span> ${loser.name}</span>
</button>`;
}
function streakCardHTML(streak) {
const team = getData().teamById.get(streak.teamId);
return `
<div class="record-card glass">
<span class="record-label">${t('stats.winStreak')}</span>
<span class="record-main">
${flagImg(team, 26, 17)}
<span class="record-score">${streak.count}</span>
</span>
<span class="record-teams">${team.name}</span>
</div>`;
}
function championPathHTML(path) {
const rows = path.map((step) => {
const opp = getData().teamById.get(step.opponentId);
const pens = step.pens ? ` <small>(${t('status.pens')} ${step.pens})</small>` : '';
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 `
<div class="champ-path-row${clickable ? ' clickable' : ''}" ${attrs}>
<span class="champ-path-phase">${translatePhase(step.phase)}</span>
<span class="champ-path-score">${step.gf}${step.ga}${pens}</span>
<span class="champ-path-opp">${flagImg(opp, 20, 13)} ${opp.name}</span>
</div>`;
}).join('');
return `
<div class="champ-path glass">
<span class="record-label">${t('stats.championPath')}</span>
${rows}
</div>`;
}
// ----------------------------------------------------- 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 ? `<div class="stats-records-grid">${cards.join('')}</div>` : '';
return `
<h2 class="section-title">${t('stats.recordsTitle')}</h2>
${grid}
${formatDebutsHTML()}`;
}
function highScoreCardHTML(rec) {
const home = getData().teamById.get(rec.homeTeam);
const away = getData().teamById.get(rec.awayTeam);
return `
<button type="button" class="record-card glass" data-record-match="${rec.matchId}"
aria-label="${t('stats.highScoreMatch')}: ${home.name} ${rec.score} ${away.name}">
<span class="record-label">${t('stats.highScoreMatch')}</span>
<span class="record-main">
${flagImg(home, 26, 17)}
<span class="record-score">${rec.score}</span>
${flagImg(away, 26, 17)}
</span>
<span class="record-teams">${home.name} <span class="record-vs">${t('hero.vs')}</span> ${away.name}</span>
</button>`;
}
// "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 `
<h3 class="stats-subhead">${t('stats.formatDebutsTitle')}</h3>
<div class="debut-band glass">
${facts.map((f) => `
<div class="debut-fact">
<span class="debut-value${f.small ? ' debut-value-sm' : ''}">${f.value}</span>
<span class="debut-label">${f.label}</span>
</div>`).join('')}
</div>`;
}
// --------------------------------------------------- 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) => `<option value="${team.id}"${team.id === selected ? ' selected' : ''}>${team.name}</option>`).join('');
return `
<h2 class="section-title">${t('stats.comparatorTitle')}</h2>
<div class="cmp-controls">
<select class="filter-control cmp-select" id="cmp-a" aria-label="${t('stats.cmpTeamA')}">${options(cmpA)}</select>
<span class="cmp-vs">${t('hero.vs')}</span>
<select class="filter-control cmp-select" id="cmp-b" aria-label="${t('stats.cmpTeamB')}">${options(cmpB)}</select>
</div>
<div class="cmp-panel glass" id="cmp-panel">${comparatorBarsHTML()}</div>`;
}
// 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 = `
<div class="cmp-head">
<div class="cmp-team">${flagImg(teamA, 28, 19)} <span>${teamA.name}</span></div>
<div class="cmp-team cmp-team-b"><span>${teamB.name}</span> ${flagImg(teamB, 28, 19)}</div>
</div>`;
const rows = CMP_METRICS.map((metric) => {
const av = a[metric.key];
const bv = b[metric.key];
const max = Math.max(av, bv, 1);
return `
<div class="cmp-row">
<span class="cmp-val a${av >= bv ? ' lead' : ''}">${av}</span>
<div class="cmp-track a"><div class="cmp-bar a" style="width:${Math.round((av / max) * 100)}%"></div></div>
<span class="cmp-label">${t(metric.label)}</span>
<div class="cmp-track b"><div class="cmp-bar b" style="width:${Math.round((bv / max) * 100)}%"></div></div>
<span class="cmp-val b${bv >= av ? ' lead' : ''}">${bv}</span>
</div>`;
}).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) => `<span class="legend-pair"><b>${t(col.label)}</b> = ${t(col.tip)}</span>`)
.join('');
return `<p class="stats-legend">${pairs}</p>`;
}
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 `<div class="stats-leaders">${leaders.map(leaderCardHTML).join('')}</div>`;
}
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)}<span class="leader-name">${team.name}</span>`;
}
// 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 `
<div class="leader-card glass">
<span class="leader-label">${label}</span>
<div class="leader-team">${leaderTeamHTML(group[0])}</div>
<span class="leader-value">${value}</span>
</div>`;
}
const indicator = group.length <= DOTS_MAX
? `<div class="leader-dots" aria-hidden="true">${group.map((_, i) =>
`<span class="leader-dot${i === 0 ? ' active' : ''}"></span>`).join('')}</div>`
: `<span class="leader-counter" aria-hidden="true">1 / ${group.length}</span>`;
return `
<div class="leader-card glass" data-leader="${id}" role="group" aria-label="${label}">
<span class="leader-label">${label}</span>
<div class="leader-stage">
<button type="button" class="leader-nav leader-prev" aria-label="${t('stats.leaderPrev')}">&lsaquo;</button>
<div class="leader-team">${leaderTeamHTML(group[0])}</div>
<button type="button" class="leader-nav leader-next" aria-label="${t('stats.leaderNext')}">&rsaquo;</button>
</div>
<span class="leader-value">${value}</span>
${indicator}
</div>`;
}
// 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 ? `<span class="sort-arrow" aria-hidden="true">${sortDir === 'asc' ? '▲' : '▼'}</span>` : '';
return `<th scope="col" class="${cls}${active ? ' sorted' : ''}" aria-sort="${ariaSort}">
<button type="button" class="col-sort has-tip" data-sort="${key}" data-tip="${tip}" aria-label="${aria}${tip}">${label}${arrow}</button>
</th>`;
}
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 `<td class="col-num${col.key === sortKey ? ' sorted' : ''}">${value}</td>`;
}).join('');
const classes = [row.played === 0 ? 'row-idle' : '', favs.has(row.teamId) ? 'row-fav' : ''].filter(Boolean).join(' ');
return `
<tr class="${classes}">
<td class="col-rank${sortKey === 'rank' ? ' sorted' : ''}">${row.rank}</td>
<td class="col-team">
${flagImg(team, 22, 15)}
<span>${team.name}</span>
</td>
${cells}
</tr>`;
}).join('');
return `
<div class="stats-table-wrap" role="region" aria-label="${t('stats.teamStatsTitle')}" tabindex="0">
<table class="stats-table">
<caption class="sr-only">${t('stats.teamStatsTitle')}</caption>
<thead>
<tr>
${rankHead}
<th scope="col" class="col-team">${t('standings.team')}</th>
${head}
</tr>
</thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
function paginationHTML(pages) {
if (pages <= 1) return '';
const nums = Array.from({ length: pages }, (_, p) => `
<button type="button" class="page-btn${p === teamPage ? ' active' : ''}" data-page="${p}"
aria-current="${p === teamPage ? 'page' : 'false'}">${p + 1}</button>`).join('');
return `
<nav class="stats-pagination" aria-label="${t('stats.teamStatsTitle')}">
<button type="button" class="page-btn page-arrow" data-page="${teamPage - 1}"
${teamPage === 0 ? 'disabled' : ''} aria-label="${t('stats.prevPage')}"></button>
${nums}
<button type="button" class="page-btn page-arrow" data-page="${teamPage + 1}"
${teamPage >= pages - 1 ? 'disabled' : ''} aria-label="${t('stats.nextPage')}"></button>
</nav>`;
}
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);
}