world-2026-hub/assets/js/i18n.js
Lucas Kalil b12110b2a5 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>
2026-06-17 10:58:24 -03:00

405 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// i18n.js — EN/PT-BR dictionaries + t(key). Every UI string in the app goes
// through t(); static HTML uses data-i18n / data-i18n-aria attributes.
// Language persists in wc2026_prefs.lang; changing it dispatches "langchange"
// so modules can re-render dynamic content.
import { getPrefs, setPref } from './storage.js';
const dicts = {
en: {
'a11y.skip': 'Skip to content',
'a11y.mainNav': 'Main navigation',
'a11y.langSwitch': 'Switch language',
'nav.home': 'Home',
'nav.matches': 'Matches',
'nav.groups': 'Groups',
'nav.bracket': 'Knockout',
'nav.stadiums': 'Stadiums',
'nav.stats': 'Stats',
'hero.live': 'Live',
'hero.nextMatch': 'Next match',
'hero.nextMatches': 'Next matches',
'hero.inProgress': 'In progress',
'hero.countdownLabel': 'Time until kickoff',
'hero.vs': 'vs',
'countdown.days': 'days',
'countdown.hours': 'hours',
'countdown.minutes': 'min',
'countdown.seconds': 'sec',
'dash.title': 'Tournament overview',
'dash.total': 'Total matches',
'dash.completed': 'Completed',
'dash.upcoming': 'Upcoming',
'dash.teams': 'Teams',
'app.loading': 'Loading data…',
'app.error': 'Could not load tournament data.',
'app.errorHint': 'If you opened index.html directly from disk, serve the folder instead: python -m http.server',
'app.comingSoon': 'This section arrives in a later build step.',
'app.tbd': 'TBD',
'phase.group': 'Group',
'phase.r32': 'Round of 32',
'phase.r16': 'Round of 16',
'phase.qf': 'Quarterfinals',
'phase.sf': 'Semifinals',
'phase.third': 'Third Place',
'phase.final': 'Final',
'schedule.searchPlaceholder': 'Search team, city or stadium…',
'schedule.dateFilter': 'Filter by date',
'schedule.allGroups': 'All groups',
'schedule.allPhases': 'All phases',
'schedule.groupStage': 'Group stage',
'schedule.allTeams': 'All teams',
'schedule.allStadiums': 'All stadiums',
'schedule.sortAsc': 'Date ↑',
'schedule.sortDesc': 'Date ↓',
'schedule.match': 'match',
'schedule.matches': 'matches',
'schedule.noResults': 'No matches found — adjust the filters.',
'schedule.clear': 'Clear filters',
'status.finished': 'Full-time',
'status.pens': 'pens',
'standings.team': 'Team',
'standings.played': 'P',
'standings.won': 'W',
'standings.drawn': 'D',
'standings.lost': 'L',
'standings.gf': 'GF',
'standings.ga': 'GA',
'standings.gd': 'GD',
'standings.pts': 'Pts',
'standings.legendTop2': 'Advance to the Round of 32',
'standings.legendThird': 'In contention for best third place',
'standings.inProgress': 'In progress',
'stadiums.capacity': 'Capacity',
'stadiums.viewMatches': 'View matches',
'status.scheduled': 'Scheduled',
'status.pending': 'Awaiting result',
'modal.close': 'Close',
'modal.date': 'Date & time',
'modal.stadium': 'Stadium',
'modal.city': 'City',
'modal.stats': 'Match stats',
'modal.possession': 'Possession',
'modal.shots': 'Shots',
'modal.cards': 'Cards',
'modal.statsSoon': 'Detailed stats will appear here once available.',
'bracket.groupWinner': 'Group {g} Winner',
'bracket.groupRunnerUp': 'Group {g} Runner-up',
'bracket.bestThird': 'Best 3rd #{n}',
'bracket.champion': 'Champion',
'bracket.zoomIn': 'Zoom in',
'bracket.zoomOut': 'Zoom out',
'bracket.zoomReset': 'Reset zoom',
'sim.mode': 'Simulation',
'sim.reset': 'Reset picks',
'sim.hint': 'Simulation on — click a highlighted match to pick its winner. Real results are never changed.',
'sim.title': 'Simulate',
'sim.pickWinner': 'Pick the winner. Equal or empty score means penalties.',
'sim.save': 'Save pick',
'sim.clear': 'Remove pick',
'sim.chip': 'SIM',
'time.local': 'Local time',
'time.stadium': 'Stadium time',
'time.toggleAria': 'Toggle between local and stadium time',
'schedule.myMatches': 'My matches',
'schedule.occAria': 'Occurrence filter',
'schedule.occAll': 'All matches',
'schedule.occPlayed': 'Played',
'schedule.occUpcoming': 'Upcoming',
'fav.toggle': 'Favorite',
'challenge.title': 'Bracket challenge',
'challenge.correct': '{x} of {y} picks correct',
'share.button': 'Share prediction',
'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.sectionsNav': 'Statistics sections',
'stats.navOverview': 'Overview',
'stats.navTeams': 'Teams',
'stats.navPlayers': 'Players',
'stats.navRecords': 'Records',
'stats.navComparator': 'Comparator',
'stats.navArchive': 'Archive',
'stats.overviewTitle': 'Overview',
'stats.played': 'Matches played',
'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',
'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)',
'tip.rank': 'Final ranking — deepest stage reached, then points',
'stats.rankCol': 'Rank',
'stats.bestAttack': 'Best attack',
'stats.bestDefense': 'Best defense',
'stats.mostCleanSheets': 'Most clean sheets',
'stats.biggestWin': 'Biggest win',
'stats.winStreak': 'Longest win streak',
'stats.championPath': "Champion's path",
'stats.recordsTitle': 'Records',
'stats.highScoreMatch': 'Highest-scoring match',
'stats.formatDebutsTitle': 'Format debuts',
'stats.debutTeams': 'First 48-team World Cup',
'stats.debutMatches': 'Matches (up from 64)',
'stats.debutGroups': 'Groups',
'stats.debutR32': 'A new knockout round',
'stats.debutThird': 'Best third-placed teams advance',
'stats.debutChampion': 'First champion of the 48-team era',
'stats.comparatorTitle': 'Team comparator',
'stats.cmpTeamA': 'Team A',
'stats.cmpTeamB': 'Team B',
'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: {
'a11y.skip': 'Pular para o conteúdo',
'a11y.mainNav': 'Navegação principal',
'a11y.langSwitch': 'Trocar idioma',
'nav.home': 'Início',
'nav.matches': 'Partidas',
'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.nextMatches': 'Próximas partidas',
'hero.inProgress': 'Bola rolando!',
'hero.countdownLabel': 'Tempo até o início da partida',
'hero.vs': 'vs',
'countdown.days': 'dias',
'countdown.hours': 'horas',
'countdown.minutes': 'min',
'countdown.seconds': 'seg',
'dash.title': 'Visão geral do torneio',
'dash.total': 'Total de partidas',
'dash.completed': 'Encerradas',
'dash.upcoming': 'Próximas',
'dash.teams': 'Seleções',
'app.loading': 'Carregando dados…',
'app.error': 'Não foi possível carregar os dados do torneio.',
'app.errorHint': 'Se você abriu o index.html direto do disco, sirva a pasta: python -m http.server',
'app.comingSoon': 'Esta seção chega em uma próxima etapa.',
'app.tbd': 'A definir',
'phase.group': 'Grupo',
'phase.r32': '16 avos de final',
'phase.r16': 'Oitavas de final',
'phase.qf': 'Quartas de final',
'phase.sf': 'Semifinais',
'phase.third': 'Disputa de 3º lugar',
'phase.final': 'Final',
'schedule.searchPlaceholder': 'Buscar seleção, cidade ou estádio…',
'schedule.dateFilter': 'Filtrar por data',
'schedule.allGroups': 'Todos os grupos',
'schedule.allPhases': 'Todas as fases',
'schedule.groupStage': 'Fase de grupos',
'schedule.allTeams': 'Todas as seleções',
'schedule.allStadiums': 'Todos os estádios',
'schedule.sortAsc': 'Data ↑',
'schedule.sortDesc': 'Data ↓',
'schedule.match': 'partida',
'schedule.matches': 'partidas',
'schedule.noResults': 'Nenhuma partida encontrada — ajuste os filtros.',
'schedule.clear': 'Limpar filtros',
'status.finished': 'Encerrado',
'status.pens': 'pên.',
'standings.team': 'Seleção',
'standings.played': 'J',
'standings.won': 'V',
'standings.drawn': 'E',
'standings.lost': 'D',
'standings.gf': 'GP',
'standings.ga': 'GC',
'standings.gd': 'SG',
'standings.pts': 'Pts',
'standings.legendTop2': 'Avançam aos 16 avos de final',
'standings.legendThird': 'Na briga por melhor 3º lugar',
'standings.inProgress': 'Em andamento',
'stadiums.capacity': 'Capacidade',
'stadiums.viewMatches': 'Ver partidas',
'status.scheduled': 'Agendada',
'status.pending': 'Pendente de resultado',
'modal.close': 'Fechar',
'modal.date': 'Data e hora',
'modal.stadium': 'Estádio',
'modal.city': 'Cidade',
'modal.stats': 'Estatísticas',
'modal.possession': 'Posse de bola',
'modal.shots': 'Finalizações',
'modal.cards': 'Cartões',
'modal.statsSoon': 'Estatísticas detalhadas aparecerão aqui quando disponíveis.',
'bracket.groupWinner': '1º do Grupo {g}',
'bracket.groupRunnerUp': '2º do Grupo {g}',
'bracket.bestThird': 'Melhor 3º #{n}',
'bracket.champion': 'Campeão',
'bracket.zoomIn': 'Aproximar',
'bracket.zoomOut': 'Afastar',
'bracket.zoomReset': 'Restaurar zoom',
'sim.mode': 'Simulação',
'sim.reset': 'Limpar palpites',
'sim.hint': 'Simulação ativa — clique numa partida destacada para escolher o vencedor. Resultados reais nunca mudam.',
'sim.title': 'Simular',
'sim.pickWinner': 'Escolha o vencedor. Placar igual ou vazio indica pênaltis.',
'sim.save': 'Salvar palpite',
'sim.clear': 'Remover palpite',
'sim.chip': 'SIM',
'time.local': 'Hora local',
'time.stadium': 'Hora do estádio',
'time.toggleAria': 'Alternar entre hora local e do estádio',
'schedule.myMatches': 'Minhas partidas',
'schedule.occAria': 'Filtro de ocorrência',
'schedule.occAll': 'Todos os jogos',
'schedule.occPlayed': 'Já ocorreram',
'schedule.occUpcoming': 'A ocorrer',
'fav.toggle': 'Favoritar',
'challenge.title': 'Bolão do mata-mata',
'challenge.correct': '{x} de {y} palpites certos',
'share.button': 'Compartilhar palpites',
'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.sectionsNav': 'Seções de estatísticas',
'stats.navOverview': 'Visão geral',
'stats.navTeams': 'Seleções',
'stats.navPlayers': 'Jogadores',
'stats.navRecords': 'Recordes',
'stats.navComparator': 'Comparador',
'stats.navArchive': 'Arquivo',
'stats.overviewTitle': 'Visão geral',
'stats.played': 'Jogos disputados',
'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',
'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)',
'tip.rank': 'Classificação final — fase alcançada, depois pontos',
'stats.rankCol': 'Posição',
'stats.bestAttack': 'Melhor ataque',
'stats.bestDefense': 'Melhor defesa',
'stats.mostCleanSheets': 'Mais clean sheets',
'stats.biggestWin': 'Maior goleada',
'stats.winStreak': 'Maior sequência de vitórias',
'stats.championPath': 'Caminho do campeão',
'stats.recordsTitle': 'Recordes',
'stats.highScoreMatch': 'Jogo com mais gols',
'stats.formatDebutsTitle': 'Estreias do formato',
'stats.debutTeams': 'Primeira Copa com 48 seleções',
'stats.debutMatches': 'Partidas (eram 64)',
'stats.debutGroups': 'Grupos',
'stats.debutR32': 'Uma nova fase eliminatória',
'stats.debutThird': 'Melhores terceiros avançam',
'stats.debutChampion': 'Primeiro campeão da era dos 48',
'stats.comparatorTitle': 'Comparador de times',
'stats.cmpTeamA': 'Time A',
'stats.cmpTeamB': 'Time B',
'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.',
},
};
let lang = 'en';
export function initI18n() {
const saved = getPrefs().lang;
lang = saved ?? (navigator.language?.toLowerCase().startsWith('pt') ? 'pt' : 'en');
document.documentElement.lang = getLocale();
applyI18n();
}
export function t(key) {
return dicts[lang][key] ?? dicts.en[key] ?? key;
}
export function getLang() {
return lang;
}
export function getLocale() {
return lang === 'pt' ? 'pt-BR' : 'en-US';
}
export function setLang(next) {
if (next === lang || !dicts[next]) return;
lang = next;
setPref('lang', next);
document.documentElement.lang = getLocale();
applyI18n();
document.dispatchEvent(new CustomEvent('langchange'));
}
export function applyI18n(root = document) {
for (const el of root.querySelectorAll('[data-i18n]')) {
el.textContent = t(el.dataset.i18n);
}
for (const el of root.querySelectorAll('[data-i18n-aria]')) {
el.setAttribute('aria-label', t(el.dataset.i18nAria));
}
}
// Phase labels come from matches.json in English ("Group A", "Round of 32"…);
// translate the known ones, pass anything else through untouched.
const PHASE_KEYS = {
'Round of 32': 'phase.r32',
'Round of 16': 'phase.r16',
Quarterfinals: 'phase.qf',
Semifinals: 'phase.sf',
'Third Place': 'phase.third',
Final: 'phase.final',
};
export function translatePhase(phase) {
if (phase.startsWith('Group ')) return `${t('phase.group')} ${phase.slice(6)}`;
const key = PHASE_KEYS[phase];
return key ? t(key) : phase;
}