mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
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
This commit is contained in:
parent
35b55a4c78
commit
71f7490e0f
3 changed files with 204 additions and 19 deletions
|
|
@ -220,6 +220,7 @@
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 1.1rem 1rem;
|
padding: 1.1rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
position: relative; /* anchor for the full-height side click areas */
|
||||||
}
|
}
|
||||||
|
|
||||||
.leader-label {
|
.leader-label {
|
||||||
|
|
@ -248,6 +249,80 @@
|
||||||
color: var(--accent-gold-soft);
|
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 */
|
/* horizontal scroll on narrow screens; rank + team columns stay frozen */
|
||||||
.stats-table-wrap {
|
.stats-table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import { getPrefs, setPref } from './storage.js';
|
import { getPrefs, setPref } from './storage.js';
|
||||||
|
|
||||||
// App version for footer display — bump this after any notable changes
|
// 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 = {
|
const dicts = {
|
||||||
en: {
|
en: {
|
||||||
|
|
@ -159,6 +159,11 @@ const dicts = {
|
||||||
'stats.bestAttack': 'Best attack',
|
'stats.bestAttack': 'Best attack',
|
||||||
'stats.bestDefense': 'Best defense',
|
'stats.bestDefense': 'Best defense',
|
||||||
'stats.mostCleanSheets': 'Most clean sheets',
|
'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.biggestWin': 'Biggest win',
|
||||||
'stats.winStreak': 'Longest win streak',
|
'stats.winStreak': 'Longest win streak',
|
||||||
'stats.championPath': "Champion's path",
|
'stats.championPath': "Champion's path",
|
||||||
|
|
@ -329,6 +334,11 @@ const dicts = {
|
||||||
'stats.bestAttack': 'Melhor ataque',
|
'stats.bestAttack': 'Melhor ataque',
|
||||||
'stats.bestDefense': 'Melhor defesa',
|
'stats.bestDefense': 'Melhor defesa',
|
||||||
'stats.mostCleanSheets': 'Mais clean sheets',
|
'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.biggestWin': 'Maior goleada',
|
||||||
'stats.winStreak': 'Maior sequência de vitórias',
|
'stats.winStreak': 'Maior sequência de vitórias',
|
||||||
'stats.championPath': 'Caminho do campeão',
|
'stats.championPath': 'Caminho do campeão',
|
||||||
|
|
|
||||||
|
|
@ -362,16 +362,31 @@ function computeChampionPath(verdict) {
|
||||||
return path.length ? path : null;
|
return path.length ? path : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight leaders consider only teams that have played, so a 0-game team's
|
// Leader cards in the Teams section. Each rotates through the teams TIED on its
|
||||||
// empty record never counts as "best defense". Null before any match finishes.
|
// 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) {
|
function computeLeaders(teamStats) {
|
||||||
const played = teamStats.filter((row) => row.played > 0);
|
const played = teamStats.filter((row) => row.played > 0);
|
||||||
if (!played.length) return null;
|
if (!played.length) return null;
|
||||||
return {
|
return LEADER_CARDS.map(({ id, labelKey, metric, cmp }) => {
|
||||||
bestAttack: [...played].sort((a, b) => b.gf - a.gf || b.gd - a.gd)[0],
|
const sorted = [...played].sort(cmp);
|
||||||
bestDefense: [...played].sort((a, b) => a.ga - b.ga || b.cleanSheets - a.cleanSheets || b.gd - a.gd)[0],
|
const best = sorted[0][metric];
|
||||||
mostCleanSheets: [...played].sort((a, b) => b.cleanSheets - a.cleanSheets || a.ga - b.ga)[0],
|
return { id, labelKey, metric, group: sorted.filter((row) => row[metric] === best) };
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- render
|
// ---------------------------------------------------------------- render
|
||||||
|
|
@ -391,6 +406,7 @@ export function initStats() {
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!model) model = buildStatsModel();
|
if (!model) model = buildStatsModel();
|
||||||
|
clearLeaderTimers(); // drop any carousel intervals from the previous render
|
||||||
const root = document.getElementById('stats-root');
|
const root = document.getElementById('stats-root');
|
||||||
const sections = SECTIONS.filter((section) => section.available(model));
|
const sections = SECTIONS.filter((section) => section.available(model));
|
||||||
root.innerHTML =
|
root.innerHTML =
|
||||||
|
|
@ -423,6 +439,7 @@ function render() {
|
||||||
cmpBEl.addEventListener('change', () => { cmpB = cmpBEl.value; refreshComparator(); });
|
cmpBEl.addEventListener('change', () => { cmpB = cmpBEl.value; refreshComparator(); });
|
||||||
}
|
}
|
||||||
setupCountUps(root);
|
setupCountUps(root);
|
||||||
|
setupLeaderCarousels(root);
|
||||||
setupSubNav(root, sections);
|
setupSubNav(root, sections);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -900,29 +917,112 @@ function legendHTML(columns) {
|
||||||
return `<p class="stats-legend">${pairs}</p>`;
|
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() {
|
function leadersHTML() {
|
||||||
const leaders = model.leaders;
|
const leaders = model.leaders;
|
||||||
if (!leaders) return '';
|
if (!leaders) return '';
|
||||||
const cards = [
|
return `<div class="stats-leaders">${leaders.map(leaderCardHTML).join('')}</div>`;
|
||||||
{ 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 `<div class="stats-leaders">${cards.map(leaderCardHTML).join('')}</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
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 `
|
return `
|
||||||
<div class="leader-card glass">
|
<div class="leader-card glass">
|
||||||
<span class="leader-label">${label}</span>
|
<span class="leader-label">${label}</span>
|
||||||
<div class="leader-team">
|
<div class="leader-team">${leaderTeamHTML(group[0])}</div>
|
||||||
${flagImg(team, 30, 20)}
|
|
||||||
<span class="leader-name">${team.name}</span>
|
|
||||||
</div>
|
|
||||||
<span class="leader-value">${value}</span>
|
<span class="leader-value">${value}</span>
|
||||||
</div>`;
|
</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')}">‹</button>
|
||||||
|
<div class="leader-team">${leaderTeamHTML(group[0])}</div>
|
||||||
|
<button type="button" class="leader-nav leader-next" aria-label="${t('stats.leaderNext')}">›</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() {
|
function sortedTeamStats() {
|
||||||
const dir = sortDir === 'asc' ? 1 : -1;
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue