Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>WIFX — Women's International Football Rankings</title> | |
| <link rel="stylesheet" href="styles.css"> | |
| <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <script src="https://cdn.plot.ly/plotly-2.35.0.min.js" charset="utf-8"></script> | |
| <style> | |
| .section-header { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| cursor: pointer; | |
| user-select: none; | |
| padding: 0.5rem 0; | |
| gap: 1rem; | |
| } | |
| .section-header:hover h2 { opacity: 0.8; } | |
| .section-header-text { flex: 1; } | |
| .section-header h2 { margin: 0 0 0.25rem; } | |
| .section-header .section-desc { margin: 0; } | |
| .section-chevron { | |
| font-size: 1.1rem; | |
| color: #888; | |
| margin-top: 0.35rem; | |
| flex-shrink: 0; | |
| transition: transform 0.2s ease; | |
| } | |
| .collapsible-section.collapsed .section-chevron { transform: rotate(-90deg); } | |
| .section-body { | |
| overflow: hidden; | |
| transition: max-height 0.3s ease, opacity 0.2s ease; | |
| max-height: 9999px; | |
| opacity: 1; | |
| } | |
| .collapsible-section.collapsed .section-body { | |
| max-height: 0; | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| /* ---- Permalink button ---- */ | |
| .section-permalink { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 2rem; | |
| height: 2rem; | |
| border-radius: 6px; | |
| background: rgba(255,255,255,0.08); | |
| border: 1px solid rgba(255,255,255,0.12); | |
| color: #aaa; | |
| cursor: pointer; | |
| flex-shrink: 0; | |
| margin-top: 0.2rem; | |
| opacity: 0; | |
| transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease; | |
| text-decoration: none; | |
| } | |
| .section-permalink svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } | |
| .collapsible-section:hover .section-permalink { opacity: 1; } | |
| .section-permalink:hover { background: rgba(79,195,247,0.15); color: #4fc3f7; border-color: rgba(79,195,247,0.3); } | |
| .section-permalink.copied { background: rgba(129,199,132,0.2); color: #81c784; border-color: rgba(129,199,132,0.4); opacity: 1; } | |
| /* ---- Copied toast ---- */ | |
| #permalink-toast { | |
| position: fixed; | |
| bottom: 1.5rem; | |
| left: 50%; | |
| transform: translateX(-50%) translateY(8px); | |
| background: #1e2a38; | |
| color: #e6eef8; | |
| border: 1px solid rgba(79,195,247,0.3); | |
| border-radius: 8px; | |
| padding: 0.5rem 1rem; | |
| font-size: 0.8rem; | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: opacity 0.2s ease, transform 0.2s ease; | |
| z-index: 9999; | |
| } | |
| #permalink-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| /* ---- Iframe-native layout ---- */ | |
| body { padding-top: 0.5rem; } | |
| .collapsible-section { border-top: none; } | |
| </style> | |
| </head> | |
| <body class="iframe-mode"> | |
| <div id="permalink-toast">Link copied</div> | |
| <main> | |
| <!-- WIFX Score — open by default --> | |
| <section id="wifx-score" class="collapsible-section"> | |
| <div class="section-header" data-target="wifx-score"> | |
| <div class="section-header-text"> | |
| <h2>WIFX Score</h2> | |
| <p class="section-desc">Proprietary algorithm combining strength of schedule, competition quality, and career achievement across 20+ competitions worldwide.</p> | |
| </div> | |
| <a class="section-permalink" data-section="wifx-score" title="Copy link" onclick="copyPermalink(event,this)"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a> | |
| <span class="section-chevron">▼</span> | |
| </div> | |
| <div class="section-body"> | |
| <div class="chart-row"> | |
| <div class="chart-box full"> | |
| <h3>Top 100 WIFXScores</h3> | |
| <div id="wifx-top100-list" class="player-list"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Cognitive Performance Index — collapsed by default --> | |
| <section id="wifx-cpi" class="collapsible-section collapsed"> | |
| <div class="section-header" data-target="wifx-cpi"> | |
| <div class="section-header-text"> | |
| <h2>Cognitive Performance Index (CPI)</h2> | |
| <p class="section-desc">Proxy model quantifying cognitive load and positional fluency. Combines Position Familiarity (PF), Natural Instincts (NI), and Reaction Time (RT) derived from 1.8M records across 8 competitions.</p> | |
| </div> | |
| <a class="section-permalink" data-section="wifx-cpi" title="Copy link" onclick="copyPermalink(event,this)"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a> | |
| <span class="section-chevron">▼</span> | |
| </div> | |
| <div class="section-body"> | |
| <div class="chart-row"> | |
| <div class="chart-box full"> | |
| <h3>Top 100 CPI Scores</h3> | |
| <div id="wifx-cpi-list" class="player-list"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- WIFX Global Rankings — collapsed by default --> | |
| <section id="wifx-teams" class="collapsible-section collapsed"> | |
| <div class="section-header" data-target="wifx-teams"> | |
| <div class="section-header-text"> | |
| <h2>WIFX Global Rankings</h2> | |
| <p class="section-desc">Offensive and defensive ratings for national teams across major tournaments (WWC 2019, WWC 2023, Euros 2022, Euros 2025).</p> | |
| </div> | |
| <a class="section-permalink" data-section="wifx-teams" title="Copy link" onclick="copyPermalink(event,this)"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a> | |
| <span class="section-chevron">▼</span> | |
| </div> | |
| <div class="section-body"> | |
| <div class="chart-row"> | |
| <div class="chart-box full"> | |
| <div class="tab-bar" id="wifx-teams-tabs"> | |
| <button class="active" data-metric="wifx_global_ranking">WIFX Global Ranking</button> | |
| <button data-metric="net">Net Rating</button> | |
| <button data-metric="offensive">Offensive</button> | |
| </div> | |
| <div id="wifx-teams-chart" class="plotly-chart"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- WIFX Club Team Rankings — collapsed by default --> | |
| <section id="wifx-clubs" class="collapsible-section collapsed"> | |
| <div class="section-header" data-target="wifx-clubs"> | |
| <div class="section-header-text"> | |
| <h2>WIFX Club Team Rankings</h2> | |
| <p class="section-desc">Offensive and defensive ratings for club teams across major domestic leagues and continental competitions.</p> | |
| </div> | |
| <a class="section-permalink" data-section="wifx-clubs" title="Copy link" onclick="copyPermalink(event,this)"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a> | |
| <span class="section-chevron">▼</span> | |
| </div> | |
| <div class="section-body"> | |
| <div class="chart-row"> | |
| <div class="chart-box full"> | |
| <div class="tab-bar" id="wifx-clubs-tabs"> | |
| <button class="active" data-metric="wifx_global_club_ranking">WIFX Club Ranking</button> | |
| <button data-metric="net">Net Rating</button> | |
| <button data-metric="offensive">Offensive</button> | |
| </div> | |
| <div id="wifx-clubs-chart" class="plotly-chart"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- WIFX Confederation Club Scores — collapsed by default --> | |
| <section id="wifx-confederations" class="collapsible-section collapsed"> | |
| <div class="section-header" data-target="wifx-confederations"> | |
| <div class="section-header-text"> | |
| <h2>WIFX Confederation Club Scores</h2> | |
| <p class="section-desc">Club rankings from confederation championships (UEFA, AFC, CONMEBOL, CONCACAF, CAF, OFC) — 15 years (2010–2025).</p> | |
| </div> | |
| <a class="section-permalink" data-section="wifx-confederations" title="Copy link" onclick="copyPermalink(event,this)"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a> | |
| <span class="section-chevron">▼</span> | |
| </div> | |
| <div class="section-body"> | |
| <div class="chart-row"> | |
| <div class="chart-box full"> | |
| <div class="tab-bar" id="conf-tabs"> | |
| <button class="active" data-conf="all">All</button> | |
| <button data-conf="UEFA">UEFA</button> | |
| <button data-conf="CONMEBOL">CONMEBOL</button> | |
| <button data-conf="AFC">AFC</button> | |
| <button data-conf="CONCACAF">CONCACAF</button> | |
| <button data-conf="CAF">CAF</button> | |
| <button data-conf="OFC">OFC</button> | |
| </div> | |
| <div id="conf-chart" class="plotly-chart"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- WIFX Historical Scores — collapsed by default --> | |
| <section id="wifx-historical" class="collapsible-section collapsed"> | |
| <div class="section-header" data-target="wifx-historical"> | |
| <div class="section-header-text"> | |
| <h2>WIFX Historical Scores</h2> | |
| <p class="section-desc">Retired players ranked by WIFX Score. Legends of the game who have stepped away from professional football.</p> | |
| </div> | |
| <a class="section-permalink" data-section="wifx-historical" title="Copy link" onclick="copyPermalink(event,this)"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a> | |
| <span class="section-chevron">▼</span> | |
| </div> | |
| <div class="section-body"> | |
| <div class="chart-row"> | |
| <div class="chart-box full"> | |
| <h3>Historical WIFX Rankings</h3> | |
| <div id="wifx-historical-list" class="player-list"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <!-- Hover card — WIFX Score --> | |
| <div id="player-card" class="player-card hidden"> | |
| <div class="pc-header"> | |
| <div class="pc-photo" id="pc-photo"></div> | |
| <div class="pc-identity"> | |
| <div class="pc-name" id="pc-name"></div> | |
| <div class="pc-team" id="pc-team"></div> | |
| <div class="pc-comp" id="pc-comp"></div> | |
| </div> | |
| </div> | |
| <div class="pc-stats"> | |
| <div class="pc-stat"><span class="pc-stat-label">WIFX Score</span><span class="pc-stat-value" id="pc-wifx"></span></div> | |
| <div class="pc-stat"><span class="pc-stat-label">Offensive</span><span class="pc-stat-value" id="pc-off"></span></div> | |
| <div class="pc-stat"><span class="pc-stat-label">Creative</span><span class="pc-stat-value" id="pc-cre"></span></div> | |
| <div class="pc-stat"><span class="pc-stat-label">Defensive</span><span class="pc-stat-value" id="pc-def"></span></div> | |
| <div class="pc-stat"><span class="pc-stat-label">Events</span><span class="pc-stat-value" id="pc-events"></span></div> | |
| <div class="pc-stat"><span class="pc-stat-label">Accolades</span><span class="pc-stat-value" id="pc-awards"></span></div> | |
| </div> | |
| </div> | |
| <!-- Hover card — CPI --> | |
| <div id="cpi-card" class="player-card hidden"> | |
| <div class="pc-header"> | |
| <div class="pc-photo" id="cpi-photo"></div> | |
| <div class="pc-identity"> | |
| <div class="pc-name" id="cpi-name"></div> | |
| <div class="pc-team" id="cpi-team"></div> | |
| <div class="pc-comp" id="cpi-position"></div> | |
| </div> | |
| </div> | |
| <div class="pc-stats"> | |
| <div class="pc-stat"><span class="pc-stat-label">CPI Score</span><span class="pc-stat-value" id="cpi-score"></span></div> | |
| <div class="pc-stat"><span class="pc-stat-label">Position Familiarity</span><span class="pc-stat-value" id="cpi-pf"></span></div> | |
| <div class="pc-stat"><span class="pc-stat-label">Natural Instincts</span><span class="pc-stat-value" id="cpi-ni"></span></div> | |
| <div class="pc-stat"><span class="pc-stat-label">Reaction Time</span><span class="pc-stat-value" id="cpi-rt"></span></div> | |
| <div class="pc-stat"><span class="pc-stat-label">Events</span><span class="pc-stat-value" id="cpi-events"></span></div> | |
| <div class="pc-stat"><span class="pc-stat-label">Matches</span><span class="pc-stat-value" id="cpi-matches"></span></div> | |
| </div> | |
| </div> | |
| <script> | |
| const DARK = { | |
| paper_bgcolor: 'rgba(0,0,0,0)', | |
| plot_bgcolor: 'rgba(0,0,0,0)', | |
| font: { color: '#e6eef8', family: 'Inter, system-ui, sans-serif', size: 12 }, | |
| margin: { l: 140, r: 20, t: 30, b: 50 }, | |
| xaxis: { gridcolor: 'rgba(255,255,255,0.06)', zerolinecolor: 'rgba(255,255,255,0.1)' }, | |
| yaxis: { gridcolor: 'rgba(255,255,255,0.06)', zerolinecolor: 'rgba(255,255,255,0.1)' }, | |
| }; | |
| const CFG = { displayModeBar: true, responsive: true, displaylogo: false }; | |
| const COLORS = ['#4fc3f7','#81c784','#ffb74d','#f06292','#ba68c8','#4dd0e1','#aed581','#ff8a65']; | |
| const TOP_N = 100; | |
| const TEAM_TOP_N = 25; | |
| const GROUP_SIZE = 25; | |
| function hbar(divId, labels, values, color, hoverText) { | |
| const trace = { | |
| y: labels.slice().reverse(), | |
| x: values.slice().reverse(), | |
| type: 'bar', orientation: 'h', | |
| marker: { color: Array.isArray(color) ? color.slice().reverse() : (color || '#4fc3f7') }, | |
| text: hoverText ? hoverText.slice().reverse() : undefined, | |
| hoverinfo: hoverText ? 'text' : 'x+y', | |
| }; | |
| const barHeight = labels.length > 50 ? 22 : 28; | |
| const layout = { | |
| paper_bgcolor: '#f0f0f0', | |
| plot_bgcolor: '#f0f0f0', | |
| xaxis: { title: 'Value', gridcolor: '#ccc' }, | |
| yaxis: { automargin: true, gridcolor: '#ccc', tickfont: { size: 10 } }, | |
| height: Math.max(400, labels.length * barHeight), | |
| margin: { l: 180, r: 20, t: 30, b: 50 } | |
| }; | |
| Plotly.newPlot(divId, [trace], layout, CFG); | |
| } | |
| // ---- WIFX Global Rankings ---- | |
| function renderWIFXTeams(data) { | |
| if (!data || !data.all_teams) return; | |
| const chartId = 'wifx-teams-chart'; | |
| const aggregated = data.all_teams.filter(t => t.matches >= 200); | |
| function draw(view) { | |
| const keyMap = { wifx_global_ranking: 'wifx_global_ranking', offensive: 'offensive_rating', net: 'net_rating' }; | |
| const key = keyMap[view]; | |
| const sorted = [...aggregated].sort((a, b) => b[key] - a[key]).slice(0, TEAM_TOP_N); | |
| const colorVals = ['#ba68c8', '#81c784', '#4fc3f7', '#ffb74d', '#f06292', '#4dd0e1', '#aed581', '#ff8a65']; | |
| const barColors = sorted.map((_, i) => colorVals[i % colorVals.length]); | |
| hbar(chartId, | |
| sorted.map(d => d.team), | |
| sorted.map(d => d[key] ?? 0), | |
| barColors, | |
| sorted.map(d => `${d.team}<br>${view}: ${(d[key] ?? 0).toFixed(1)}<br>Off: ${(d.offensive_rating ?? 0).toFixed(1)} | Def: ${(d.defensive_rating ?? 0).toFixed(1)}<br>Goals: ${d.goals_scored}-${d.goals_conceded}<br>Matches: ${d.matches}`) | |
| ); | |
| } | |
| draw('wifx_global_ranking'); | |
| document.getElementById('wifx-teams-tabs').addEventListener('click', e => { | |
| if (!e.target.dataset.metric) return; | |
| document.querySelectorAll('#wifx-teams-tabs button').forEach(b => b.classList.remove('active')); | |
| e.target.classList.add('active'); | |
| draw(e.target.dataset.metric); | |
| }); | |
| } | |
| // ---- WIFX Club Team Rankings ---- | |
| function renderWIFXClubs(data) { | |
| if (!data || !data.all_teams) return; | |
| const chartId = 'wifx-clubs-chart'; | |
| const aggregated = data.all_teams.filter(t => t.matches >= 100); | |
| function draw(view) { | |
| const keyMap = { wifx_global_club_ranking: 'wifx_global_club_ranking', offensive: 'offensive_rating', net: 'net_rating' }; | |
| const key = keyMap[view]; | |
| const sorted = [...aggregated].sort((a, b) => b[key] - a[key]).slice(0, TEAM_TOP_N); | |
| const colorVals = ['#ba68c8', '#81c784', '#4fc3f7', '#ffb74d', '#f06292', '#4dd0e1', '#aed581', '#ff8a65']; | |
| const barColors = sorted.map((_, i) => colorVals[i % colorVals.length]); | |
| hbar(chartId, | |
| sorted.map(d => d.team), | |
| sorted.map(d => d[key] ?? 0), | |
| barColors, | |
| sorted.map(d => `${d.team}<br>${view}: ${(d[key] ?? 0).toFixed(1)}<br>Off: ${(d.offensive_rating ?? 0).toFixed(1)}<br>Wins: ${d.championship_wins || 0}<br>Matches: ${d.matches}`) | |
| ); | |
| } | |
| draw('wifx_global_club_ranking'); | |
| document.getElementById('wifx-clubs-tabs').addEventListener('click', e => { | |
| if (!e.target.dataset.metric) return; | |
| document.querySelectorAll('#wifx-clubs-tabs button').forEach(b => b.classList.remove('active')); | |
| e.target.classList.add('active'); | |
| draw(e.target.dataset.metric); | |
| }); | |
| } | |
| // Shared aggregation for teams/clubs | |
| function aggregateTeams(teams) { | |
| const agg = {}; | |
| for (const t of teams) { | |
| if (!agg[t.team]) { | |
| agg[t.team] = { team: t.team, matches: 0, goals_scored: 0, goals_conceded: 0, offensive_rating_sum: 0, defensive_rating_sum: 0, net_rating_sum: 0, composite_rating_sum: 0 }; | |
| } | |
| agg[t.team].matches += t.matches || 0; | |
| agg[t.team].goals_scored += t.goals_scored || 0; | |
| agg[t.team].goals_conceded += t.goals_conceded || 0; | |
| agg[t.team].offensive_rating_sum += (t.offensive_rating || 0) * (t.matches || 1); | |
| agg[t.team].defensive_rating_sum += (t.defensive_rating || 0) * (t.matches || 1); | |
| agg[t.team].net_rating_sum += (t.net_rating || 0) * (t.matches || 1); | |
| agg[t.team].composite_rating_sum += (t.composite_rating || 0) * (t.matches || 1); | |
| } | |
| return Object.values(agg).map(t => ({ | |
| team: t.team, | |
| matches: t.matches, | |
| goals_scored: t.goals_scored, | |
| goals_conceded: t.goals_conceded, | |
| offensive_rating: t.matches ? t.offensive_rating_sum / t.matches : 0, | |
| defensive_rating: t.matches ? t.defensive_rating_sum / t.matches : 0, | |
| net_rating: t.matches ? t.net_rating_sum / t.matches : 0, | |
| composite_rating: t.matches ? t.composite_rating_sum / t.matches : 0, | |
| })); | |
| } | |
| // ---- WIFX Confederation Club Scores ---- | |
| function renderConfederations(data) { | |
| if (!data || !data.club_confederation_scores) return; | |
| const clubs = data.club_confederation_scores; | |
| const CONF_COLORS = { UEFA:'#4fc3f7', CONCACAF:'#81c784', CONMEBOL:'#ffb74d', CAF:'#f06292', AFC:'#ba68c8', OFC:'#4dd0e1' }; | |
| // Aggregate by team | |
| const agg = {}; | |
| for (const c of clubs) { | |
| if (!agg[c.team]) { | |
| agg[c.team] = { team: c.team, country: c.country, confederation: c.confederation, championships_won: 0, finals_reached: 0, wifx_club_score_sum: 0, years: 0 }; | |
| } | |
| agg[c.team].championships_won += c.championships_won || 0; | |
| agg[c.team].finals_reached += c.finals_reached || 0; | |
| agg[c.team].wifx_club_score_sum += c.wifx_club_score || 0; | |
| agg[c.team].years += 1; | |
| } | |
| const aggregated = Object.values(agg).map(t => ({ | |
| ...t, wifx_club_score: t.wifx_club_score_sum / t.years | |
| })); | |
| function draw(conf) { | |
| const filtered = conf === 'all' ? aggregated : aggregated.filter(c => c.confederation === conf); | |
| const sorted = [...filtered].sort((a, b) => b.wifx_club_score - a.wifx_club_score).slice(0, TEAM_TOP_N); | |
| const trace = { | |
| y: sorted.map(d => d.team).reverse(), | |
| x: sorted.map(d => d.wifx_club_score).reverse(), | |
| type: 'bar', orientation: 'h', | |
| marker: { color: conf === 'all' ? sorted.map(d => CONF_COLORS[d.confederation] || '#aaa').reverse() : CONF_COLORS[conf] || '#4fc3f7' }, | |
| text: sorted.map(d => `${d.team} (${d.confederation})<br>Score: ${(d.wifx_club_score ?? 0).toFixed(1)}<br>Championships: ${d.championships_won}<br>Finals: ${d.finals_reached}`).reverse(), | |
| hoverinfo: 'text', | |
| }; | |
| Plotly.newPlot('conf-chart', [trace], { | |
| paper_bgcolor: '#f0f0f0', | |
| plot_bgcolor: '#f0f0f0', | |
| xaxis: { title: 'WIFX Club Score', gridcolor: '#ccc' }, | |
| yaxis: { automargin: true, gridcolor: '#ccc' }, | |
| height: Math.max(400, sorted.length * 28), | |
| margin: { l: 150, r: 20, t: 30, b: 50 } | |
| }, CFG); | |
| } | |
| draw('all'); | |
| document.getElementById('conf-tabs').addEventListener('click', e => { | |
| if (!e.target.dataset.conf) return; | |
| document.querySelectorAll('#conf-tabs button').forEach(b => b.classList.remove('active')); | |
| e.target.classList.add('active'); | |
| draw(e.target.dataset.conf); | |
| }); | |
| } | |
| // ---- Permalink copy ---- | |
| const BASE_URL = 'https://huggingface.co/spaces/AMadabhushi/WIFX'; | |
| let toastTimer; | |
| function copyPermalink(e, btn) { | |
| e.stopPropagation(); // don't toggle the section | |
| const section = btn.dataset.section; | |
| const url = BASE_URL + '?section=' + section; | |
| navigator.clipboard.writeText(url).then(() => { | |
| btn.classList.add('copied'); | |
| const toast = document.getElementById('permalink-toast'); | |
| toast.classList.add('show'); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => { | |
| btn.classList.remove('copied'); | |
| toast.classList.remove('show'); | |
| }, 2000); | |
| }); | |
| } | |
| // ---- Iframe embed API ---- | |
| (function () { | |
| const params = new URLSearchParams(window.location.search); | |
| // ?section=<id> — open only that section, collapse the rest | |
| const targetSection = params.get('section'); | |
| if (targetSection) { | |
| document.querySelectorAll('.collapsible-section').forEach(s => { | |
| s.id === targetSection | |
| ? s.classList.remove('collapsed') | |
| : s.classList.add('collapsed'); | |
| }); | |
| } | |
| // Report height to parent whenever layout changes | |
| function reportHeight() { | |
| parent.postMessage({ type: 'wifx:height', height: document.body.scrollHeight }, '*'); | |
| } | |
| // Report after fonts/images settle and after data loads | |
| window.addEventListener('load', () => setTimeout(reportHeight, 400)); | |
| // postMessage API — parent (WordPress) can drive the dashboard | |
| // Supported actions: | |
| // { action: 'open', section: 'wifx-score' } — expand a section | |
| // { action: 'collapse', section: 'wifx-teams' } — collapse a section | |
| // { action: 'toggle', section: 'wifx-cpi' } — toggle a section | |
| // { action: 'ping' } — responds with wifx:ready | |
| window.addEventListener('message', e => { | |
| if (!e.data || typeof e.data !== 'object') return; | |
| const { action, section } = e.data; | |
| if (action === 'ping') { | |
| e.source.postMessage({ type: 'wifx:ready' }, e.origin || '*'); | |
| return; | |
| } | |
| if (!section) return; | |
| const el = document.getElementById(section); | |
| if (!el) return; | |
| if (action === 'open') el.classList.remove('collapsed'); | |
| if (action === 'collapse') el.classList.add('collapsed'); | |
| if (action === 'toggle') el.classList.toggle('collapsed'); | |
| // Resize Plotly charts if section just opened | |
| if (!el.classList.contains('collapsed')) { | |
| el.querySelectorAll('.plotly-chart').forEach(c => { if (c.layout) Plotly.Plots.resize(c); }); | |
| } | |
| setTimeout(reportHeight, 350); | |
| e.source && e.source.postMessage({ type: 'wifx:ack', action, section }, e.origin || '*'); | |
| }); | |
| // Re-report height whenever a section is toggled | |
| document.addEventListener('click', e => { | |
| if (e.target.closest('.section-header')) setTimeout(reportHeight, 350); | |
| }); | |
| })(); | |
| // ---- Collapsible sections ---- | |
| document.querySelectorAll('.section-header').forEach(header => { | |
| header.addEventListener('click', () => { | |
| const section = header.closest('.collapsible-section'); | |
| const wasCollapsed = section.classList.contains('collapsed'); | |
| section.classList.toggle('collapsed'); | |
| // Resize any Plotly charts inside the section once it opens | |
| if (wasCollapsed) { | |
| const charts = section.querySelectorAll('.plotly-chart'); | |
| charts.forEach(el => { | |
| if (el.layout) Plotly.Plots.resize(el); | |
| }); | |
| } | |
| }); | |
| }); | |
| // ---- CPI Player List ---- | |
| function renderCPIList(cpiData) { | |
| if (!cpiData || !Array.isArray(cpiData)) return; | |
| const sorted = [...cpiData].sort((a, b) => b.cpi_score - a.cpi_score).slice(0, TOP_N); | |
| const MAX_CPI = 10; | |
| const listEl = document.getElementById('wifx-cpi-list'); | |
| if (!listEl) return; | |
| function renderCPIRow(p, i) { | |
| const pct = ((p.cpi_score / MAX_CPI) * 100).toFixed(0); | |
| const hue = Math.round(Math.min(p.cpi_score / MAX_CPI, 1) * 120); | |
| const compShort = (p.competition || '').replace(/_/g, ' '); | |
| return `<div class="pl-row" data-cpi-idx="${i}"> | |
| <div class="pl-score-ring" style="--pct:${Math.min(pct, 100)};--hue:${hue}"> | |
| <span>${p.cpi_score.toFixed(1)}</span> | |
| </div> | |
| <div class="pl-rank">${i + 1}</div> | |
| <div class="pl-info"> | |
| <div class="pl-name">${p.player}</div> | |
| <div class="pl-meta">${p.team} · ${p.primary_position}</div> | |
| </div> | |
| </div>`; | |
| } | |
| let html = ''; | |
| const groups = Math.ceil(sorted.length / GROUP_SIZE); | |
| for (let g = 0; g < groups; g++) { | |
| const start = g * GROUP_SIZE; | |
| const end = Math.min(start + GROUP_SIZE, sorted.length); | |
| const rows = sorted.slice(start, end).map((p, j) => renderCPIRow(p, start + j)).join(''); | |
| if (g === 0) { | |
| html += `<div class="pl-group">${rows}</div>`; | |
| } else { | |
| const label = `${start + 1}–${end}`; | |
| html += `<div class="pl-group-toggle" data-target="cpi-group-${g}"> | |
| <span class="pl-toggle-label">Show ${label}</span> | |
| <span class="pl-toggle-arrow">▼</span> | |
| </div> | |
| <div class="pl-group pl-group-collapsed" id="cpi-group-${g}">${rows}</div>`; | |
| } | |
| } | |
| listEl.innerHTML = html; | |
| listEl.querySelectorAll('.pl-group-toggle').forEach(toggle => { | |
| const gIdx = +toggle.dataset.target.replace('cpi-group-', ''); | |
| const s = gIdx * GROUP_SIZE + 1; | |
| const e = Math.min((gIdx + 1) * GROUP_SIZE, sorted.length); | |
| const rangeLabel = `${s}\u2013${e}`; | |
| toggle.addEventListener('click', () => { | |
| const target = document.getElementById(toggle.dataset.target); | |
| const collapsed = target.classList.toggle('pl-group-collapsed'); | |
| toggle.querySelector('.pl-toggle-arrow').innerHTML = collapsed ? '▼' : '▲'; | |
| toggle.querySelector('.pl-toggle-label').textContent = (collapsed ? 'Show ' : 'Hide ') + rangeLabel; | |
| }); | |
| }); | |
| // CPI hover card | |
| const cpiCard = document.getElementById('cpi-card'); | |
| let cpiHideTimeout; | |
| listEl.addEventListener('mouseover', e => { | |
| const row = e.target.closest('.pl-row'); | |
| if (!row) return; | |
| clearTimeout(cpiHideTimeout); | |
| const idx = +row.dataset.cpiIdx; | |
| const p = sorted[idx]; | |
| if (!p) return; | |
| document.getElementById('cpi-photo').textContent = p.player.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); | |
| document.getElementById('cpi-name').textContent = p.player; | |
| document.getElementById('cpi-team').textContent = p.team; | |
| document.getElementById('cpi-position').textContent = p.primary_position; | |
| document.getElementById('cpi-score').textContent = p.cpi_score.toFixed(2); | |
| document.getElementById('cpi-pf').textContent = (p.pf_score ?? 0).toFixed(2); | |
| document.getElementById('cpi-ni').textContent = (p.ni_score ?? 0).toFixed(2); | |
| document.getElementById('cpi-rt').textContent = (p.rt_score ?? 0).toFixed(2); | |
| document.getElementById('cpi-events').textContent = (p.total_events || 0).toLocaleString(); | |
| document.getElementById('cpi-matches').textContent = p.match_count || 0; | |
| const nameEl = row.querySelector('.pl-name'); | |
| const nameRect = nameEl.getBoundingClientRect(); | |
| const cardW = 320; | |
| let left = nameRect.left + Math.min(nameEl.scrollWidth, nameRect.width) + 16; | |
| if (left + cardW > window.innerWidth) left = window.innerWidth - cardW - 16; | |
| let top = nameRect.top + window.scrollY - 40; | |
| cpiCard.style.left = left + 'px'; | |
| cpiCard.style.top = top + 'px'; | |
| cpiCard.classList.remove('hidden'); | |
| }); | |
| listEl.addEventListener('mouseout', e => { | |
| if (!e.target.closest('.pl-row')) return; | |
| cpiHideTimeout = setTimeout(() => cpiCard.classList.add('hidden'), 200); | |
| }); | |
| cpiCard.addEventListener('mouseenter', () => clearTimeout(cpiHideTimeout)); | |
| cpiCard.addEventListener('mouseleave', () => cpiCard.classList.add('hidden')); | |
| } | |
| // ---- Load and render all data ---- | |
| async function loadAllData() { | |
| try { | |
| // Load all data in parallel | |
| const [scores, historicalScores, cpiScores, teams, clubs, confs] = await Promise.all([ | |
| fetch('output/wifx_scores.json').then(r => r.json()), | |
| fetch('output/wifx_historical_scores.json').then(r => r.json()), | |
| fetch('output/wifx_cpi_player_scores.json').then(r => r.json()), | |
| fetch('output/wifx_national_team_scores.json').then(r => r.json()), | |
| fetch('output/wifx_club_team_scores.json').then(r => r.json()), | |
| fetch('output/wifx_club_confederation_scores.json').then(r => r.json()), | |
| ]); | |
| // WIFX Score - Top 100 Player List | |
| if (scores.all_players) { | |
| // Awards counts come from the JSON (`awards: {nwsl, intl}` per player), | |
| // sourced from data/manual_overrides/player_awards.csv via | |
| // build_dashboard_data.py. Render-only — no scoring math here. | |
| const totalAwards = (p) => { | |
| const a = p.awards || {}; | |
| return (a.nwsl || 0) + (a.intl || 0); | |
| }; | |
| const rankSort = (a, b) => b.WIFXScore - a.WIFXScore; | |
| const filtered = scores.all_players.filter(p => p.total_events >= 1000); | |
| const activePlayers = filtered.slice().sort(rankSort).slice(0, TOP_N); | |
| // Historical players from separate JSON | |
| const histFiltered = (historicalScores.all_players || []).filter(p => p.total_events >= 100); | |
| const historicalPlayers = histFiltered.slice().sort(rankSort).slice(0, 50); | |
| const MAX_DISPLAY = 25; | |
| // --- Render player list with collapsible groups of 25 --- | |
| const listEl = document.getElementById('wifx-top100-list'); | |
| function renderPlayerRow(p, i, listId) { | |
| const pct = ((p.WIFXScore / MAX_DISPLAY) * 100).toFixed(0); | |
| const hue = Math.round(Math.min(p.WIFXScore / MAX_DISPLAY, 1) * 120); | |
| const compShort = p.display_comp || ''; | |
| const awardCount = totalAwards(p); | |
| const awardsTag = awardCount > 0 ? `<span class="pl-awards">★ ${awardCount}</span>` : ''; | |
| return `<div class="pl-row" data-idx="${i}" data-list="${listId}"> | |
| <div class="pl-score-ring" style="--pct:${Math.min(pct, 100)};--hue:${hue}"> | |
| <span>${p.WIFXScore.toFixed(1)}</span> | |
| </div> | |
| <div class="pl-rank">${i + 1}</div> | |
| <div class="pl-info"> | |
| <div class="pl-name">${p.player}${awardsTag}</div> | |
| <div class="pl-meta">${p.team}${compShort ? ' · ' + compShort : ''}</div> | |
| </div> | |
| </div>`; | |
| } | |
| function renderPlayerList(container, players, listId) { | |
| let html = ''; | |
| const groups = Math.ceil(players.length / GROUP_SIZE); | |
| for (let g = 0; g < groups; g++) { | |
| const start = g * GROUP_SIZE; | |
| const end = Math.min(start + GROUP_SIZE, players.length); | |
| const rows = players.slice(start, end).map((p, j) => renderPlayerRow(p, start + j, listId)).join(''); | |
| if (g === 0) { | |
| html += `<div class="pl-group">${rows}</div>`; | |
| } else { | |
| const label = `${start + 1}–${end}`; | |
| html += `<div class="pl-group-toggle" data-target="${listId}-group-${g}"> | |
| <span class="pl-toggle-label">Show ${label}</span> | |
| <span class="pl-toggle-arrow">▼</span> | |
| </div> | |
| <div class="pl-group pl-group-collapsed" id="${listId}-group-${g}">${rows}</div>`; | |
| } | |
| } | |
| container.innerHTML = html; | |
| container.querySelectorAll('.pl-group-toggle').forEach(toggle => { | |
| const range = toggle.dataset.target.replace(`${listId}-group-`, ''); | |
| const gIdx = +range; | |
| const s = gIdx * GROUP_SIZE + 1; | |
| const e = Math.min((gIdx + 1) * GROUP_SIZE, players.length); | |
| const rangeLabel = `${s}\u2013${e}`; | |
| toggle.addEventListener('click', () => { | |
| const target = document.getElementById(toggle.dataset.target); | |
| const collapsed = target.classList.toggle('pl-group-collapsed'); | |
| toggle.querySelector('.pl-toggle-arrow').innerHTML = collapsed ? '▼' : '▲'; | |
| toggle.querySelector('.pl-toggle-label').textContent = (collapsed ? 'Show ' : 'Hide ') + rangeLabel; | |
| }); | |
| }); | |
| } | |
| renderPlayerList(listEl, activePlayers, 'active'); | |
| // Render historical players | |
| const histEl = document.getElementById('wifx-historical-list'); | |
| if (histEl) renderPlayerList(histEl, historicalPlayers, 'historical'); | |
| // Shared hover card logic | |
| const card = document.getElementById('player-card'); | |
| let hideTimeout; | |
| const allLists = { active: activePlayers, historical: historicalPlayers }; | |
| function attachHoverCard(containerEl) { | |
| containerEl.addEventListener('mouseover', e => { | |
| const row = e.target.closest('.pl-row'); | |
| if (!row) return; | |
| clearTimeout(hideTimeout); | |
| const idx = +row.dataset.idx; | |
| const listId = row.dataset.list; | |
| const p = allLists[listId][idx]; | |
| if (!p) return; | |
| document.getElementById('pc-photo').textContent = p.player.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); | |
| document.getElementById('pc-name').textContent = p.player; | |
| document.getElementById('pc-team').textContent = p.team; | |
| document.getElementById('pc-comp').textContent = p.display_comp || ''; | |
| document.getElementById('pc-wifx').textContent = p.WIFXScore.toFixed(2); | |
| document.getElementById('pc-off').textContent = (p.offensive_score ?? 0).toFixed(1); | |
| document.getElementById('pc-cre').textContent = (p.creative_score ?? 0).toFixed(1); | |
| document.getElementById('pc-def').textContent = (p.defensive_score ?? 0).toFixed(1); | |
| document.getElementById('pc-events').textContent = p.total_events.toLocaleString(); | |
| document.getElementById('pc-awards').textContent = totalAwards(p) || '0'; | |
| const nameEl = row.querySelector('.pl-name'); | |
| const nameRect = nameEl.getBoundingClientRect(); | |
| const textRight = nameRect.left + Math.min(nameEl.scrollWidth, nameRect.width); | |
| const cardW = 320; | |
| let left = textRight + 16; | |
| if (left + cardW > window.innerWidth) left = window.innerWidth - cardW - 16; | |
| let top = nameRect.top + window.scrollY - 40; | |
| card.style.left = left + 'px'; | |
| card.style.top = top + 'px'; | |
| card.classList.remove('hidden'); | |
| }); | |
| containerEl.addEventListener('mouseout', e => { | |
| const row = e.target.closest('.pl-row'); | |
| if (!row) return; | |
| hideTimeout = setTimeout(() => card.classList.add('hidden'), 200); | |
| }); | |
| } | |
| attachHoverCard(listEl); | |
| if (histEl) attachHoverCard(histEl); | |
| card.addEventListener('mouseenter', () => clearTimeout(hideTimeout)); | |
| card.addEventListener('mouseleave', () => card.classList.add('hidden')); | |
| } | |
| // CPI rankings | |
| renderCPIList(cpiScores); | |
| // Render team/club/confederation charts | |
| renderWIFXTeams(teams); | |
| renderWIFXClubs(clubs); | |
| renderConfederations(confs); | |
| } catch (err) { | |
| console.error('Dashboard load error:', err); | |
| } | |
| } | |
| loadAllData().then(() => { | |
| setTimeout(() => parent.postMessage({ type: 'wifx:height', height: document.body.scrollHeight }, '*'), 600); | |
| parent.postMessage({ type: 'wifx:ready' }, '*'); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |