diff --git a/assets/css/stats.css b/assets/css/stats.css index d0906e4..7a4bcbc 100644 --- a/assets/css/stats.css +++ b/assets/css/stats.css @@ -220,6 +220,7 @@ gap: 0.5rem; padding: 1.1rem 1rem; text-align: center; + position: relative; /* anchor for the full-height side click areas */ } .leader-label { @@ -248,6 +249,80 @@ color: var(--accent-gold-soft); } +/* tied-leader carousel: arrows pinned to the card edges (so they never shift + with the team-name width), dots/counter below */ +.leader-stage { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + /* keep the team clear of the full-height side click areas */ + padding-inline: 2.75rem; +} + +/* each arrow is a full-height strip down the card side: clicking anywhere on the + lateral navigates. The chevron glyph stays small, centered in the strip (≈ the + team row, since the card's vertical centre sits on it). */ +.leader-nav { + position: absolute; + top: 0; + bottom: 0; + width: 2.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary); + opacity: 0.5; + font-size: 1rem; + line-height: 1; + cursor: pointer; + transition: color 0.15s ease, opacity 0.15s ease; +} + +.leader-prev { left: 0; } +.leader-next { right: 0; } + +.leader-nav:hover, +.leader-nav:focus-visible { + opacity: 1; + color: var(--accent-gold); +} + +.leader-dots { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + flex-wrap: wrap; + margin-top: -0.15rem; /* tuck the smaller dots closer to the value */ +} + +.leader-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--glass-border); + transition: background 0.2s ease, transform 0.2s ease; +} + +.leader-dot.active { + background: var(--accent-gold); + transform: scale(1.25); +} + +.leader-counter { + font-size: 0.68rem; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +@media (prefers-reduced-motion: reduce) { + .leader-dot { transition: none; } +} + /* horizontal scroll on narrow screens; rank + team columns stay frozen */ .stats-table-wrap { overflow-x: auto; diff --git a/assets/js/i18n.js b/assets/js/i18n.js index 5ab1999..b75ddb2 100644 --- a/assets/js/i18n.js +++ b/assets/js/i18n.js @@ -6,7 +6,7 @@ import { getPrefs, setPref } from './storage.js'; // App version for footer display — bump this after any notable changes -const APP_VERSION = 'v1.0.1'; +const APP_VERSION = 'v1.0.2'; const dicts = { en: { @@ -159,6 +159,11 @@ const dicts = { 'stats.bestAttack': 'Best attack', 'stats.bestDefense': 'Best defense', 'stats.mostCleanSheets': 'Most clean sheets', + 'stats.mostWins': 'Most wins', + 'stats.mostConceded': 'Most goals conceded', + 'stats.bestGoalDiff': 'Best goal difference', + 'stats.leaderPrev': 'Previous team', + 'stats.leaderNext': 'Next team', 'stats.biggestWin': 'Biggest win', 'stats.winStreak': 'Longest win streak', 'stats.championPath': "Champion's path", @@ -329,6 +334,11 @@ const dicts = { 'stats.bestAttack': 'Melhor ataque', 'stats.bestDefense': 'Melhor defesa', 'stats.mostCleanSheets': 'Mais clean sheets', + 'stats.mostWins': 'Mais vitórias', + 'stats.mostConceded': 'Mais gols sofridos', + 'stats.bestGoalDiff': 'Melhor saldo de gols', + 'stats.leaderPrev': 'Time anterior', + 'stats.leaderNext': 'Próximo time', 'stats.biggestWin': 'Maior goleada', 'stats.winStreak': 'Maior sequência de vitórias', 'stats.championPath': 'Caminho do campeão', diff --git a/assets/js/stats.js b/assets/js/stats.js index 2226146..1ccb693 100644 --- a/assets/js/stats.js +++ b/assets/js/stats.js @@ -362,16 +362,31 @@ function computeChampionPath(verdict) { return path.length ? path : null; } -// Highlight leaders consider only teams that have played, so a 0-game team's -// empty record never counts as "best defense". Null before any match finishes. +// 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 { - bestAttack: [...played].sort((a, b) => b.gf - a.gf || b.gd - a.gd)[0], - bestDefense: [...played].sort((a, b) => a.ga - b.ga || b.cleanSheets - a.cleanSheets || b.gd - a.gd)[0], - mostCleanSheets: [...played].sort((a, b) => b.cleanSheets - a.cleanSheets || a.ga - b.ga)[0], - }; + 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 @@ -391,6 +406,7 @@ export function initStats() { 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 = @@ -423,6 +439,7 @@ function render() { cmpBEl.addEventListener('change', () => { cmpB = cmpBEl.value; refreshComparator(); }); } setupCountUps(root); + setupLeaderCarousels(root); setupSubNav(root, sections); } @@ -900,30 +917,113 @@ function legendHTML(columns) { 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 ''; - const cards = [ - { label: t('stats.bestAttack'), row: leaders.bestAttack, value: leaders.bestAttack.gf }, - { label: t('stats.bestDefense'), row: leaders.bestDefense, value: leaders.bestDefense.ga }, - { label: t('stats.mostCleanSheets'), row: leaders.mostCleanSheets, value: leaders.mostCleanSheets.cleanSheets }, - ]; - return `
${cards.map(leaderCardHTML).join('')}
`; + return `
${leaders.map(leaderCardHTML).join('')}
`; } -function leaderCardHTML({ label, row, value }) { +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} -
- ${flagImg(team, 30, 20)} - ${team.name} +
+ +
${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') {