mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
feat(stats): add tournament-to-date stats tab
This commit is contained in:
parent
ba81e49eac
commit
d5a9dadc5d
9 changed files with 1263 additions and 4 deletions
394
assets/css/stats.css
Normal file
394
assets/css/stats.css
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
/* stats.css — "Stats" tab: tournament-to-date hero, overview cards and the
|
||||
goals-by-stage chart. Reuses the global tokens and the .stat-card pattern.
|
||||
Repeated tiles use a flat translucent fill (no backdrop-filter) per the
|
||||
project's perf rule; blur is reserved for the few large .glass surfaces. */
|
||||
|
||||
/* --------------------------------------------------------- hero "pulse" */
|
||||
|
||||
.stats-hero {
|
||||
padding: clamp(1.4rem, 4vw, 2.4rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-hero .hero-label {
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-hero-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.stats-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem 0.6rem;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.stats-tile-value {
|
||||
font-size: clamp(1.5rem, 4.5vw, 2.1rem);
|
||||
font-weight: 700;
|
||||
color: var(--accent-gold-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stats-tile-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- overview */
|
||||
|
||||
.stats-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-overview-grid .stat-value {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- goals-by-stage chart */
|
||||
|
||||
.stats-chart {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1.25rem 1.4rem;
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(86px, 22vw, 150px) 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.chart-bar-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chart-track {
|
||||
height: 14px;
|
||||
background: var(--glass-bg-strong);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--accent-blue), var(--accent-gold));
|
||||
transform-origin: left center;
|
||||
animation: stats-bar-grow 0.6s ease both;
|
||||
}
|
||||
|
||||
@keyframes stats-bar-grow {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
|
||||
.chart-bar-val {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.5ch;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- team statistics */
|
||||
|
||||
.stats-leaders {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.leader-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.1rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.leader-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--accent-gold);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.leader-team {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.leader-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.leader-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent-gold-soft);
|
||||
}
|
||||
|
||||
/* horizontal scroll on narrow screens; rank + team columns stay frozen */
|
||||
.stats-table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--glass-border);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
background: var(--glass-bg);
|
||||
}
|
||||
|
||||
.stats-table th,
|
||||
.stats-table td {
|
||||
padding: 0.6rem 0.7rem;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stats-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats-table tbody tr {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.stats-table tbody tr:hover {
|
||||
background: var(--glass-bg-strong);
|
||||
}
|
||||
|
||||
.col-rank {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.col-team {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* freeze rank + team to the left while metric columns scroll under them */
|
||||
.stats-table .col-rank,
|
||||
.stats-table .col-team {
|
||||
position: sticky;
|
||||
background: var(--bg-secondary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stats-table .col-rank { left: 0; }
|
||||
.stats-table .col-team { left: 2.5rem; }
|
||||
|
||||
.stats-table tbody .col-rank,
|
||||
.stats-table tbody .col-team {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.stats-table tbody tr:hover .col-rank,
|
||||
.stats-table tbody tr:hover .col-team {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.col-sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.col-sort:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stats-table th.sorted,
|
||||
.stats-table td.sorted {
|
||||
color: var(--accent-gold-soft);
|
||||
}
|
||||
|
||||
.sort-arrow {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.row-idle td {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.stats-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 2.2rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color 0.2s, border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
color: var(--bg-primary);
|
||||
background: linear-gradient(135deg, var(--accent-gold), var(--accent-gold-soft));
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------- header tooltips + key */
|
||||
|
||||
.has-tip { cursor: help; }
|
||||
.col-sort.has-tip { cursor: pointer; }
|
||||
|
||||
.app-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-width: 220px;
|
||||
padding: 0.45rem 0.65rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-soft);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-tooltip[hidden] { display: none; }
|
||||
|
||||
/* abbreviation key — hidden on desktop (tooltips cover it), shown on mobile */
|
||||
.stats-legend { display: none; }
|
||||
|
||||
.legend-pair b {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.stats-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.9rem;
|
||||
margin: 0.9rem 0 0;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- misc */
|
||||
|
||||
.stats-more {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-link {
|
||||
padding: 0.55rem 1.2rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 999px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s, border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.stats-link:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-gold);
|
||||
background: var(--glass-bg-strong);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- responsive */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.stats-hero-tiles {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chart-bar { animation: none; }
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { initGroups } from './groups.js';
|
|||
import { initStadiums } from './stadiums.js';
|
||||
import { initModal } from './modal.js';
|
||||
import { initBracket } from './bracket.js';
|
||||
import { initStats } from './stats.js';
|
||||
|
||||
// ---------------------------------------------------------------- data
|
||||
|
||||
|
|
@ -57,7 +58,7 @@ export function flagSrc(team) {
|
|||
|
||||
// ---------------------------------------------------------------- tabs
|
||||
|
||||
const TABS = ['home', 'matches', 'groups', 'bracket', 'stadiums'];
|
||||
const TABS = ['home', 'matches', 'groups', 'bracket', 'stadiums', 'stats'];
|
||||
|
||||
function activateTab(id, { updateHash = true } = {}) {
|
||||
const tab = TABS.includes(id) ? id : 'home';
|
||||
|
|
@ -216,6 +217,53 @@ function renderDashboard() {
|
|||
|
||||
// ---------------------------------------------------------------- init
|
||||
|
||||
// shared tooltip for abbreviated table headers (Stats + Groups). A single
|
||||
// fixed-position bubble driven by event delegation, so it survives table
|
||||
// re-renders and is never clipped by a table's overflow/stacking context.
|
||||
// Hover + keyboard focus both trigger it; screen readers use the header's
|
||||
// aria-label, and small screens fall back to the visible legend.
|
||||
function initTooltips() {
|
||||
const tip = document.createElement('div');
|
||||
tip.className = 'app-tooltip';
|
||||
tip.setAttribute('role', 'tooltip');
|
||||
tip.hidden = true;
|
||||
document.body.appendChild(tip);
|
||||
let current = null;
|
||||
|
||||
const show = (el) => {
|
||||
current = el;
|
||||
tip.textContent = el.dataset.tip;
|
||||
tip.style.left = '-9999px';
|
||||
tip.style.top = '-9999px';
|
||||
tip.hidden = false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const box = tip.getBoundingClientRect();
|
||||
let left = Math.round(rect.left + rect.width / 2 - box.width / 2);
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - box.width - 8));
|
||||
let top = Math.round(rect.top - box.height - 8);
|
||||
if (top < 8) top = Math.round(rect.bottom + 8); // flip below if no room above
|
||||
tip.style.left = `${left}px`;
|
||||
tip.style.top = `${top}px`;
|
||||
};
|
||||
const hide = (el) => {
|
||||
if (!el || el === current) { tip.hidden = true; current = null; }
|
||||
};
|
||||
|
||||
for (const event of ['mouseover', 'focusin']) {
|
||||
document.addEventListener(event, (e) => {
|
||||
const el = e.target.closest?.('.has-tip[data-tip]');
|
||||
if (el) show(el);
|
||||
});
|
||||
}
|
||||
for (const event of ['mouseout', 'focusout']) {
|
||||
document.addEventListener(event, (e) => {
|
||||
const el = e.target.closest?.('.has-tip[data-tip]');
|
||||
if (el) hide(el);
|
||||
});
|
||||
}
|
||||
document.addEventListener('scroll', () => hide(current), true);
|
||||
}
|
||||
|
||||
// global star delegation — stars exist in schedule, groups, and modal
|
||||
function initFavorites() {
|
||||
document.addEventListener('click', (event) => {
|
||||
|
|
@ -278,6 +326,7 @@ async function init() {
|
|||
initLangSwitch();
|
||||
initTimeToggle();
|
||||
initFavorites();
|
||||
initTooltips();
|
||||
document.addEventListener('langchange', renderHome);
|
||||
document.addEventListener('timemodechange', renderHero);
|
||||
try {
|
||||
|
|
@ -288,6 +337,7 @@ async function init() {
|
|||
initGroups();
|
||||
initBracket();
|
||||
initStadiums();
|
||||
initStats();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,13 +69,27 @@ function render() {
|
|||
</p>
|
||||
<div class="groups-grid">
|
||||
${Object.entries(standings).map(([letter, rows]) => groupCardHTML(letter, rows)).join('')}
|
||||
</div>`;
|
||||
</div>
|
||||
${legendHTML()}`;
|
||||
}
|
||||
|
||||
// Abbreviation key shown only on small screens (where the header hover tooltips
|
||||
// don't fire); reuses the shared .stats-legend styling.
|
||||
function legendHTML() {
|
||||
const pairs = ['played', 'won', 'drawn', 'lost', 'gf', 'ga', 'gd', 'pts']
|
||||
.map((key) => `<span class="legend-pair"><b>${t(`standings.${key}`)}</b> = ${t(`tip.${key}`)}</span>`)
|
||||
.join('');
|
||||
return `<p class="stats-legend">${pairs}</p>`;
|
||||
}
|
||||
|
||||
function groupCardHTML(letter, rows) {
|
||||
const finished = isGroupFinished(letter);
|
||||
const headers = ['played', 'won', 'drawn', 'lost', 'gf', 'ga', 'gd', 'pts']
|
||||
.map((key) => `<th class="${key === 'gf' || key === 'ga' ? 'col-goals' : ''}" scope="col">${t(`standings.${key}`)}</th>`)
|
||||
.map((key) => {
|
||||
const tip = t(`tip.${key}`);
|
||||
const goals = key === 'gf' || key === 'ga' ? 'col-goals ' : '';
|
||||
return `<th class="${goals}has-tip" scope="col" data-tip="${tip}" aria-label="${t(`standings.${key}`)} — ${tip}">${t(`standings.${key}`)}</th>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const dicts = {
|
|||
'nav.groups': 'Groups',
|
||||
'nav.bracket': 'Knockout',
|
||||
'nav.stadiums': 'Stadiums',
|
||||
'nav.stats': 'Stats',
|
||||
'hero.live': 'Live',
|
||||
'hero.nextMatch': 'Next match',
|
||||
'hero.kickoff': 'Kickoff!',
|
||||
|
|
@ -106,6 +107,37 @@ const dicts = {
|
|||
'share.copied': 'Link copied!',
|
||||
'share.confirm': 'Apply the shared prediction? Your current picks will be replaced.',
|
||||
'modal.addCalendar': 'Add to calendar',
|
||||
'stats.heroTitle': 'Tournament in progress',
|
||||
'stats.heroProgress': '{x} of {y} matches played',
|
||||
'stats.tileGoals': 'Goals',
|
||||
'stats.tileAvg': 'Goals / match',
|
||||
'stats.tileBiggestMargin': 'Biggest margin',
|
||||
'stats.tileCleanSheets': 'Clean sheets',
|
||||
'stats.overviewTitle': 'Overview',
|
||||
'stats.played': 'Matches played',
|
||||
'stats.decisive': 'Decisive',
|
||||
'stats.draws': 'Draws',
|
||||
'stats.goalsByPhase': 'Goals by stage',
|
||||
'stats.stageGroup': 'Group stage',
|
||||
'stats.teamStatsTitle': 'Team statistics',
|
||||
'stats.colGpg': 'G/M',
|
||||
'stats.colCS': 'CS',
|
||||
'tip.played': 'Matches played',
|
||||
'tip.won': 'Wins',
|
||||
'tip.drawn': 'Draws',
|
||||
'tip.lost': 'Losses',
|
||||
'tip.gf': 'Goals for (scored)',
|
||||
'tip.ga': 'Goals against (conceded)',
|
||||
'tip.gd': 'Goal difference (for − against)',
|
||||
'tip.pts': 'Points',
|
||||
'tip.gpg': 'Goals per match (average)',
|
||||
'tip.cs': 'Clean sheets (no goals conceded)',
|
||||
'stats.bestAttack': 'Best attack',
|
||||
'stats.bestDefense': 'Best defense',
|
||||
'stats.mostCleanSheets': 'Most clean sheets',
|
||||
'stats.prevPage': 'Previous page',
|
||||
'stats.nextPage': 'Next page',
|
||||
'stats.seeAllMatches': 'See all matches',
|
||||
'footer.note': 'Fan-made static hub — all data lives in JSON files.',
|
||||
},
|
||||
pt: {
|
||||
|
|
@ -117,6 +149,7 @@ const dicts = {
|
|||
'nav.groups': 'Grupos',
|
||||
'nav.bracket': 'Mata-mata',
|
||||
'nav.stadiums': 'Estádios',
|
||||
'nav.stats': 'Estatísticas',
|
||||
'hero.live': 'Ao vivo',
|
||||
'hero.nextMatch': 'Próxima partida',
|
||||
'hero.kickoff': 'Bola rolando!',
|
||||
|
|
@ -208,6 +241,37 @@ const dicts = {
|
|||
'share.copied': 'Link copiado!',
|
||||
'share.confirm': 'Aplicar os palpites compartilhados? Seus palpites atuais serão substituídos.',
|
||||
'modal.addCalendar': 'Adicionar à agenda',
|
||||
'stats.heroTitle': 'Copa em andamento',
|
||||
'stats.heroProgress': '{x} de {y} jogos disputados',
|
||||
'stats.tileGoals': 'Gols',
|
||||
'stats.tileAvg': 'Gols por jogo',
|
||||
'stats.tileBiggestMargin': 'Maior margem',
|
||||
'stats.tileCleanSheets': 'Sem sofrer gols',
|
||||
'stats.overviewTitle': 'Visão geral',
|
||||
'stats.played': 'Jogos disputados',
|
||||
'stats.decisive': 'Decididas',
|
||||
'stats.draws': 'Empates',
|
||||
'stats.goalsByPhase': 'Gols por fase',
|
||||
'stats.stageGroup': 'Fase de grupos',
|
||||
'stats.teamStatsTitle': 'Estatísticas por time',
|
||||
'stats.colGpg': 'G/J',
|
||||
'stats.colCS': 'CS',
|
||||
'tip.played': 'Jogos disputados',
|
||||
'tip.won': 'Vitórias',
|
||||
'tip.drawn': 'Empates',
|
||||
'tip.lost': 'Derrotas',
|
||||
'tip.gf': 'Gols pró (marcados)',
|
||||
'tip.ga': 'Gols contra (sofridos)',
|
||||
'tip.gd': 'Saldo de gols (pró − contra)',
|
||||
'tip.pts': 'Pontos',
|
||||
'tip.gpg': 'Gols por jogo (média)',
|
||||
'tip.cs': 'Clean sheets (sem sofrer gols)',
|
||||
'stats.bestAttack': 'Melhor ataque',
|
||||
'stats.bestDefense': 'Melhor defesa',
|
||||
'stats.mostCleanSheets': 'Mais clean sheets',
|
||||
'stats.prevPage': 'Página anterior',
|
||||
'stats.nextPage': 'Próxima página',
|
||||
'stats.seeAllMatches': 'Ver todas as partidas',
|
||||
'footer.note': 'Hub estático feito por fãs — todos os dados vivem em arquivos JSON.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
439
assets/js/stats.js
Normal file
439
assets/js/stats.js
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
// 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 { 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'];
|
||||
|
||||
// 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' },
|
||||
];
|
||||
|
||||
let model = null;
|
||||
// table interaction state — survives langchange re-renders (default on load:
|
||||
// most goals first, page 1), like the bracket keeps its zoom across re-renders.
|
||||
let sortKey = 'gf';
|
||||
let sortDir = 'desc';
|
||||
let teamPage = 0;
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
totalMatches: matches.length,
|
||||
finishedCount: finished.length,
|
||||
totalGoals,
|
||||
avgGoals: finished.length ? totalGoals / finished.length : 0,
|
||||
draws,
|
||||
decisive,
|
||||
biggestMargin,
|
||||
cleanSheets,
|
||||
byStage,
|
||||
teamStats,
|
||||
leaders: computeLeaders(teamStats),
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
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],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- render
|
||||
|
||||
export function initStats() {
|
||||
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);
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!model) model = buildStatsModel();
|
||||
const root = document.getElementById('stats-root');
|
||||
root.innerHTML = heroHTML() + overviewHTML() + teamsSectionHTML() + footerHTML();
|
||||
root.querySelector('#stats-see-matches')?.addEventListener('click', () => navigateTo('matches'));
|
||||
const teamsHost = root.querySelector('#stats-teams-table');
|
||||
if (teamsHost) {
|
||||
teamsHost.addEventListener('click', onTeamTableClick);
|
||||
renderTeamTable();
|
||||
}
|
||||
setupCountUps(root);
|
||||
}
|
||||
|
||||
function heroHTML() {
|
||||
const m = model;
|
||||
const progress = t('stats.heroProgress')
|
||||
.replace('{x}', String(m.finishedCount))
|
||||
.replace('{y}', String(m.totalMatches));
|
||||
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 `
|
||||
<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">
|
||||
${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('')}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
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()}`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- team statistics
|
||||
|
||||
function teamsSectionHTML() {
|
||||
return `
|
||||
<h2 class="section-title">${t('stats.teamStatsTitle')}</h2>
|
||||
${leadersHTML()}
|
||||
<div id="stats-teams-table" class="stats-teams-table"></div>
|
||||
${legendHTML(COLUMNS)}`;
|
||||
}
|
||||
|
||||
// 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>`;
|
||||
}
|
||||
|
||||
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 `<div class="stats-leaders">${cards.map(leaderCardHTML).join('')}</div>`;
|
||||
}
|
||||
|
||||
function leaderCardHTML({ label, row, value }) {
|
||||
const team = getData().teamById.get(row.teamId);
|
||||
return `
|
||||
<div class="leader-card glass">
|
||||
<span class="leader-label">${label}</span>
|
||||
<div class="leader-team">
|
||||
<img class="flag" src="${flagSrc(team)}" alt="" width="30" height="20" loading="lazy">
|
||||
<span class="leader-name">${team.name}</span>
|
||||
</div>
|
||||
<span class="leader-value">${value}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function sortedTeamStats() {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return [...model.teamStats].sort((a, b) => {
|
||||
const primary = (a[sortKey] - b[sortKey]) * dir;
|
||||
if (primary) return primary;
|
||||
// tiebreak is always GD → GF → name, independent of the sort direction
|
||||
return b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId);
|
||||
});
|
||||
}
|
||||
|
||||
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), start) + paginationHTML(pages);
|
||||
}
|
||||
|
||||
function tableHTML(rows, startIndex) {
|
||||
const head = COLUMNS.map((col) => {
|
||||
const active = col.key === sortKey;
|
||||
const aria = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
||||
const arrow = active ? `<span class="sort-arrow" aria-hidden="true">${sortDir === 'asc' ? '▲' : '▼'}</span>` : '';
|
||||
const tip = t(col.tip);
|
||||
return `<th scope="col" class="col-num${active ? ' sorted' : ''}" aria-sort="${aria}">
|
||||
<button type="button" class="col-sort has-tip" data-sort="${col.key}" data-tip="${tip}" aria-label="${t(col.label)} — ${tip}">${t(col.label)}${arrow}</button>
|
||||
</th>`;
|
||||
}).join('');
|
||||
|
||||
const body = rows.map((row, i) => {
|
||||
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('');
|
||||
return `
|
||||
<tr class="${row.played === 0 ? 'row-idle' : ''}">
|
||||
<td class="col-rank">${startIndex + i + 1}</td>
|
||||
<td class="col-team">
|
||||
<img class="flag" src="${flagSrc(team)}" alt="" width="22" height="15" loading="lazy">
|
||||
<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>
|
||||
<th scope="col" class="col-rank">#</th>
|
||||
<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 = '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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue