mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
feat(stats): verdict hero + goals-by-round chart (Stage B)
- Verdict hero: champion (trophy + flag + name) and a 2/3/4 podium (runner-up/3rd/4th) above the shared count-up tiles. Gated on the REAL final via computeVerdict() — getBracketTree()'s FINAL node must be a real finished result (!simulated), so a user's simulated champion never leaks into the verdict. Third/fourth from the third-place match, independently. Falls back to the existing aggregate 'in progress' hero until the final is in, so an early merge stays correct. - Goals-by-round chart beside goals-by-stage: group stage split into its 3 matchdays (derived per group; no matchday field exists) plus each knockout round. Hidden until >=2 rounds have data so it never duplicates the goals-by-stage Group bar early on. - stats.js imports getBracketTree from bracket.js (4th circular import with app.js, render-time only). i18n stats.goalsByRound/matchday/verditTitle/ runnerUp/thirdPlace/fourthPlace (EN/PT); verdict-hero CSS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f3751f0042
commit
61e0bb0d0a
3 changed files with 231 additions and 12 deletions
|
|
@ -48,6 +48,94 @@
|
|||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- verdict hero */
|
||||
/* post-tournament hero: champion + podium above the count-up tiles */
|
||||
|
||||
.stats-verdict {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.3rem;
|
||||
}
|
||||
|
||||
.stats-verdict .hero-label { margin-bottom: 0.1rem; }
|
||||
|
||||
.verdict-champion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.verdict-trophy {
|
||||
font-size: 2.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.verdict-flag {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
|
||||
outline: 2px solid var(--accent-gold);
|
||||
}
|
||||
|
||||
.verdict-name {
|
||||
font-size: clamp(1.6rem, 5vw, 2.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
color: var(--accent-gold-soft);
|
||||
}
|
||||
|
||||
.verdict-crown {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.verdict-podium {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.8rem 1.5rem;
|
||||
}
|
||||
|
||||
.verdict-place {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.verdict-rank {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--glass-bg-strong);
|
||||
border: 1px solid var(--glass-border);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.verdict-place-name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.verdict-place-label {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats-verdict .stats-hero-tiles { width: 100%; }
|
||||
|
||||
/* ----------------------------------------------------------- overview */
|
||||
|
||||
.stats-overview-grid {
|
||||
|
|
|
|||
|
|
@ -131,7 +131,13 @@ const dicts = {
|
|||
'stats.decisive': 'Decisive',
|
||||
'stats.draws': 'Draws',
|
||||
'stats.goalsByPhase': 'Goals by stage',
|
||||
'stats.goalsByRound': 'Goals by round',
|
||||
'stats.matchday': 'Matchday',
|
||||
'stats.stageGroup': 'Group stage',
|
||||
'stats.verdictTitle': 'Final verdict',
|
||||
'stats.runnerUp': 'Runner-up',
|
||||
'stats.thirdPlace': 'Third place',
|
||||
'stats.fourthPlace': 'Fourth place',
|
||||
'stats.teamStatsTitle': 'Team statistics',
|
||||
'stats.colGpg': 'G/M',
|
||||
'stats.colCS': 'CS',
|
||||
|
|
@ -278,7 +284,13 @@ const dicts = {
|
|||
'stats.decisive': 'Decididas',
|
||||
'stats.draws': 'Empates',
|
||||
'stats.goalsByPhase': 'Gols por fase',
|
||||
'stats.goalsByRound': 'Gols por rodada',
|
||||
'stats.matchday': 'Rodada',
|
||||
'stats.stageGroup': 'Fase de grupos',
|
||||
'stats.verdictTitle': 'Veredito final',
|
||||
'stats.runnerUp': 'Vice-campeão',
|
||||
'stats.thirdPlace': 'Terceiro lugar',
|
||||
'stats.fourthPlace': 'Quarto lugar',
|
||||
'stats.teamStatsTitle': 'Estatísticas por time',
|
||||
'stats.colGpg': 'G/J',
|
||||
'stats.colCS': 'CS',
|
||||
|
|
|
|||
|
|
@ -6,12 +6,18 @@
|
|||
// 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 { 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.
|
||||
|
|
@ -109,6 +115,8 @@ function buildStatsModel() {
|
|||
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);
|
||||
|
|
@ -121,6 +129,12 @@ function buildStatsModel() {
|
|||
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);
|
||||
|
|
@ -161,11 +175,51 @@ function buildStatsModel() {
|
|||
biggestMargin,
|
||||
cleanSheets,
|
||||
byStage,
|
||||
byRound,
|
||||
verdict: computeVerdict(),
|
||||
teamStats,
|
||||
leaders: computeLeaders(teamStats),
|
||||
};
|
||||
}
|
||||
|
||||
// Matchday (1–3) for every group match, derived per group: a 4-team group plays
|
||||
// two games per matchday, so sorting a group's six fixtures by kickoff and
|
||||
// chunking into pairs reproduces the official matchdays (no stored field).
|
||||
function computeGroupMatchdays(matches) {
|
||||
const byGroup = new Map();
|
||||
for (const m of matches) {
|
||||
if (!m.phase.startsWith('Group ')) continue;
|
||||
if (!byGroup.has(m.phase)) byGroup.set(m.phase, []);
|
||||
byGroup.get(m.phase).push(m);
|
||||
}
|
||||
const matchday = new Map();
|
||||
for (const list of byGroup.values()) {
|
||||
list.sort((a, b) => `${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`) || a.id - b.id);
|
||||
list.forEach((m, i) => matchday.set(m.id, Math.floor(i / 2) + 1));
|
||||
}
|
||||
return matchday;
|
||||
}
|
||||
|
||||
// The tournament verdict — REAL results only. The bracket tree's champion can be
|
||||
// a user simulation; gate on the FINAL node carrying a real finished result
|
||||
// (decide() sets winner from real results first, so !simulated means it's real).
|
||||
// Third/fourth come from the third-place match the same way; each is independent
|
||||
// so the podium degrades gracefully if (somehow) only the final is in.
|
||||
function computeVerdict() {
|
||||
const tree = getBracketTree();
|
||||
const finalNode = tree.nodesByRef.get('FINAL');
|
||||
if (!finalNode || finalNode.simulated || finalNode.result?.status !== 'finished' || !finalNode.winner) {
|
||||
return null;
|
||||
}
|
||||
const verdict = { champion: finalNode.winner, runnerUp: finalNode.loser };
|
||||
const third = tree.third;
|
||||
if (third && !third.simulated && third.result?.status === 'finished' && third.winner) {
|
||||
verdict.third = third.winner;
|
||||
verdict.fourth = third.loser;
|
||||
}
|
||||
return verdict;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
@ -308,28 +362,69 @@ function installImageFallback() {
|
|||
}, 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 `
|
||||
<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>`;
|
||||
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() {
|
||||
|
|
@ -348,7 +443,8 @@ function overviewHTML() {
|
|||
<span class="stat-label">${card.label}</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
${goalsByStageHTML()}`;
|
||||
${goalsByStageHTML()}
|
||||
${goalsByRoundHTML()}`;
|
||||
}
|
||||
|
||||
function footerHTML() {
|
||||
|
|
@ -378,6 +474,29 @@ function goalsByStageHTML() {
|
|||
<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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue