mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
feat(stats): team comparator with diverging bars (Stage F)
- New Comparator sub-nav section (available once >=1 match played). Two team selects (all 48, alphabetical), defaulting to the top-2 ranked teams; the choice survives langchange (module-level cmpA/cmpB). - Diverging mirrored bars: A grows left from the centre metric label, B right; each row scales to max(a,b) so the longer bar is the higher value, and the higher side's number is gold. Metrics P/W/GF/GA/CS/Pts (all non-negative). cmp-grow scaleX animation from the centre edge; off under reduced-motion. - On select change only the bars panel re-renders (keeps focus, replays the animation). - Players side intentionally NOT shipped: a disabled toggle would be a dead control (violates the graceful-degradation rule). The Teams/Players toggle arrives in Stage H with players.json. - i18n comparatorTitle/cmpTeamA/cmpTeamB (EN/PT); CSS for the comparator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
489a44fc2d
commit
b12110b2a5
3 changed files with 189 additions and 1 deletions
|
|
@ -49,16 +49,31 @@ const SECTIONS = [
|
|||
{ 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: () => false, body: () => '' },
|
||||
{ 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;
|
||||
|
|
@ -400,6 +415,13 @@ function render() {
|
|||
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);
|
||||
setupSubNav(root, sections);
|
||||
}
|
||||
|
|
@ -770,6 +792,68 @@ function formatDebutsHTML() {
|
|||
</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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue