Chargement du profil...

JustRate © 2026

`; } // ── Placeholder tab builder ──────────────────────────────────── function buildPlaceholderTab(icon, title, subtitle) { return `
${icon}
${title}
${subtitle}
`; } // ── Market Value — Transfermarkt ─────────────────────────────── let tmktHistoryChartInstance = null; function renderMarketValueBlock(data) { const el = document.getElementById('tmktValueSection'); if (!el) return; const mv = data && data.market_value; const transfers = (data && data.transfers) || []; if (!mv || !mv.formatted) { el.innerHTML = `
💶 Valeur marchande
Transfermarkt
💶
Données Transfermarkt non disponibles pour ce joueur
`; return; } // Build history chart HTML (only if history data exists) const history = (mv.history || []).filter(h => h.raw && h.date); // Trend: compare current value vs 6 months ago let trendHtml = ''; let chartColor = '#ffc107'; let chartBgColor = 'rgba(255,193,7,0.08)'; if (history.length >= 2 && mv.raw) { const sixMonthsAgo = new Date(); sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); const sorted6 = [...history].sort((a,b) => new Date(a.date) - new Date(b.date)); const pastEntry = sorted6.reduce((best, h) => { const hDate = new Date(h.date); return (hDate <= sixMonthsAgo) ? h : best; }, null); if (pastEntry && pastEntry.raw > 0) { const pct = (mv.raw - pastEntry.raw) / pastEntry.raw * 100; const isRising = pct > 0; const trendColor = isRising ? '#22c55e' : '#ef4444'; const trendArrow = isRising ? '▲' : '▼'; chartColor = trendColor; chartBgColor = isRising ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)'; trendHtml = `${trendArrow} ${Math.abs(pct).toFixed(0)}% 6 mois`; } } const histChartHtml = history.length >= 2 ? `
` : ''; // Highest value badge const highestHtml = mv.highest && mv.highest.formatted ? `
Pic : ${esc(mv.highest.formatted)} ${mv.highest.date ? `(${new Date(mv.highest.date).toLocaleDateString('fr-FR',{month:'short',year:'numeric'})})` : ''}
` : ''; // Vertical transfer timeline (newest first) let transfersHtml = ''; if (transfers.length > 0) { const items = transfers.slice(0, 10).map((t, idx) => { const typeStr = (t.type || '').toLowerCase(); const feeStr = (t.fee || '').toLowerCase(); const isLoan = typeStr.includes('prêt') || typeStr.includes('loan'); const isFree = feeStr.includes('gratuit') || feeStr.includes('free') || feeStr === '€0' || feeStr === '0'; let feeDisplay = '', feeBadgeColor = '#22c55e', feeBadgeBg = 'rgba(34,197,94,0.1)', feeBadgeBorder = 'rgba(34,197,94,0.25)'; if (isLoan) { feeDisplay = 'Prêt'; feeBadgeColor = '#ffc107'; feeBadgeBg = 'rgba(255,193,7,0.1)'; feeBadgeBorder = 'rgba(255,193,7,0.25)'; } else if (isFree) { feeDisplay = 'Libre'; feeBadgeColor = 'rgba(255,255,255,0.4)'; feeBadgeBg = 'rgba(255,255,255,0.05)'; feeBadgeBorder = 'rgba(255,255,255,0.1)'; } else if (t.fee) { feeDisplay = esc(t.fee); } const isLast = idx === Math.min(transfers.length - 1, 9); return `
${!isLast ? '
' : ''}
${esc(String(t.season||t.year||'—'))} ${feeDisplay ? `${feeDisplay}` : ''}
${esc(t.from||'?')} ${esc(t.to||'?')}
`; }).join(''); transfersHtml = `
🔁 Historique des transferts
${items}
`; } el.innerHTML = `
💶 Valeur marchande
Transfermarkt
${esc(mv.formatted)}
${trendHtml}
Valeur de marché actuelle
${highestHtml} ${histChartHtml}
${transfersHtml}
`; // Render history chart (SofaScore-style) if (history.length >= 2) { requestAnimationFrame(() => { const canvas = document.getElementById('tmktHistCanvas'); if (!canvas) return; if (tmktHistoryChartInstance) { tmktHistoryChartInstance.destroy(); tmktHistoryChartInstance = null; } const sorted = [...history].sort((a,b) => new Date(a.date) - new Date(b.date)); const labels = sorted.map(h => { const d = new Date(h.date); return d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' }); }); const values = sorted.map(h => h.raw / 1_000_000); tmktHistoryChartInstance = new Chart(canvas.getContext('2d'), { type: 'line', data: { labels, datasets: [{ data: values, borderColor: chartColor, backgroundColor: chartBgColor, borderWidth: 2.5, pointRadius: 0, pointHoverRadius: 5, pointHoverBackgroundColor: chartColor, pointHoverBorderColor: 'rgba(10,12,18,0.9)', pointHoverBorderWidth: 2, fill: true, tension: 0.4, }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 900, easing: 'easeInOutQuart' }, scales: { x: { display: true, grid: { display: false }, border: { display: false }, ticks: { color: 'rgba(255,255,255,0.22)', font: { size: 9 }, maxTicksLimit: 7, maxRotation: 0, } }, y: { display: true, position: 'right', grid: { color: 'rgba(255,255,255,0.04)', drawBorder: false }, border: { display: false }, ticks: { color: 'rgba(255,255,255,0.28)', font: { size: 9 }, maxTicksLimit: 4, callback: v => v >= 1 ? `${v}M` : `${(v*1000).toFixed(0)}K` } } }, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(8,10,16,0.96)', titleColor: 'rgba(255,255,255,0.45)', bodyColor: chartColor, borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, padding: 10, displayColors: false, callbacks: { title: (items) => labels[items[0].dataIndex], label: (item) => ` ${item.raw >= 1 ? item.raw.toFixed(1)+' M €' : (item.raw*1000).toFixed(0)+' K €'}` } } }, interaction: { mode: 'index', intersect: false } } }); }); } } // ── Injuries block ───────────────────────────────────────────── function renderInjuriesBlock(data) { const el = document.getElementById('tmktInjuriesSection'); if (!el) return; const injuries = (data && data.injuries) || []; if (!injuries.length) { el.innerHTML = ''; return; } const rows = injuries.slice(0, 10).map(inj => { const fromStr = inj.from ? new Date(inj.from).toLocaleDateString('fr-FR',{month:'short',year:'numeric'}) : '—'; const untilStr = inj.until ? new Date(inj.until).toLocaleDateString('fr-FR',{month:'short',year:'numeric'}) : 'En cours'; const daysHtml = inj.days_missed > 0 ? `${inj.days_missed}j manqués` : ''; return `
${esc(fromStr)} → ${esc(untilStr)} ${esc(inj.type||'Blessure')} ${daysHtml}
`; }).join(''); el.innerHTML = `
🏥 Historique blessures
Transfermarkt
${rows}
`; } // ── Achievements block ───────────────────────────────────────── function renderAchievementsBlock(data) { const el = document.getElementById('tmktAchievementsSection'); if (!el) return; const achievements = (data && data.achievements) || []; if (!achievements.length) { el.innerHTML = ''; return; } const rows = achievements.slice(0, 15).map(ach => { return `
${esc(ach.season||'—')} ${esc(ach.title)} ${ach.competition ? `${esc(ach.competition)}` : ''}
`; }).join(''); el.innerHTML = `
Palmarès
Transfermarkt
${rows}
`; } async function loadMarketValue(player) { const el = document.getElementById('tmktValueSection'); if (!el) return; // Show loading state el.innerHTML = `
💶 Valeur marchande
Transfermarkt
`; // Also load injuries and achievements in parallel const [injRes, achRes] = await Promise.all([ fetch('/api/player/' + encodeURIComponent(playerName) + '/injuries').then(r => r.ok ? r.json() : null).catch(() => null), fetch('/api/player/' + encodeURIComponent(playerName) + '/achievements').then(r => r.ok ? r.json() : null).catch(() => null), ]); if (injRes) renderInjuriesBlock(injRes); if (achRes) renderAchievementsBlock(achRes); try { const teamParam = player && player.team ? `?team=${encodeURIComponent(player.team)}` : ''; const res = await fetch('/api/player/' + encodeURIComponent(playerName) + '/market-value' + teamParam); if (res.ok) { const data = await res.json(); // Store transfers globally for valuation tab if (data.transfers) window._tmktTransfers = data.transfers; renderMarketValueBlock(data); // Enrich bio tab with TM data if available if (data.player) renderTmBioExtra(data.player); } else { renderMarketValueBlock(null); } } catch (e) { renderMarketValueBlock(null); } } function renderTmBioExtra(playerTm) { const el = document.getElementById('tmBioExtra'); if (!el) return; const foot = playerTm.preferred_foot; const contractUntil = playerTm.contract_until; const agent = playerTm.agent; const birthplace = playerTm.birthplace; if (!foot && !contractUntil && !agent && !birthplace) return; const footIcon = foot ? (foot.toLowerCase().includes('left') || foot.toLowerCase().includes('gauche') ? '🦶⬅' : foot.toLowerCase().includes('both') || foot.toLowerCase().includes('deux') ? '🦶↔' : '🦶➡') : null; const footLabel = foot ? (foot.toLowerCase().includes('left') || foot.toLowerCase().includes('gauche') ? 'Pied gauche' : foot.toLowerCase().includes('both') || foot.toLowerCase().includes('deux') ? 'Ambidextre' : 'Pied droit') : null; let contractBadge = ''; if (contractUntil) { const contractDate = new Date(contractUntil); const now = new Date(); const monthsLeft = (contractDate.getFullYear() - now.getFullYear()) * 12 + (contractDate.getMonth() - now.getMonth()); const contractStr = contractDate.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); const urgentStyle = monthsLeft < 6 ? 'color:#ef4444;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.25);padding:1px 6px;border-radius:4px;font-size:0.72em;margin-left:4px' : ''; contractBadge = urgentStyle ? `${esc(contractStr)} ⚠ < 6 mois` : esc(contractStr); } const extras = [ footIcon && footLabel ? `
${footIcon} Pied fort
${esc(footLabel)}
` : '', contractBadge ? `
Fin de contrat
${contractBadge}
` : '', agent ? `
🤝 Agent
${esc(agent)}
` : '', birthplace ? `
📍 Lieu de naissance
${esc(birthplace)}
` : '', ].filter(Boolean).join(''); if (extras) el.innerHTML = `
${extras}
`; } // ══════════════════════════════════════════════════════════════ // TAB NAVIGATION // ══════════════════════════════════════════════════════════════ let tabsInitialized = false; let analyseTabLoaded = false; let classementTabLoaded = false; let analyseChart = null; function initTabs() { if (tabsInitialized) return; tabsInitialized = true; const nav = document.getElementById('playerTabNav'); if (!nav) return; nav.querySelectorAll('.ptab-btn').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); } function switchTab(tabId) { document.querySelectorAll('.ptab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tabId)); document.querySelectorAll('.ptab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-' + tabId)); // Lazy-load heavy tabs on first activation if (tabId === 'analyse' && !analyseTabLoaded) { analyseTabLoaded = true; loadAnalyseTab(); } if (tabId === 'classement' && !classementTabLoaded) { classementTabLoaded = true; loadClassementTab(); } } // ══════════════════════════════════════════════════════════════ // TAB 2 — STATISTIQUES GÉNÉRALES // ══════════════════════════════════════════════════════════════ function buildStatsTab(seasons, player) { if (!seasons || !seasons.length) { return `
Données statistiques non disponibles
`; } // Build season filter options const seasonOpts = seasons.map(s => ``).join(''); // Available leagues const leagueMap = {}; seasons.forEach(s => { if (s.league_name) leagueMap[s.league_name] = true; }); const leagueOpts = Object.keys(leagueMap).map(l => ``).join(''); return `
Saison
${leagueOpts ? `
Compétition
` : ''}
${buildStatsGrid(seasons[0], player)}
`; } function buildStatsGrid(s, player) { if (!s) return `
Aucune donnée pour cette saison
`; const pos = (player && player.position) || s.position || 'MID'; function statRow(icon, label, value, cls='') { if (value === null || value === undefined || value === '' || (typeof value === 'number' && isNaN(value))) return ''; const displayVal = typeof value === 'number' ? (Number.isInteger(value) ? value : value.toFixed(2)) : value; return `
${icon}${label}
${displayVal}
`; } const app = parseInt(s.appearances) || 0; const min = parseInt(s.minutes) || 0; const goals = parseInt(s.goals) || 0; const assists = parseInt(s.assists) || 0; const yc = parseInt(s.yellow_cards) || 0; const rc = parseInt(s.red_cards) || 0; const rating = s.rating ? parseFloat(s.rating).toFixed(2) : null; const minPerMatch = app > 0 ? Math.round(min / app) : null; // Category: Participation const partRows = [ statRow('', 'Matchs joués', app, 'accent'), statRow('', 'Titularisations', s.lineups != null ? parseInt(s.lineups) : null), statRow('⏰', 'Minutes totales', min), statRow('📐', 'Minutes / match', minPerMatch), rating ? `
Note API moyenne
${rating}
` : '', ].filter(Boolean).join(''); // Category: Attaque const attRows = [ statRow('', 'Buts', goals, goals > 0 ? 'accent' : ''), statRow('', 'Passes décisives', assists, assists > 0 ? 'blue' : ''), goals + assists > 0 ? statRow('', 'Contributions', goals + assists) : '', app > 0 && goals > 0 ? statRow('', 'Buts / match', (goals/app).toFixed(2)) : '', ].filter(Boolean).join(''); // Category: Discipline const discRows = [ statRow('', 'Cartons jaunes', yc, yc >= 5 ? 'amber' : ''), statRow('', 'Cartons rouges', rc, rc > 0 ? 'red' : ''), app > 0 && (yc+rc) > 0 ? statRow('⚠', 'Cartons / match', ((yc+rc)/app).toFixed(2)) : '', ].filter(Boolean).join(''); return `
${partRows ? `
Participation
${partRows}
` : ''} ${attRows ? `
Attaque
${attRows}
` : ''} ${discRows ? `
Discipline
${discRows}
` : ''}
Chargement des stats de matchs…
`; } async function loadMatchStatsForTab(season) { const wrap = document.getElementById('matchStatsGridWrap'); if (!wrap) return; try { // Find a recent match for this season to get aggregate stats const seasonMatches = allMatches.filter(m => !season || m.season == season); if (!seasonMatches.length) { wrap.innerHTML = ''; return; } // Try to aggregate from multiple match stats const recent = seasonMatches.filter(m => m.status === 'finished').slice(0, 10); if (!recent.length) { wrap.innerHTML = ''; return; } const statsResults = await Promise.all(recent.slice(0, 5).map(m => fetch('/api/player/' + encodeURIComponent(playerName) + '/match-stats/' + m.match_id).then(r => r.ok ? r.json() : null).catch(() => null) )); const validStats = statsResults.filter(r => r && r.stats).map(r => r.stats); if (!validStats.length) { wrap.innerHTML = ''; return; } function avg(key) { const vals = validStats.map(s => parseFloat(s[key])).filter(v => !isNaN(v) && v > 0); return vals.length ? (vals.reduce((a,b) => a+b, 0) / vals.length) : null; } const shots = avg('shots_total'); const shotsOn = avg('shots_on_target'); const passes = avg('passes_total'); const passAcc = avg('passes_accuracy'); const keyPasses = avg('passes_key'); const drib = avg('dribbles_success'); const dribAtt = avg('dribbles_attempts'); const tackles = avg('tackles_total'); const interceptions = avg('tackles_interceptions'); const duels = avg('duels_total'); const duelsWon = avg('duels_won'); const foulsC = avg('fouls_committed'); function avgRow(icon, label, value, suffix='', cls='') { if (!value && value !== 0) return ''; return `
${icon}${label}
${parseFloat(value).toFixed(2)}${suffix}
`; } const shotRows = [ avgRow('', 'Tirs / match', shots), avgRow('', 'Tirs cadrés / match', shotsOn, '', 'accent'), ].filter(Boolean).join(''); const passRows = [ avgRow('📤', 'Passes / match', passes), passAcc ? `
Précision de passe
${parseFloat(passAcc).toFixed(0)}%
` : '', avgRow('🔑', 'Passes clés / match', keyPasses, '', 'blue'), ].filter(Boolean).join(''); const techRows = [ drib ? avgRow('', `Dribbles réussis / match`, drib, ` (/${dribAtt?parseFloat(dribAtt).toFixed(2):'?'})`) : '', ].filter(Boolean).join(''); const defRows = [ avgRow('🛡', 'Tacles / match', tackles, '', 'blue'), avgRow('✋', 'Interceptions / match', interceptions, '', 'blue'), duels && duelsWon ? `
Duels gagnés
${parseFloat(duelsWon).toFixed(2)} / ${parseFloat(duels).toFixed(2)}
` : '', avgRow('⚠', 'Fautes commises / match', foulsC, '', 'amber'), ].filter(Boolean).join(''); wrap.innerHTML = `
${shotRows ? `
Tirs (moy/match)
${shotRows}
` : ''} ${passRows ? `
📤 Passes (moy/match)
${passRows}
` : ''} ${techRows ? `
Technique (moy/match)
${techRows}
` : ''} ${defRows ? `
🛡 Défense (moy/match)
${defRows}
` : ''}

Moyenne sur les ${validStats.length} derniers matchs disponibles

`; } catch(e) { wrap.innerHTML = ''; } } function initStatsTabFilters(seasons, player) { const sel = document.getElementById('statsSeason'); if (!sel) return; sel.addEventListener('change', () => { const season = parseInt(sel.value); const s = seasons.find(x => x.season === season); const wrap = document.getElementById('statsGridWrap'); if (wrap) wrap.innerHTML = buildStatsGrid(s, player); loadMatchStatsForTab(season); }); const leagueSel = document.getElementById('statsLeague'); if (leagueSel) { leagueSel.addEventListener('change', () => { const league = leagueSel.value; const season = parseInt(sel.value); const filtered = league ? seasons.filter(s => s.league_name === league) : seasons; const s = filtered.find(x => x.season === season) || filtered[0]; const wrap = document.getElementById('statsGridWrap'); if (wrap) wrap.innerHTML = buildStatsGrid(s, player); if (s) loadMatchStatsForTab(s.season); }); } // Load initial match stats if (seasons.length) loadMatchStatsForTab(seasons[0].season); } // ══════════════════════════════════════════════════════════════ // TAB 3 — ANALYSE DE PERFORMANCE // ══════════════════════════════════════════════════════════════ const RADAR_AXES = { FWD: ['Finition', 'Dribble', 'Vitesse', 'Passes clés', 'Jeu aérien', 'Pressing'], MID: ['Passes', 'Vision', 'Interceptions', 'Tirs', 'Endurance', 'Duels'], DEF: ['Tacles', 'Interceptions', 'Jeu aérien', 'Relance', 'Duels', 'Positionnement'], GK: ['Arrêts', 'Sorties', 'Relance pied', 'Jeu aérien', 'Penalties', 'Communication'], }; function getRadarAxes(pos) { if (!pos) return RADAR_AXES.MID; const p = pos.toUpperCase(); if (p === 'GK') return RADAR_AXES.GK; if (p === 'DEF' || p === 'CB' || p === 'LB' || p === 'RB') return RADAR_AXES.DEF; if (p === 'FWD' || p === 'ST' || p === 'CF' || p === 'LW' || p === 'RW') return RADAR_AXES.FWD; return RADAR_AXES.MID; } function loadAnalyseTab() { const el = document.getElementById('analyseTabContent'); if (!el) return; const playerObj = window.__analysePlayer; if (!playerObj) return; const pos = playerObj.position || 'MID'; const axes = getRadarAxes(pos); // Compute radar scores from available data const matchStats = allMatches.filter(m => m.status === 'finished').slice(0, 10); const jri5 = jriHistory.filter(j => j.justrate_index).slice(0, 5); const jri10 = jriHistory.filter(j => j.justrate_index).slice(0, 10); const avg5 = jri5.length ? (jri5.reduce((a,b) => a + parseFloat(b.justrate_index||0), 0) / jri5.length) : null; const avg10 = jri10.length ? (jri10.reduce((a,b) => a + parseFloat(b.justrate_index||0), 0) / jri10.length) : null; // Trend detection let trendHtml = ''; if (avg5 !== null && avg10 !== null) { const diff = avg5 - avg10; let trendCls, trendIcon, trendTxt; if (diff > 0.3) { trendCls = 'trend-up'; trendIcon = '↗'; trendTxt = 'En progression'; } else if (diff < -0.3) { trendCls = 'trend-down'; trendIcon = '↘'; trendTxt = 'En baisse'; } else { trendCls = 'trend-stable'; trendIcon = '→'; trendTxt = 'Stable'; } trendHtml = `${trendIcon} ${trendTxt}`; } el.innerHTML = `
🕸 Radar de performance — ${esc(pos)}
Percentiles par poste
Chargement des percentiles…
Tendance de forme
${avg5 !== null ? `
${avg5.toFixed(2)}
JRI 5 matchs
` : ''} ${avg10 !== null ? `
${avg10.toFixed(2)}
JRI 10 matchs
` : ''} ${trendHtml}
${jri10.length >= 3 ? `
` : ''}
💪 Points forts / faibles
Chargement…
`; requestAnimationFrame(() => { renderAnalyseRadar(pos, playerObj); renderAnalyseSparkline(jri10); }); // Async: load percentiles fetchAndRenderPercentiles(playerObj); } async function renderAnalyseRadar(pos, player) { const canvas = document.getElementById('analyseRadarCanvas'); if (!canvas) return; if (analyseChart) { analyseChart.destroy(); analyseChart = null; } const axes = getRadarAxes(pos); // Try to get real scores from recent match stats const recentFinished = allMatches.filter(m => m.status === 'finished').slice(0, 3); let scores = new Array(6).fill(5); if (recentFinished.length) { try { const r = await fetch('/api/player/' + encodeURIComponent(playerName) + '/match-stats/' + recentFinished[0].match_id); const data = await r.json(); const s = data.stats || {}; scores = radarScores(s); // uses existing radarScores function } catch(e) {} } const posLabel = { FWD:'Attaquants', MID:'Milieux', DEF:'Défenseurs', GK:'Gardiens' }; const p = pos.toUpperCase(); const group = p === 'GK' ? 'GK' : (p === 'DEF' || p === 'CB' || p === 'LB' || p === 'RB') ? 'DEF' : (p === 'FWD' || p === 'ST' || p === 'CF' || p === 'LW' || p === 'RW') ? 'FWD' : 'MID'; analyseChart = new Chart(canvas.getContext('2d'), { type: 'radar', data: { labels: axes, datasets: [ { label: player.player_name || 'Joueur', data: scores.map(v => Math.min(10, Math.max(0, v))), backgroundColor: 'rgba(0,230,118,0.12)', borderColor: '#00e676', borderWidth: 2, pointBackgroundColor: '#00e676', pointBorderColor: '#fff', pointBorderWidth: 1.5, pointRadius: 4, }, { label: 'Moyenne poste', data: new Array(6).fill(5), backgroundColor: 'rgba(255,255,255,0.04)', borderColor: 'rgba(255,255,255,0.15)', borderWidth: 1.5, borderDash: [4, 3], pointRadius: 0, } ] }, options: { responsive: true, animation: { duration: 900, easing: 'easeOutQuart' }, scales: { r: { min: 0, max: 10, ticks: { display: false, stepSize: 2 }, grid: { color: 'rgba(255,255,255,0.07)' }, angleLines: { color: 'rgba(255,255,255,0.07)' }, pointLabels: { color: 'rgba(255,255,255,0.55)', font: { size: 10, family: 'DM Sans, sans-serif', weight: '500' } } } }, plugins: { legend: { display: true, position: 'bottom', labels: { color: 'rgba(255,255,255,0.4)', font: { size: 10 }, boxWidth: 12 } }, tooltip: { backgroundColor: 'rgba(10,12,18,0.95)', borderColor: 'rgba(0,230,118,0.3)', borderWidth: 1, titleColor: '#fff', bodyColor: '#00e676', callbacks: { label: ctx => ` ${ctx.dataset.label}: ${ctx.raw}/10` } } } } }); } function renderAnalyseSparkline(jriHistory) { const canvas = document.getElementById('analyseSparkline'); if (!canvas || !jriHistory.length) return; const labels = jriHistory.slice().reverse().map((j,i) => i === 0 ? 'Plus ancien' : i === jriHistory.length-1 ? 'Récent' : ''); const values = jriHistory.slice().reverse().map(j => parseFloat(j.justrate_index)||0); new Chart(canvas.getContext('2d'), { type: 'line', data: { labels, datasets: [{ data: values, borderColor: '#00e676', backgroundColor: 'rgba(0,230,118,0.08)', borderWidth: 2, pointRadius: 3, pointBackgroundColor: '#00e676', fill: true, tension: 0.35, }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 700 }, scales: { x: { display: false }, y: { display: false, min: 0, max: 10 } }, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(10,12,18,0.9)', bodyColor: '#00e676', callbacks: { label: ctx => ` JRI: ${ctx.raw.toFixed(2)}` } } } } }); } async function fetchAndRenderPercentiles(player) { const card = document.getElementById('analysePercentilesCard'); const strCard = document.getElementById('analyseStrengthsCard'); if (!card) return; try { const r = await fetch('/api/player/' + encodeURIComponent(playerName) + '/percentiles'); const data = await r.json(); if (!data.available) { card.innerHTML += '
Données insuffisantes pour calculer les percentiles.
'; if (strCard) strCard.innerHTML = card.innerHTML; return; } const pos = data.position; const leagueName = data.league_name || ''; const ms = data.match_stats || {}; const ss = data.season_stats || {}; // Build list of all available percentiles const allPcts = []; const posLower = (pos||'').toUpperCase(); const isGK = posLower === 'GK'; const isDEF = posLower === 'DEF' || posLower === 'CB' || posLower === 'LB' || posLower === 'RB'; const isFWD = posLower === 'FWD' || posLower === 'ST' || posLower === 'CF' || posLower === 'LW' || posLower === 'RW'; if (ss.goals_per_match && !isGK && !isDEF) allPcts.push({ key: 'goals', label: 'Buts / match', pct: ss.goals_per_match.pct, val: ss.goals_per_match.value.toFixed(2) }); if (ss.assists_per_match && !isGK) allPcts.push({ key: 'assists', label: 'Passes D. / match', pct: ss.assists_per_match.pct, val: ss.assists_per_match.value.toFixed(2) }); if (ss.rating && ss.rating.value > 0) allPcts.push({ key: 'rating', label: 'Note API', pct: ss.rating.pct, val: ss.rating.value.toFixed(2) }); if (ms.pass_accuracy && ms.pass_accuracy.value > 0) allPcts.push({ key: 'pass_acc', label: 'Précision de passe', pct: ms.pass_accuracy.pct, val: ms.pass_accuracy.value.toFixed(0) + '%' }); if (ms.key_passes_per_game && ms.key_passes_per_game.value > 0) allPcts.push({ key: 'key_passes', label: 'Passes clés / match', pct: ms.key_passes_per_game.pct, val: ms.key_passes_per_game.value.toFixed(2) }); if (ms.dribbles_per_game && ms.dribbles_per_game.value > 0) allPcts.push({ key: 'dribbles', label: 'Dribbles / match', pct: ms.dribbles_per_game.pct, val: ms.dribbles_per_game.value.toFixed(2) }); if (ms.tackles_per_game && ms.tackles_per_game.value > 0) allPcts.push({ key: 'tackles', label: 'Tacles / match', pct: ms.tackles_per_game.pct, val: ms.tackles_per_game.value.toFixed(2) }); if (ms.interceptions_per_game && ms.interceptions_per_game.value > 0) allPcts.push({ key: 'interceptions', label: 'Interceptions / match', pct: ms.interceptions_per_game.pct, val: ms.interceptions_per_game.value.toFixed(2) }); if (ms.duel_win_pct && ms.duel_win_pct.value > 0) allPcts.push({ key: 'duels', label: 'Duels gagnés %', pct: ms.duel_win_pct.pct, val: ms.duel_win_pct.value.toFixed(0) + '%' }); if (ms.shots_per_game && ms.shots_per_game.value > 0 && (isFWD || ms.shots_per_game.value > 1)) allPcts.push({ key: 'shots', label: 'Tirs / match', pct: ms.shots_per_game.pct, val: ms.shots_per_game.value.toFixed(2) }); if (ms.saves_per_game && ms.saves_per_game.value > 0 && isGK) allPcts.push({ key: 'saves', label: 'Arrêts / match', pct: ms.saves_per_game.pct, val: ms.saves_per_game.value.toFixed(2) }); function pctClass(p) { return p >= 75 ? 'pct-high' : p >= 40 ? 'pct-mid' : 'pct-low'; } const pctHtml = allPcts.map(item => { const cls = pctClass(item.pct); return `
${item.label} ${item.pct}e percentile (${item.val})
Meilleur que ${item.pct}% des ${pos} en ${leagueName||'la ligue'}
`; }).join(''); card.innerHTML = `
Percentiles — ${pos} · ${leagueName}
${pctHtml || '
Données insuffisantes.
'}`; // Strengths / weaknesses if (strCard && allPcts.length) { const strengths = allPcts.filter(x => x.pct >= 75).slice(0, 3); const weaknesses = allPcts.filter(x => x.pct < 25).slice(0, 3); const sHtml = strengths.map(x => ` ${x.label}`).join(''); const wHtml = weaknesses.map(x => ` ${x.label}`).join(''); strCard.innerHTML = `
💪 Points forts / faibles
${strengths.length ? `
Points forts (>75e pct.)
${sHtml}
` : ''} ${weaknesses.length ? `
Points faibles (<25e pct.)
${wHtml}
` : ''} ${!strengths.length && !weaknesses.length ? `
Profil équilibré — aucun point extrême détecté.
` : ''}`; } } catch(e) { if (card) card.innerHTML += '
Erreur de chargement des percentiles.
'; } } // ══════════════════════════════════════════════════════════════ // TAB 4 — HISTORIQUE DES MATCHS // ══════════════════════════════════════════════════════════════ let histPage = 0; const HIST_PAGE_SIZE = 15; let histFilteredMatches = []; let histJriMap = {}; function buildHistoriqueTab(matches, jriHist) { // Build JRI map by match_id (used by older code, kept for compat) histJriMap = {}; if (jriHist) jriHist.forEach(j => { if (j.match_id) histJriMap[j.match_id] = j.justrate_index; }); // Build filter options from match data const seasons = [...new Set(matches.map(m => m.season).filter(Boolean))].sort((a,b)=>b-a); const comps = [...new Set(matches.map(m => m.competition_name || m.league || m.league_name).filter(Boolean))].sort(); const teams = [...new Set(matches.map(m => m.team).filter(Boolean))].sort(); const seasonOpts = seasons.map(s => ``).join(''); const compOpts = comps.map(c => ``).join(''); const teamOpts = teams.length > 1 ? teams.map(t => ``).join('') : ''; const total = matches.length; const totalLabel = total ? `${total} match${total>1?'s':''}` : ''; return `
Saison
${compOpts ? `
Compétition
` : ''} ${teamOpts ? `
Équipe
` : ''}
Période
${totalLabel}
Date
Match
Score
Min
Évts
JRI
Fans / API
`; } function renderHistoriqueList() { const seasonSel = document.getElementById('histSeason'); const compSel = document.getElementById('histComp'); const periodSel = document.getElementById('histPeriod'); const teamSel = document.getElementById('histTeam'); const season = seasonSel ? seasonSel.value : ''; const comp = compSel ? compSel.value : ''; const period = periodSel ? parseInt(periodSel.value) : null; const team = teamSel ? teamSel.value : ''; const now = Date.now(); histFilteredMatches = allMatches.filter(m => { if (season && m.season != season) return false; if (comp && (m.competition_name || m.league || m.league_name || '') !== comp) return false; if (team && (m.team || '') !== team) return false; if (period && m.match_date) { const diff = (now - new Date(m.match_date).getTime()) / (1000 * 3600 * 24); if (diff > period) return false; } return true; }); renderHistPage(); } function renderHistPage() { const wrap = document.getElementById('histListWrap'); const pag = document.getElementById('histPagination'); if (!wrap) return; const start = histPage * HIST_PAGE_SIZE; const pageItems = histFilteredMatches.slice(start, start + HIST_PAGE_SIZE); if (!pageItems.length) { wrap.innerHTML = `
Aucun match trouvé
`; pag.innerHTML = ''; return; } wrap.innerHTML = `
${pageItems.map(m => buildHistMatchItem(m)).join('')}
`; // Pagination const total = histFilteredMatches.length; const totalPages = Math.ceil(total / HIST_PAGE_SIZE); pag.innerHTML = totalPages > 1 ? `
${start+1}–${Math.min(start+HIST_PAGE_SIZE, total)} / ${total} matchs
` : `
${total} match${total>1?'s':''}
`; // Bind click events wrap.querySelectorAll('.hist-match-item').forEach(el => { el.addEventListener('click', () => { const matchId = parseInt(el.dataset.matchId); const match = allMatches.find(m => m.match_id === matchId); if (match) { window.switchTab(0); setTimeout(() => window.loadMatchStats(matchId, match), 100); } }); }); const prevBtn = document.getElementById('histPrevBtn'); const nextBtn = document.getElementById('histNextBtn'); if (prevBtn) prevBtn.addEventListener('click', () => { histPage--; renderHistPage(); }); if (nextBtn) nextBtn.addEventListener('click', () => { histPage++; renderHistPage(); }); } function buildHistMatchItem(m) { const date = m.match_date ? new Date(m.match_date).toLocaleDateString('fr-FR', {day:'2-digit',month:'short'}) : '—'; // Competition logo const compLogo = m.league_logo ? `` : `${abbrevLeague(m.league||m.competition_name)}`; // Team logos const homeLogo = m.home_badge ? `` : ''; const awayLogo = m.away_badge ? `` : ''; // Player's team bolded const homeIsPlayer = m.is_home === true; const awayIsPlayer = m.is_home === false; const homeName = `${esc(m.home_team||'—')}`; const awayName = `${esc(m.away_team||'—')}`; // H/A badge const haBadge = m.is_home !== null ? (m.is_home ? 'D' : 'E') : ''; // Score + win/draw/loss let scoreBadge = ''; if (m.home_score != null && m.away_score != null) { const isHome = m.is_home; const scored = isHome ? m.home_score : m.away_score; const conceded = isHome ? m.away_score : m.home_score; let res = 'draw'; if (scored > conceded) res = 'win'; else if (scored < conceded) res = 'loss'; scoreBadge = `${m.home_score}–${m.away_score}`; } else { scoreBadge = `${m.status||'—'}`; } // Events (goals, assists, cards) + advanced stats chips const goals = (m.goals_scored||0) > 0 ? '⚽'.repeat(Math.min(m.goals_scored||0, 3)) : ''; const assists = (m.assists_count||0) > 0 ? '🎯' : ''; const ycard = (m.yellow_cards||0) > 0 ? '🟨' : ''; const rcard = (m.red_cards||0) > 0 ? '🟥' : ''; // Shots on target + key passes (when available from Phase 3A data) const shotsChip = (m.shots_on_target||0) > 0 ? `${m.shots_on_target}🥅` : ''; const keyPassChip = (m.passes_key||0) > 0 ? `${m.passes_key}🔑` : ''; const events = [goals, assists, ycard, rcard].filter(Boolean).join('') + (shotsChip||keyPassChip ? `${shotsChip}${keyPassChip}` : ''); const minHtml = m.minutes_played ? `
${m.minutes_played}'
` : '
'; // ── Dual ratings: Fan avg (community) + API-Football ── const fanRating = m.avg_rating; const apiRating = m.api_football_rating; const voteCount = parseInt(m.vote_count) || 0; let fanCls = 'r-none', fanVal = '—'; if (fanRating && parseFloat(fanRating) > 0) { const f = parseFloat(fanRating); fanCls = f >= 8.5 ? 'r-gold' : f >= 7 ? 'r-elite' : f >= 5 ? 'r-mid' : 'r-low'; fanVal = f.toFixed(2); } let apiCls = 'ra-none', apiVal = '—'; const isApiEstimated = m.is_api_estimated === true; if (apiRating && parseFloat(apiRating) > 0) { apiCls = isApiEstimated ? 'ra-estimated' : ''; apiVal = parseFloat(apiRating).toFixed(2) + (isApiEstimated ? '~' : ''); } // ── JustRate Index (JRI) ── // Fans + API → 60% fans + 40% API avec Bayesian confidence // API seule → 100% note API brute (pas de pondération bayésienne) // Fans seule → 100% note fans // Aucune → '—' const PRIOR_MEAN = 6.5; const CONFIDENCE_C = 5; let jriCls = 'jri-none', jriVal = '—', jriEstimated = false; const hasFan = fanRating && parseFloat(fanRating) > 0; const hasApi = apiRating && parseFloat(apiRating) > 0; if (hasFan && hasApi) { const f = parseFloat(fanRating); const a = parseFloat(apiRating); const jriRaw = 0.6 * f + 0.4 * a; // Bayesian dampening: pull toward prior when vote count is low const jriBayes = (voteCount * jriRaw + CONFIDENCE_C * PRIOR_MEAN) / (voteCount + CONFIDENCE_C); const jri = Math.max(0, Math.min(10, jriBayes)); jriVal = jri.toFixed(2); jriCls = jri >= 8.5 ? 'jri-gold' : jri >= 7 ? 'jri-elite' : jri >= 5 ? 'jri-mid' : 'jri-low'; } else if (hasApi) { // API uniquement — note brute, pas de pondération bayésienne const jri = Math.max(0, Math.min(10, parseFloat(apiRating))); jriVal = jri.toFixed(2); jriCls = jri >= 8.5 ? 'jri-gold' : jri >= 7 ? 'jri-elite' : jri >= 5 ? 'jri-mid' : 'jri-low'; jriEstimated = true; // indique JRI estimé (sans votes fans) } else if (hasFan) { // Fans uniquement — 100% note fans const jri = Math.max(0, Math.min(10, parseFloat(fanRating))); jriVal = jri.toFixed(2); jriCls = jri >= 8.5 ? 'jri-gold' : jri >= 7 ? 'jri-elite' : jri >= 5 ? 'jri-mid' : 'jri-low'; } const jriHtml = `
${jriVal}${jriEstimated && jriVal !== '—' ? '~' : ''}
`; const ratingsHtml = `
Fans ${fanVal}
API ${apiVal}
`; return `
${compLogo}
${date}
${haBadge} ${homeLogo} ${homeName} ${awayName} ${awayLogo}
${scoreBadge} ${minHtml}
${events}
${jriHtml} ${ratingsHtml}
`; } function initHistoriqueTab() { ['histSeason','histComp','histTeam','histPeriod'].forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('change', () => { histPage = 0; renderHistoriqueList(); }); }); renderHistoriqueList(); } // ══════════════════════════════════════════════════════════════ // TAB 5 — CLASSEMENT // ══════════════════════════════════════════════════════════════ async function loadClassementTab() { const el = document.getElementById('classementTabContent'); if (!el) return; el.innerHTML = `
Calcul des classements…
`; try { const r = await fetch('/api/player/' + encodeURIComponent(playerName) + '/rankings'); const data = await r.json(); if (!data.available) { el.innerHTML = `
Données insuffisantes pour calculer les classements.
Le joueur doit avoir un JustRate Index calculé.
`; return; } const jri = data.player_jri ? parseFloat(data.player_jri).toFixed(2) : '—'; function rankCard(emoji, type, rank, total, context, extraClass='') { if (!rank) return ''; const isTop3 = rank <= 3; const pct = total > 0 ? Math.round((1 - rank/total) * 100) : null; return `
${emoji} #${rank}
/ ${total || '?'}
${type}
${esc(context)}
JRI ${jri}${pct !== null ? ` · Top ${100-pct}%` : ''}
`; } const cards = [ data.global ? rankCard('', 'Classement mondial', data.global.rank, data.global.total, 'Tous les joueurs JustRate') : '', data.by_position ? rankCard('📍', `Classement par poste`, data.by_position.rank, data.by_position.total, `${data.by_position.position} — tous championnats`) : '', data.by_league ? rankCard('', 'Classement championnat', data.by_league.rank, data.by_league.total, 'Dans sa compétition principale') : '', data.by_age ? rankCard('', `Classement ${data.by_age.age_group}`, data.by_age.rank, data.by_age.total, `Tranche d'âge ${data.by_age.age_group} (${data.by_age.age} ans)`) : '', data.by_club ? rankCard('', 'Classement club', data.by_club.rank, data.by_club.total, `${data.by_club.team}`) : '', data.by_nationality ? rankCard('🌐', 'Classement nationalité', data.by_nationality.rank, data.by_nationality.total, `Joueurs ${data.by_nationality.nationality}`) : '', ].filter(Boolean); el.innerHTML = cards.length ? `
${cards.join('')}

Classements basés sur le JustRate Index (JRI) moyen · Mise à jour toutes les heures

` : `
Aucune donnée de classement disponible.
`; } catch(e) { el.innerHTML = `
Erreur de chargement des classements.
`; } } // ══════════════════════════════════════════════════════════════ // TAB 6 — PALMARÈS // ══════════════════════════════════════════════════════════════ function buildPalmaresTab(player, seasons, careerData) { const lthHtml = buildLongTermHistory(seasons); const ciHtml = buildCareerIdentity(player, careerData); return `
${lthHtml}${ciHtml}
`; } // ══════════════════════════════════════════════════════════════ // TAB 8 — BIO // ══════════════════════════════════════════════════════════════ function buildBioTab(player, careerData) { const career = careerData || {}; const clubs = career.clubs || []; const trophies = career.trophies || []; const height = player.height || null; const weight = player.weight || null; const birthDate = player.birth_date || null; const nationality = player.nationality || null; const position = player.detailed_position || player.position || null; const team = player.team || null; const jersey = player.jersey_number || null; function bioCard(icon, label, value, extra) { if (!value) return ''; return `
${icon} ${label}
${String(value)}
`; } const age = birthDate ? Math.floor((Date.now() - new Date(birthDate).getTime()) / (365.25*24*3600*1000)) : null; const birthStr = birthDate ? new Date(birthDate).toLocaleDateString('fr-FR', {day:'2-digit',month:'long',year:'numeric'}) : null; // Nationality with flag const countryCodeMap = { France:'fr',Morocco:'ma',Maroc:'ma',Portugal:'pt',Spain:'es',Germany:'de',England:'gb-eng',Brazil:'br',Argentina:'ar',Netherlands:'nl','Ivory Coast':'ci','Côte d\'Ivoire':'ci',Belgium:'be',Italy:'it',Cameroon:'cm',Senegal:'sn',Algeria:'dz',Tunisia:'tn',Poland:'pl',Croatia:'hr',Egypt:'eg',Nigeria:'ng',Uruguay:'uy',Colombia:'co',Chile:'cl',Japan:'jp',Australia:'au',USA:'us',Canada:'ca',Mexico:'mx',Sweden:'se',Denmark:'dk',Switzerland:'ch',Austria:'at',Ukraine:'ua','South Korea':'kr',Ghana:'gh',Serbia:'rs',Greece:'gr' }; let nationalityDisplay = null; if (nationality) { const code = countryCodeMap[nationality]; nationalityDisplay = code ? `${esc(nationality)} ${esc(nationality)}` : esc(nationality); } const cards = [ bioCard('', 'Date de naissance', birthStr ? `${esc(birthStr)}${age?` (${age} ans)`:''}` : null), nationalityDisplay ? `
Nationalité
${nationalityDisplay}
` : '', bioCard('📍', 'Poste', position ? esc(position) : null), bioCard('', 'Club actuel', team ? esc(team) : null), bioCard('👕', 'Numéro', jersey ? `#${esc(String(jersey))}` : null), bioCard('📏', 'Taille', height ? `${esc(String(height))} cm` : null), bioCard('⚖', 'Poids', weight ? `${esc(String(weight))} kg` : null), ].filter(Boolean).join(''); // Career clubs section let clubItems = clubs.length ? clubs : (function() { const clubMap = {}; (seasonsData||[]).forEach(s => { if (!s.team_name) return; if (!clubMap[s.team_name]) clubMap[s.team_name] = { from: s.season, to: s.season, apps: 0, goals: 0 }; else { clubMap[s.team_name].from = Math.min(clubMap[s.team_name].from, s.season); clubMap[s.team_name].to = Math.max(clubMap[s.team_name].to, s.season); } clubMap[s.team_name].apps += (parseInt(s.appearances)||0); clubMap[s.team_name].goals += (parseInt(s.goals)||0); }); return Object.entries(clubMap).sort((a,b)=>b[1].from-a[1].from).map(([name, d]) => ({ team_name: name, from_season: d.from, to_season: d.to, total_apps: d.apps, total_goals: d.goals })); })(); let clubsHtml = ''; if (clubItems.length) { const rows = clubItems.map(c => { const toY = (c.to_season||c.to) >= 2025 ? 'présent' : String((c.to_season||c.to||0)+1).slice(2); const fromY = c.from_season||c.from||''; const apps = (c.total_apps||0) > 0 ? ` · ${c.total_apps} mj` : ''; const goals = (c.total_goals||0) > 0 ? ` · ${c.total_goals} buts` : ''; return `
${esc(c.team_name)}
${fromY}–${toY}${apps}${goals}
`; }).join(''); clubsHtml = `
Carrière clubs
${rows}
`; } // Trophies section let trophiesHtml = ''; if (trophies.length) { const tRows = trophies.slice(0,8).map(t => `
${esc(t.league||'Trophée')}
${esc(t.season||'')}${t.country?' · '+esc(t.country):''}
`).join(''); trophiesHtml = `
Palmarès
${tRows}
`; } return `
${cards || '
Informations biographiques non disponibles.
'}
${clubsHtml}${trophiesHtml}
`; } // ── Main load ────────────────────────────────────────────────── async function load() { try { const [infoRes, seasonsRes, matchesRes, jriRes, careerRes] = await Promise.all([ fetch('/api/player/' + encodeURIComponent(playerName)), fetch('/api/player/' + encodeURIComponent(playerName) + '/seasons'), fetch('/api/player/' + encodeURIComponent(playerName) + '/matches'), fetch('/api/justrate-index/player/' + encodeURIComponent(playerName) + '/history'), fetch('/api/player/' + encodeURIComponent(playerName) + '/career').catch(()=>null) ]); jriHistory = jriRes.ok ? ((await jriRes.json()).history||[]) : []; let careerData = null; try { if (careerRes && careerRes.ok) careerData = await careerRes.json(); } catch(e) {} if (!infoRes.ok) { document.getElementById('playerHeaderZone').innerHTML = '

Joueur introuvable

'; return; } const player = await infoRes.json(); const matchData = await matchesRes.json(); allMatches = matchData.matches||[]; // Render left-column recent matches as soon as we have data renderLeftColMatches(allMatches); if (seasonsRes.ok) { const sd = await seasonsRes.json(); seasonsData = sd.seasons||[]; extraMatchSeasons = (sd.match_seasons||[]).map(Number); if (!player.photo && sd.photo) player.photo = sd.photo; if (!player.nationality && sd.nationality) player.nationality = sd.nationality; } document.title = player.player_name + ' — JustRate'; // Default season const sSet = new Set(seasonsData.map(s=>s.season)); extraMatchSeasons.forEach(s=>sSet.add(s)); if (!sSet.has(2025)) sSet.add(2025); const uniqueS = [...sSet].sort((a,b)=>b-a); if (selectedSeason===null) selectedSeason = uniqueS[0]; const mBySeason = {}; allMatches.forEach(m=>{ if (m.season) { if (!mBySeason[m.season]) mBySeason[m.season]=[]; mBySeason[m.season].push(m); } }); // ── Build full JRI history from match data (includes API-only matches) ── const fullJriFromMatches = buildJriChartHistory(allMatches); // Override the server-side jriHistory if match data gives more points if (fullJriFromMatches.length > jriHistory.length) { jriHistory = fullJriFromMatches; } // Compute average JRI for header from all rated matches if (fullJriFromMatches.length > 0) { const avgJri = fullJriFromMatches.reduce((s,h)=>s+h.justrate_index,0)/fullJriFromMatches.length; player.justrate_index = parseFloat(avgJri.toFixed(2)); player.official_score_10 = player.justrate_index; player.jriMatchCount = fullJriFromMatches.length; } // Render header (pass allMatches for match status strip) document.getElementById('playerHeaderZone').innerHTML = buildHeader(player, allMatches); // ── Async: Fetch JRI Season + Global Power (Phase 4) ───────────── (async function loadJriSeason() { try { const pid = player.external_player_id || player.player_id || player.id; if (!pid) return; const jriSeasonRes = await fetch('/api/players/' + pid + '/jri-season'); if (!jriSeasonRes.ok) return; const jriSeasonData = await jriSeasonRes.json(); if (!jriSeasonData.seasons || jriSeasonData.seasons.length === 0) return; // Deduplicate: best jri_season per season_id const bySeasonMap = new Map(); for (const s of jriSeasonData.seasons) { const existing = bySeasonMap.get(s.season_id); if (!existing || (s.jri_season != null && (existing.jri_season == null || parseFloat(s.jri_season) > parseFloat(existing.jri_season)))) { bySeasonMap.set(s.season_id, s); } } const allSeasons = Array.from(bySeasonMap.values()).sort((a, b) => parseInt(b.season_id) - parseInt(a.season_id)); function renderJriSeasonDisplay(seasonEntry) { // JRI Saison if (seasonEntry && seasonEntry.jri_season != null) { const jriS = parseFloat(seasonEntry.jri_season); const jriEl = document.getElementById('phJriSeason'); const jriValEl = document.getElementById('phJriSeasonVal'); const jriLblEl = document.getElementById('phJriSeasonLbl'); if (jriEl && jriValEl) { const jClr = jriS >= 8.5 ? '#ffd700' : jriS >= 7 ? '#00e676' : jriS >= 5 ? '#ffb300' : '#ff6b6b'; jriValEl.textContent = Math.min(10, jriS).toFixed(2); jriValEl.style.color = jClr; jriEl.style.display = 'flex'; } if (jriLblEl) { const yr = parseInt(seasonEntry.season_id); jriLblEl.textContent = !isNaN(yr) ? `JRI ${yr}–${yr+1}` : 'JRI Saison'; } } // Reliability if (seasonEntry && seasonEntry.reliability_level) { const rlEl = document.getElementById('phJriReliability'); const rlBadge = document.getElementById('phJriRelBadge'); if (rlEl && rlBadge) { const rl = seasonEntry.reliability_level; const rlColors = { 'Elite': '#ffd700', 'Forte': '#00e676', 'Moyenne': '#ffb300', 'Faible': '#ff6b6b' }; const rlBgs = { 'Elite': 'rgba(255,215,0,0.12)', 'Forte': 'rgba(0,230,118,0.12)', 'Moyenne': 'rgba(255,179,0,0.12)', 'Faible': 'rgba(255,107,107,0.12)' }; rlBadge.textContent = rl; rlBadge.style.color = rlColors[rl] || '#999'; rlBadge.style.background = rlBgs[rl] || 'rgba(255,255,255,0.06)'; rlEl.style.display = 'flex'; } } } function renderSeasonPills(activeSeason) { const pillsEl = document.getElementById('phJriSeasonPills'); if (!pillsEl || allSeasons.length < 2) return; pillsEl.innerHTML = allSeasons.map(s => { const yr = parseInt(s.season_id); const lbl = !isNaN(yr) ? `${yr}–${yr+1}` : s.season_id; const active = s.season_id === activeSeason; return ``; }).join(''); pillsEl.style.display = 'flex'; } // Default to current season or most recent let currentSeason = allSeasons.find(s => s.season_id === '2025') || allSeasons[0]; renderJriSeasonDisplay(currentSeason); renderSeasonPills(currentSeason.season_id); // Update career bar & ph-jri-value to show current season JRI if (currentSeason && currentSeason.jri_season != null) { const csJri = parseFloat(currentSeason.jri_season); if (!isNaN(csJri)) { const csJriStr = Math.min(10, csJri).toFixed(2); const yr = parseInt(currentSeason.season_id); const seasonLbl = !isNaN(yr) ? `JRI ${yr}–${yr+1}` : 'JRI actuel'; // Update career bar const cbVal = document.getElementById('cbJriVal'); const cbLbl = document.getElementById('cbJriLbl'); if (cbVal) cbVal.textContent = csJriStr; if (cbLbl) cbLbl.textContent = seasonLbl; // Update main ph-jri-value const phVal = document.querySelector('.ph-jri-value'); if (phVal && phVal.textContent !== '—') phVal.textContent = csJriStr; } } window.selectPlayerJriSeason = function(seasonId) { currentSeason = allSeasons.find(s => s.season_id === seasonId) || allSeasons[0]; renderJriSeasonDisplay(currentSeason); renderSeasonPills(seasonId); }; // Global Power display (use first/most recent) const gp = jriSeasonData.global_power && jriSeasonData.global_power[0]; if (gp && gp.jri_global_power != null) { const gpVal = parseFloat(gp.jri_global_power); const gpEl = document.getElementById('phJriGlobalPower'); const gpValEl = document.getElementById('phJriGlobalVal'); const gpRankEl = document.getElementById('phJriGlobalRank'); if (gpEl && gpValEl) { const gClr = gpVal >= 8.5 ? '#ffd700' : gpVal >= 7 ? '#00e676' : gpVal >= 5 ? '#ffb300' : '#ff6b6b'; gpValEl.textContent = Math.min(10, gpVal).toFixed(2); gpValEl.style.color = gClr; gpEl.style.display = 'flex'; if (gpRankEl && gp.global_power_rank) { gpRankEl.textContent = '#' + gp.global_power_rank; gpRankEl.style.display = 'inline'; } } } } catch (e) { /* non-fatal */ } })(); // ── Async: Fetch advanced per-match stats (Phase 3C) ───────────── (async function loadAdvancedStats() { try { const advRes = await fetch('/api/player/' + encodeURIComponent(playerName) + '/advanced-stats'); if (!advRes.ok) return; const advData = await advRes.json(); if (!advData.seasons || !advData.seasons.length) return; advancedStats = advData; // Inject advanced stats section into Tab 1 if it's already rendered const advContainer = document.getElementById('advancedStatsSection'); if (advContainer) { advContainer.innerHTML = buildAdvancedStatsSection(advData); } } catch (e) { /* non-fatal */ } })(); // Show/hide vote CTA banner const playerTotalVotes = parseInt(player.total_votes) || 0; const playerVoteCTA = document.getElementById('playerVoteCTA'); if (playerVoteCTA) { // Init sticky vote CTA bar (voting-cta.js) if (window.JRVoteCta && typeof JRVoteCta.init === 'function') { try { JRVoteCta.init({ profileId: playerName, profileName: player.player_name || playerName, profileType: 'player', totalVotes: playerTotalVotes }); } catch(e) { console.error('[JRVoteCta] init failed:', e); } } playerVoteCTA.style.display = playerTotalVotes === 0 ? 'block' : 'none'; } // Render career bar (pass allMatches as fallback data source) document.getElementById('careerBarZone').innerHTML = buildCareerBar(player, seasonsData, allMatches); // ── Data hero zone — market value, JRI, community, stats, career ── buildPlayerDataZone(player, seasonsData, allMatches, jriHistory); // — Build & inject all tab contents — const filteredJri = filterJri(jriHistory, currentJriPeriod); // Tab 0 — Match actuel (2-col: T2 analysis left, Form right) // Rating module is now in the right column of the 3-col layout document.getElementById('ptab-0').innerHTML = `
${buildT2Module()}
${buildRecentForm(allMatches)}
`; // Build right-column rating module if (!isEmbed) { const prcBody = document.getElementById('prcRatingBody'); if (prcBody) { prcBody.innerHTML = buildRatingModule(); initRatingModule(); } // Build left column "Derniers matchs à noter" list buildMatchVotingList(allMatches); } // Tab 1 — Statistiques document.getElementById('ptab-1').innerHTML = `
Stats par saison
${buildSeasonTabs(seasonsData, false)}
${buildStatsTable(seasonsData)}
${advancedStats ? buildAdvancedStatsSection(advancedStats) : ''}
`; // Tab 2 — Analyse (placeholder) document.getElementById('ptab-2').innerHTML = buildPlaceholderTab('📐', 'Analyse tactique', 'L\'analyse avancée des performances match par match arrive bientôt.'); // Tab 3 — Historique (JRI chart + match-by-match list avec double notation) document.getElementById('ptab-3').innerHTML = `
${buildJriSection(filteredJri)}
${buildRatingSparklineSection(allMatches)}
Historique match-by-match
${buildHistoriqueTab(allMatches, jriHistory)}
`; initHistoriqueTab(); // ── Tab 2: Stats par saison (write to sub-element if it exists) ── const tabStatsEl = document.getElementById('tabStatsContent'); if (tabStatsEl) { tabStatsEl.innerHTML = `
Stats par saison
${buildSeasonTabs(seasonsData, false)}
${buildStatsTable(seasonsData)} ${buildChartHTML()}
${buildLongTermHistory(seasonsData)} `; } // Tab 4 — Classement (placeholder) document.getElementById('ptab-4').innerHTML = buildPlaceholderTab('', 'Classement mondial', 'Le classement mondial et par poste selon le JustRate Index arrive bientôt.'); // Tab 5 — Valorisation document.getElementById('ptab-5').innerHTML = ` ${buildCareerIdentity(player, careerData)}
`; // Tab 6 — Bio document.getElementById('ptab-6').innerHTML = buildBioTab(player, careerData); // Tab 7 — Comparaison (placeholder) document.getElementById('ptab-7').innerHTML = buildPlaceholderTab('⚖', 'Comparaison', 'Comparez ce joueur avec n\'importe quel autre joueur du monde — bientôt disponible.'); // Show tab zone document.getElementById('playerTabsZone').style.display = 'block'; // Render Tab 0 charts after DOM (visible by default) requestAnimationFrame(() => { // Form sparkline (in Tab 0 right column) const finishedMatches = allMatches.filter(m => m.status === 'finished').slice(0, formCount); renderFormSparkline(finishedMatches); // Rating module is now initialized in the right column (above) // Auto-load most recent finished match into T2 + rating module const recentFinished = allMatches.find(m => m.status === 'finished'); if (recentFinished) { loadMatchStats(recentFinished.match_id, recentFinished); // Highlight the active card in left column setActiveMatchCard(recentFinished.match_id); } // Note: evolution chart (Tab 1) and JRI chart (Tab 3) rendered lazily via switchTab() }); // Build bio tab const bioEl = document.getElementById('bioTabContent'); if (bioEl) bioEl.innerHTML = buildBioTab(player, careerData); // Load market value lazily (separate API, 24h cached) loadMarketValue(player); // Load JRI valuation (comparison + delta vs Transfermarkt) loadValuationTab(player); // Fetch world ranking and update header badge asynchronously fetch('/api/player/' + encodeURIComponent(playerName) + '/ranking') .then(r => r.ok ? r.json() : null) .then(d => { if (d && d.global_rank) { const el = document.getElementById('phRankBlock'); if (el) { el.innerHTML = ` #${d.global_rank} mondial${d.position_rank && d.position ? ` · #${d.position_rank} ${esc(d.position)}` : ''}`; el.style.display = 'flex'; } } }).catch(() => {}); // ── Injury status — populates Valorisation tab badge ──────── fetch('/api/player/' + encodeURIComponent(playerName) + '/injuries') .then(r => r.ok ? r.json() : null) .then(d => { const el = document.getElementById('playerInjuryStatus'); if (!el) return; if (!d) { el.innerHTML = 'Non disponible'; return; } if (d.is_injured && d.current_injury) { const inj = d.current_injury; const label = inj.type || 'Blessure'; const reason = inj.reason && inj.reason !== inj.type ? ` — ${inj.reason}` : ''; const since = inj.since ? ` (depuis ${new Date(inj.since).toLocaleDateString('fr-FR', {day:'2-digit',month:'short'})})` : ''; el.innerHTML = `🏥 ${esc(label)}${esc(reason)}${esc(since)}`; } else { el.innerHTML = ` Apte`; } }).catch(() => { const el = document.getElementById('playerInjuryStatus'); if (el) el.innerHTML = 'Non disponible'; }); // ── Sidelined history — populates Bio tab ─────────────────── fetch('/api/player/' + encodeURIComponent(playerName) + '/sidelined') .then(r => r.ok ? r.json() : null) .then(d => { const el = document.getElementById('playerSidelinedHistoryBlock'); if (!el || !d || !d.history || d.history.length === 0) return; const rows = d.history.slice(0, 10).map(h => { const start = h.start_date ? new Date(h.start_date).toLocaleDateString('fr-FR', {day:'2-digit',month:'short',year:'numeric'}) : '?'; const end = h.end_date ? new Date(h.end_date).toLocaleDateString('fr-FR', {day:'2-digit',month:'short',year:'numeric'}) : 'En cours'; return `
🩺 ${esc(h.type||'Absence')}
${esc(start)} → ${esc(end)}
`; }).join(''); el.innerHTML = `
🩺 Historique absences
${rows}
`; }).catch(() => {}); } catch(err) { console.error(err); document.getElementById('playerHeaderZone').innerHTML = '

Erreur de chargement

'; } } // ── Chart state flags for lazy rendering ────────────────── let statsChartRendered = false; let valTabLoaded = false; let compareTabLoaded = false; let cmpRadarChart = null; let valChart = null; // ── Tab switching ──────────────────────────────────────── window.switchTab = function(tabId) { const tid = String(tabId); document.querySelectorAll('.ptab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tid)); document.querySelectorAll('.ptab-panel').forEach(p => p.classList.toggle('active', p.id === 'ptab-' + tid)); // Lazy-render charts when their tab becomes visible // Tab 1 = Statistiques, Tab 3 = Historique (JRI), Tab 0 = Match actuel (forme) if (tid === '1' && !statsChartRendered) { statsChartRendered = true; requestAnimationFrame(() => { const mBySeason2 = {}; allMatches.forEach(m => { if (m.season) { if (!mBySeason2[m.season]) mBySeason2[m.season]=[]; mBySeason2[m.season].push(m); } }); renderEvolutionChart(seasonsData, mBySeason2); }); } if (tid === '3') { requestAnimationFrame(() => renderJriChart(filterJri(jriHistory, currentJriPeriod))); } if (tid === '0') { requestAnimationFrame(() => { const fin = allMatches.filter(m => m.status === 'finished').slice(0, formCount); renderFormSparkline(fin); }); } }; // ── Onglet 6 — Valorisation ────────────────────────────── async function loadValuationTab(player) { try { const teamParam = player && player.team ? `?team=${encodeURIComponent(player.team)}` : ''; const res = await fetch(`/api/player/${encodeURIComponent(playerName)}/valuation${teamParam}`); const data = res.ok ? await res.json() : null; renderValuationTab(data, player); } catch(e) { renderValuationTab(null, player); } } function renderValuationTab(data, player) { const el = document.getElementById('tabValContent'); if (!el) return; if (!data || !data.jri_valuation) { el.innerHTML = `
💰
Valorisation non disponible
Données insuffisantes pour calculer la JRI Valorisation.
`; return; } const v = data.jri_valuation; const tmkt = data.transfermarkt; const inp = data.inputs || {}; const trendIcon = v.trend === 'up' ? '↗' : v.trend === 'down' ? '↘' : '→'; const trendLabel = v.trend === 'up' ? 'Hausse' : v.trend === 'down' ? 'Baisse' : 'Stable'; // ── Update header valuation badge ───────────────────────── const headerBadgeEl = document.getElementById('ph-valuation-badge'); if (headerBadgeEl) { const voteCount = player && player.total_votes ? parseInt(player.total_votes) : 0; let confBadge = ''; if (voteCount > 0 && voteCount < 10) confBadge = ' PROVISOIRE'; else if (voteCount >= 50) confBadge = ' FIABLE'; headerBadgeEl.innerHTML = `💰 JRI Val. ${v.formatted || '—'}${confBadge}`; } // ── Confidence badge ───────────────────────────────────── const voteCount = player && player.total_votes ? parseInt(player.total_votes) : 0; let confidenceHtml = ''; if (voteCount > 0 && voteCount < 10) { confidenceHtml = `
Provisoire — ${voteCount} vote${voteCount!==1?'s':''}
`; } else if (voteCount >= 50) { confidenceHtml = `
✓ Fiable — ${voteCount} votes
`; } else if (voteCount >= 10) { confidenceHtml = `
${voteCount} votes
`; } // ── Comparison hero: Marché vs JRI ──────────────────────── let comparisonHeroHtml = ''; if (tmkt && tmkt.raw && v.raw) { const delta = v.raw - tmkt.raw; const deltaAbs = Math.abs(delta); let deltaFmt; if (deltaAbs >= 1e9) deltaFmt = (deltaAbs / 1e9).toFixed(2) + 'Md€'; else if (deltaAbs >= 1e6) deltaFmt = (deltaAbs / 1e6).toFixed(2) + 'M€'; else if (deltaAbs >= 1e3) deltaFmt = Math.round(deltaAbs / 1e3) + 'K€'; else deltaFmt = deltaAbs + '€'; const deltaSign = delta >= 0 ? '+' : '−'; const deltaCls = delta > 0 ? 'pos' : delta < 0 ? 'neg' : 'neu'; const deltaDesc = delta > 0 ? 'Communauté
surestime' : delta < 0 ? 'Communauté
sous-estime' : 'En ligne
avec le marché'; comparisonHeroHtml = `
🏪 Marché
${tmkt.formatted}
Transfermarkt
${deltaSign}${deltaFmt}
delta
${deltaDesc}
JRI
${v.formatted || '—'}
JustRate
${confidenceHtml}
`; } else { // No Transfermarkt data — show JRI only with confidence comparisonHeroHtml = `
${v.formatted || '—'}
JRI Valorisation
${trendIcon} ${trendLabel}
${confidenceHtml}
${inp.avg_jri ? `JRI: ${inp.avg_jri}` : ''} ${inp.age ? `Âge: ${inp.age} ans` : ''} ${inp.league ? `${inp.league}` : ''} ${inp.position ? `${inp.position}` : ''} ${inp.appearances ? `${inp.appearances} matchs` : ''} Base: ${inp.base_value_source === 'transfermarkt' ? 'Transfermarkt' : 'estimée'}
`; } const heroHtml = comparisonHeroHtml; // Factors card const factorsHtml = (data.factors || []).map(f => `
${f.icon} ${f.label} ${f.effect}
`).join(''); // JRI history chart const jriHistHtml = data.jri_history && data.jri_history.length > 1 ? `
Évolution JRI × Valorisation
JRI Index
${tmkt && tmkt.history && tmkt.history.length > 1 ? `
Transfermarkt
` : ''}
` : ''; // Transfers section let transfersHtml = ''; if (tmkt && tmkt.history && tmkt.history.length > 0) { const histItems = tmkt.history.slice(0, 12).map(h => `
${h.date ? new Date(h.date).toLocaleDateString('fr-FR', {month:'short', year:'numeric'}) : '—'} ${h.value || '—'}
`).join(''); transfersHtml += `
Historique valeur marchande (Transfermarkt)
${histItems}
`; } // Transfer history let transferListHtml = ''; const tmktTransfers = tmkt && window._tmktTransfers ? window._tmktTransfers : []; if (tmktTransfers.length > 0) { transferListHtml = tmktTransfers.map(t => `
${t.season || '—'}
${t.from_club_id ? 'Club ' + t.from_club_id : '—'} ${t.to_club_id ? 'Club ' + t.to_club_id : '—'}
${t.fee ? `${t.fee}` : ''}
`).join(''); } el.innerHTML = ` ${heroHtml}
Facteurs d'influence
${factorsHtml || '
Données insuffisantes
'}
Paramètres du calcul
${inp.avg_jri !== undefined ? `
JRI moyen${inp.avg_jri}
` : ''} ${inp.age ? `
Facteur âge${(inp.age_factor * 100).toFixed(0)}%
` : ''} ${inp.league_coeff ? `
Coeff. championnat×${inp.league_coeff.toFixed(2)}
` : ''} ${inp.pos_multiplier ? `
👕Coeff. poste×${inp.pos_multiplier}
` : ''} ${inp.volume_factor !== undefined ? `
Volume de jeu${(inp.volume_factor * 100).toFixed(0)}%
` : ''} ${inp.regularity_bonus !== undefined ? `
Bonus régularité+${((inp.regularity_bonus - 1) * 100).toFixed(2)}%
` : ''}
${jriHistHtml} ${transfersHtml ? `
${transfersHtml}
` : ''} ${transferListHtml ? `
Historique des transferts
${transferListHtml}
` : ''}
* La JRI Valorisation est un indicateur propriétaire JustRate basé sur la performance, l'âge et le niveau de compétition. Non utilisable à des fins financières.
`; // Render valuation evolution chart if (data.jri_history && data.jri_history.length > 1) { requestAnimationFrame(() => renderValuationChart(data.jri_history, tmkt)); } } function renderValuationChart(jriHistory, tmkt) { const canvas = document.getElementById('valEvolutionChart'); if (!canvas) return; if (valChart) { valChart.destroy(); valChart = null; } const labels = jriHistory.map((h, i) => i + 1); const jriData = jriHistory.map(h => h.index); const datasets = [{ label: 'JRI Index', data: jriData, borderColor: '#00e676', backgroundColor: 'rgba(0,230,118,0.07)', borderWidth: 2, tension: 0.35, pointRadius: 3, fill: true, }]; if (tmkt && tmkt.history && tmkt.history.length > 1) { const tmktVals = tmkt.history.slice(-jriHistory.length).map(h => h.raw ? h.raw / 1000000 : null); datasets.push({ label: 'Valeur TM (M€)', data: tmktVals, borderColor: '#4488ff', backgroundColor: 'rgba(68,136,255,0.05)', borderWidth: 1.5, tension: 0.35, pointRadius: 2, yAxisID: 'y2', fill: false, }); } valChart = new Chart(canvas.getContext('2d'), { type: 'line', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 9 } } }, y: { min: 0, max: 10, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 9 } } }, y2: { position: 'right', grid: { display: false }, ticks: { color: 'rgba(68,136,255,0.5)', font: { size: 9 }, callback: v => v + 'M' } }, }, plugins: { legend: { display: false } }, } }); } // ── Onglet 8 — Comparaison ─────────────────────────────── let cmpPlayerB = null; function initCompareTab(playerA) { const el = document.getElementById('tabCompareContent'); if (!el) return; el.innerHTML = `
${esc(playerA ? playerA.player_name : playerName)} VS
Comparaison joueurs
Recherchez un joueur B pour lancer la comparaison side-by-side
`; // Search input handler let searchTimer = null; const input = document.getElementById('cmpSearchInput'); const dropdown = document.getElementById('cmpDropdown'); if (input) { input.addEventListener('input', () => { clearTimeout(searchTimer); const q = input.value.trim(); if (q.length < 2) { dropdown.style.display = 'none'; return; } searchTimer = setTimeout(() => searchPlayers(q), 280); }); input.addEventListener('keydown', e => { if (e.key === 'Escape') { dropdown.style.display = 'none'; input.blur(); } if (e.key === 'Enter') { dropdown.style.display = 'none'; } }); document.addEventListener('click', e => { if (!document.getElementById('cmpSearchWrap')?.contains(e.target)) dropdown.style.display = 'none'; }, { capture: false }); } // Check URL for direct compare const pathParts = window.location.pathname.split('/'); if (pathParts[1] === 'compare' && pathParts[3]) { const nameB = decodeURIComponent(pathParts[3]); runComparison(nameB); } } async function searchPlayers(q) { const dropdown = document.getElementById('cmpDropdown'); if (!dropdown) return; try { const res = await fetch(`/api/players/search?q=${encodeURIComponent(q)}`); const data = await res.json(); const players = data.players || []; if (!players.length) { dropdown.innerHTML = `
Aucun résultat
`; dropdown.style.display = 'block'; return; } dropdown.innerHTML = players.map(p => `
${p.photo ? `` : ``} ${esc(p.player_name)} ${esc(p.team || '')}
`).join(''); dropdown.style.display = 'block'; } catch(e) { dropdown.style.display = 'none'; } } async function runComparison(nameBParam) { const input = document.getElementById('cmpSearchInput'); const dropdown = document.getElementById('cmpDropdown'); if (input) input.value = nameBParam; if (dropdown) dropdown.style.display = 'none'; const result = document.getElementById('cmpResult'); if (result) result.innerHTML = `
Chargement de la comparaison…
`; try { const res = await fetch(`/api/players/compare?a=${encodeURIComponent(playerName)}&b=${encodeURIComponent(nameBParam)}`); const data = await res.json(); if (!res.ok || !data.a || !data.b) throw new Error('No data'); cmpPlayerB = nameBParam; renderComparison(data.a, data.b); // Update URL without reload if (window.history.pushState) { const newUrl = `/compare/${encodeURIComponent(playerName)}/${encodeURIComponent(nameBParam)}`; window.history.pushState({ compare: true }, '', newUrl); } } catch(e) { if (result) result.innerHTML = `
Erreur de chargement. Vérifiez le nom du joueur.
`; } } function renderComparison(a, b) { const result = document.getElementById('cmpResult'); if (!result) return; // Destroy old radar if (cmpRadarChart) { cmpRadarChart.destroy(); cmpRadarChart = null; } const aJri = a.jri.current || 0; const bJri = b.jri.current || 0; const aWins = aJri >= bJri; function playerCardHtml(d, cls, color) { const jri = d.jri.current; const jriCl = jri ? (jri >= 8.5 ? 'jri-gold' : jri >= 7 ? 'jri-elite' : jri >= 5 ? 'jri-mid' : 'jri-low') : 'jri-none'; const ini = initials(d.player_name); const photo = d.info.photo; const trendIcon = d.jri.trend === 'up' ? '↗' : d.jri.trend === 'down' ? '↘' : '→'; return `
${photo ? `` : `
${ini}
`}
${esc(d.player_name)}
${esc(d.info.team || '—')} · ${esc(d.info.position || '—')}${d.info.age ? ' · ' + d.info.age + ' ans' : ''}
${jri ? jri.toFixed(2) : 'N/A'}
JustRate Index
${d.valuation.formatted || '—'}
JRI Valorisation
${trendIcon}
`; } // Stats comparison rows function statRow(label, va, vb, higherIsBetter = true) { const aNum = parseFloat(va) || 0; const bNum = parseFloat(vb) || 0; const aWin = higherIsBetter ? aNum > bNum : aNum < bNum; const bWin = higherIsBetter ? bNum > aNum : bNum < aNum; return `
${va !== null && va !== undefined ? va : '—'}
${label}
${vb !== null && vb !== undefined ? vb : '—'}
`; } const as = a.season, bs = b.season; // JRI bubbles function bubblesHtml(jriVals) { if (!jriVals || !jriVals.length) return '
Aucune donnée JRI
'; return `
${jriVals.slice(-8).map(v => { const cls = v >= 7 ? 'cmp-bubble-high' : v >= 5 ? 'cmp-bubble-mid' : 'cmp-bubble-low'; return `
${v.toFixed(2)}
`; }).join('')}
`; } result.innerHTML = `
${playerCardHtml(a, 'card-a cmp-show', '#00e676')} ${playerCardHtml(b, 'card-b', '#4488ff')}
Statistiques comparées — Saison actuelle
${statRow('JRI Index', aJri ? aJri.toFixed(2) : '—', bJri ? bJri.toFixed(2) : '—')} ${statRow('Valorisation', a.valuation.formatted, b.valuation.formatted)} ${statRow('Matchs joués', as.appearances, bs.appearances)} ${statRow('Buts', as.goals, bs.goals)} ${statRow('Passes D.', as.assists, bs.assists)} ${statRow('Minutes', as.minutes, bs.minutes)} ${statRow('Note API', as.rating ? parseFloat(as.rating).toFixed(2) : null, bs.rating ? parseFloat(bs.rating).toFixed(2) : null)} ${statRow('Jaunes', as.yellow_cards, bs.yellow_cards, false)} ${statRow('Rouges', as.red_cards, bs.red_cards, false)}
Radar performance
${esc(a.player_name.split(' ').pop())}
${esc(b.player_name.split(' ').pop())}
Forme récente — ${esc(a.player_name)}
${bubblesHtml(a.jri.history)}
Forme récente — ${esc(b.player_name)}
${bubblesHtml(b.jri.history)}
`; // Render radar chart requestAnimationFrame(() => { const canvas = document.getElementById('cmpRadarCanvas'); if (!canvas) return; if (cmpRadarChart) { cmpRadarChart.destroy(); cmpRadarChart = null; } const radarA = a.radar; const radarB = b.radar; cmpRadarChart = new Chart(canvas.getContext('2d'), { type: 'radar', data: { labels: ['JRI', 'Buts', 'Passes D.', 'Volume', 'Note'], datasets: [ { label: a.player_name, data: [radarA.jri, radarA.goals, radarA.assists, radarA.volume, radarA.rating], backgroundColor: 'rgba(0,230,118,0.1)', borderColor: 'rgba(0,230,118,0.7)', borderWidth: 2, pointBackgroundColor: 'rgba(0,230,118,0.8)', pointRadius: 3 }, { label: b.player_name, data: [radarB.jri, radarB.goals, radarB.assists, radarB.volume, radarB.rating], backgroundColor: 'rgba(68,136,255,0.1)', borderColor: 'rgba(68,136,255,0.7)', borderWidth: 2, pointBackgroundColor: 'rgba(68,136,255,0.8)', pointRadius: 3 }, ] }, options: { responsive: true, maintainAspectRatio: true, scales: { r: { min: 0, max: 10, ticks: { stepSize: 2, display: false }, grid: { color: 'rgba(255,255,255,0.06)' }, angleLines: { color: 'rgba(255,255,255,0.06)' }, pointLabels: { color: 'rgba(240,240,245,0.5)', font: { size: 9 } } } }, plugins: { legend: { display: false } } } }); }); } window.cmpShowPlayer = function(which) { document.querySelectorAll('.cmp-player-card').forEach(c => c.classList.remove('cmp-show')); const target = which === 'a' ? '.card-a' : '.card-b'; document.querySelector(target)?.classList.add('cmp-show'); document.querySelectorAll('.cmp-mobile-toggle-btn').forEach((b, i) => { b.classList.toggle('active-a', which === 'a' && i === 0); b.classList.toggle('active-b', which === 'b' && i === 1); }); }; window.swapCompare = function() { if (!cmpPlayerB) return; const tmp = playerName; runComparison(tmp === playerName ? cmpPlayerB : tmp); }; window.shareCompare = function() { if (!cmpPlayerB) { alert('Sélectionnez d\'abord un joueur B'); return; } const url = `${window.location.origin}/compare/${encodeURIComponent(playerName)}/${encodeURIComponent(cmpPlayerB)}`; navigator.clipboard ? navigator.clipboard.writeText(url).then(() => { const btn = document.querySelector('.cmp-btn-share'); if (btn) { btn.textContent = '✓ Copié!'; setTimeout(() => btn.textContent = ' Partager', 2000); } }) : prompt('Copier l\'URL:', url); }; // ══════════════════════════════════════════════════════════════ // DATA HERO ZONE — visible above tabs, no paywall // ══════════════════════════════════════════════════════════════ let dataZoneMvChart = null; let dataZoneJriChart = null; function buildPlayerDataZone(p, seasons, matches, jriHist) { const zone = document.getElementById('playerDataZone'); if (!zone) return; // ── 1. Season stats cards (current season = highest season in data) ── const currentSeasons = seasons.filter(s => s.season >= 2024).sort((a,b) => b.season - a.season); const allSeasons = [...seasons].sort((a,b) => b.season - a.season); const topSeason = currentSeasons[0] || allSeasons[0]; // Aggregate current season (may span multiple competitions) let csGoals = 0, csAssists = 0, csApps = 0, csMinutes = 0; if (topSeason) { const sameSeason = seasons.filter(s => s.season === topSeason.season); sameSeason.forEach(s => { csGoals += parseInt(s.goals) || 0; csAssists += parseInt(s.assists) || 0; csApps += parseInt(s.appearances) || 0; csMinutes += parseInt(s.minutes) || 0; }); } // Fallback to match data if season stats are empty if (csApps === 0 && matches.length) { const finishedMatches = matches.filter(m => m.status === 'finished'); csApps = finishedMatches.length; csGoals = finishedMatches.reduce((a, m) => a + (parseInt(m.goals_scored) || 0), 0); csAssists = finishedMatches.reduce((a, m) => a + (parseInt(m.assists_count) || 0), 0); csMinutes = finishedMatches.reduce((a, m) => a + (parseInt(m.minutes_played) || 0), 0); } const seasonLabel = topSeason ? seasonLbl(topSeason.season) : '2025/26'; const statsHTML = `
Stats saison ${esc(seasonLabel)}
📅
${csApps || '—'}
Matchs
${csGoals}
Buts
🎯
${csAssists}
Passes D.
${csMinutes > 0 ? Math.round(csMinutes / Math.max(csApps, 1)) + "'" : '—'}
Min/match
`; // ── 2. JRI Hero with sparkline + rolling average ── // Prefer Phase 3C server-computed JRI when available (formula: 0.6 community + 0.3 API weighted + 0.1 volume) const jri3c = p.jri_phase3c; const jri = jri3c ? jri3c.jri_final : (p.justrate_index != null ? parseFloat(p.justrate_index) : null); const jriClass = jriCls(jri); const jriDisplay = jri != null ? jri.toFixed(2) : '—'; const jriSource = jri3c ? 'Phase 3C' : 'client'; const validVotes = jri3c ? (jri3c.vote_count || 0) : (p.valid_votes_count != null ? p.valid_votes_count : (p.total_votes || 0)); const posLabel = p.detailed_position_label || p.position || ''; // Rolling average from server (last 5 API-Football match ratings) const rollingAvg5 = p.rolling_avg_5; const rollingAvg5Count = p.rolling_avg_5_matches || 0; // Build JRI sparkline from recent matches (last 10 rated) const ratedMatchesForSparkline = (jriHist || []) .filter(h => h.justrate_index != null) .slice(-10); const sparklineHasData = ratedMatchesForSparkline.length >= 2; // Rolling avg color: green >= 7, amber >= 5, red < 5 const rollingAvgColor = rollingAvg5 >= 7 ? '#00e676' : rollingAvg5 >= 5 ? '#ffb300' : '#ff6b6b'; const jriHeroHTML = `
JustRate Index — Saison en cours
${jri3c ? 'JRI v3c' : ''}
${esc(jriDisplay)}
${validVotes > 0 ? `
${validVotes} vote${validVotes > 1 ? 's' : ''} communauté
` : ''} ${jri3c && jri3c.jri_api_weighted ? `
API pondéré : ${jri3c.jri_api_weighted.toFixed(2)}
` : ''} ${posLabel ? `
${esc(posLabel)} · chargement...
` : ''}
${sparklineHasData ? `
` : `
Évolution disponible après 2 matchs notés
` } ${rollingAvg5 != null && rollingAvg5Count >= 2 ? `
Moy. ${rollingAvg5Count} derniers matchs ${rollingAvg5.toFixed(2)}
` : '' }
`; // ── 3. Community section ── // Derive last 5 rated matches from match history const ratedMatches = (matches || []) .filter(m => m.status === 'finished' && m.avg_rating && parseFloat(m.avg_rating) > 0) .sort((a, b) => new Date(b.match_date || 0) - new Date(a.match_date || 0)) .slice(0, 5); const totalVotesDisplay = validVotes > 0 ? (validVotes >= 1000 ? (validVotes/1000).toFixed(1)+'k' : validVotes) : null; // Distribution buckets for histogram (1-3, 4-5, 6-7, 8-9, 10) const allRatedInHistory = (jriHist || []).filter(h => h.fan_score != null && h.fan_score > 0); const buckets = [0,0,0,0,0]; // [1-3, 4-5, 6-7, 8-9, 10] allRatedInHistory.forEach(h => { const s = parseFloat(h.fan_score); if (s <= 3) buckets[0]++; else if (s <= 5) buckets[1]++; else if (s <= 7) buckets[2]++; else if (s < 10) buckets[3]++; else buckets[4]++; }); const maxBucket = Math.max(...buckets, 1); const histColors = ['#ef4444','#f97316','#fbbf24','#4ade80','#00e676']; const histLabels = ['≤3','4-5','6-7','8-9','10']; const histHTML = `
${buckets.map((cnt, i) => `
${histLabels[i]}
`).join('')}
`; const recentVotesHTML = ratedMatches.length > 0 ? ratedMatches.map(m => { const score = parseFloat(m.avg_rating).toFixed(1); const scoreClass = score >= 7 ? 'high' : score >= 5 ? 'mid' : 'low'; const opp = m.is_home === false ? m.home_team : m.away_team; const matchStr = opp ? esc(opp) : (m.home_team && m.away_team ? esc(m.home_team) + ' – ' + esc(m.away_team) : 'Match'); const dateStr = m.match_date ? new Date(m.match_date).toLocaleDateString('fr-FR', {day:'2-digit',month:'short'}) : ''; return `
${score} vs ${matchStr} ${dateStr}
`; }).join('') : `
Aucun vote récent — soyez le premier !
`; const isLoggedIn = !!localStorage.getItem('jr_token') || !!document.cookie.includes('jr_session'); const rateBtnHTML = ` `; const communityHTML = `
💬 Ce que la communauté pense
${totalVotesDisplay ? `
${totalVotesDisplay} vote${validVotes > 1 ? 's' : ''}
` : ''}
${recentVotesHTML}
${allRatedInHistory.length >= 3 ? histHTML : ''}
${rateBtnHTML}
`; // ── 4. Career section ── // Build career from seasons data (club-by-club) const clubMap = {}; const currentTeam = p.team; (seasons || []).forEach(s => { if (!s.team_name) return; if (!clubMap[s.team_name]) clubMap[s.team_name] = { from: s.season, to: s.season, apps: 0, goals: 0 }; else { clubMap[s.team_name].from = Math.min(clubMap[s.team_name].from, s.season); clubMap[s.team_name].to = Math.max(clubMap[s.team_name].to, s.season); } clubMap[s.team_name].apps += (parseInt(s.appearances) || 0); clubMap[s.team_name].goals += (parseInt(s.goals) || 0); }); const careerClubs = Object.entries(clubMap) .sort((a, b) => b[1].from - a[1].from) .slice(0, 8); let careerHTML = ''; if (careerClubs.length > 0) { const items = careerClubs.map(([name, d], idx) => { const isCurrentClub = name === currentTeam || d.to >= 2025; const toY = d.to >= 2025 ? 'présent' : String(d.to + 1).slice(2); const fromY = d.from || '?'; const stats = [d.apps > 0 ? `${d.apps} mj` : '', d.goals > 0 ? `${d.goals} buts` : ''].filter(Boolean).join(' · '); const isLast = idx === careerClubs.length - 1; return `
${!isLast ? '
' : ''}
${esc(name)}
${fromY}–${toY}${stats ? ' · ' + stats : ''}
`; }).join(''); careerHTML = `
🏟 Carrière
${items}
`; } // ── 5. Market value placeholder (replaced async) ── const mvBannerHTML = `
`; // Inject everything zone.innerHTML = jriHeroHTML + statsHTML + communityHTML + mvBannerHTML + careerHTML; // Render JRI sparkline if (sparklineHasData) { requestAnimationFrame(() => renderDataZoneJriSparkline('dataZoneJriCanvas', ratedMatchesForSparkline)); } // Load market value asynchronously and inject banner loadDataZoneMvBanner(p); // Load position average for JRI comparison loadJriPositionAvg(p); } function renderDataZoneJriSparkline(canvasId, history) { const canvas = document.getElementById(canvasId); if (!canvas) return; if (dataZoneJriChart) { dataZoneJriChart.destroy(); dataZoneJriChart = null; } const sorted = [...history].sort((a, b) => new Date(a.match_date || 0) - new Date(b.match_date || 0)); const vals = sorted.map(h => h.justrate_index != null ? parseFloat(h.justrate_index.toFixed(2)) : null); const lastVal = vals.filter(v => v != null).pop() || 0; const firstVal = vals.filter(v => v != null)[0] || 0; const isRising = lastVal >= firstVal; const lineColor = isRising ? '#10b981' : '#ef4444'; const bgColor = isRising ? 'rgba(16,185,129,0.1)' : 'rgba(239,68,68,0.08)'; dataZoneJriChart = new Chart(canvas.getContext('2d'), { type: 'line', data: { labels: sorted.map((_, i) => i + 1), datasets: [{ data: vals, borderColor: lineColor, backgroundColor: bgColor, borderWidth: 2, pointRadius: 0, pointHoverRadius: 3, pointHoverBackgroundColor: lineColor, tension: 0.4, fill: true, spanGaps: true }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 700 }, scales: { x: { display: false }, y: { display: false } }, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => ' JRI ' + ctx.raw }, backgroundColor: 'rgba(10,12,18,0.95)', bodyColor: lineColor, padding: 6 }} } }); } function renderDataZoneMvSparkline(canvasId, history, chartColor) { const canvas = document.getElementById(canvasId); if (!canvas) return; if (dataZoneMvChart) { dataZoneMvChart.destroy(); dataZoneMvChart = null; } const sorted = [...history].sort((a, b) => new Date(a.date || 0) - new Date(b.date || 0)); const vals = sorted.map(h => h.raw / 1_000_000); dataZoneMvChart = new Chart(canvas.getContext('2d'), { type: 'line', data: { labels: sorted.map((_, i) => i), datasets: [{ data: vals, borderColor: chartColor, backgroundColor: 'transparent', borderWidth: 2.5, pointRadius: 0, tension: 0.4, fill: false, spanGaps: true }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 500 }, scales: { x: { display: false }, y: { display: false } }, plugins: { legend: { display: false }, tooltip: { enabled: false } } } }); } async function loadDataZoneMvBanner(p) { const el = document.getElementById('dataZoneMvBanner'); if (!el) return; try { const teamParam = p && p.team ? `?team=${encodeURIComponent(p.team)}` : ''; const res = await fetch('/api/player/' + encodeURIComponent(playerName) + '/market-value' + teamParam); if (!res.ok) return; const data = await res.json(); const mv = data && data.market_value; if (!mv || !mv.formatted) return; const history = (mv.history || []).filter(h => h.raw && h.date); let trendClass = 'neutral', trendLabel = ''; let chartColor = '#ffc107'; if (history.length >= 2 && mv.raw) { const sixMonthsAgo = new Date(); sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); const sorted6 = [...history].sort((a, b) => new Date(a.date) - new Date(b.date)); const pastEntry = sorted6.reduce((best, h) => new Date(h.date) <= sixMonthsAgo ? h : best, null); if (pastEntry && pastEntry.raw > 0) { const pct = (mv.raw - pastEntry.raw) / pastEntry.raw * 100; trendClass = pct > 0 ? 'up' : 'down'; trendLabel = (pct > 0 ? '▲' : '▼') + ' ' + Math.abs(pct).toFixed(0) + '% 6 mois'; chartColor = pct > 0 ? '#22c55e' : '#ef4444'; } } const updatedStr = mv.updated_at ? 'MAJ ' + new Date(mv.updated_at).toLocaleDateString('fr-FR', {day:'numeric',month:'short'}) : ''; const highestStr = mv.highest && mv.highest.formatted ? `Pic : ${mv.highest.formatted}` : ''; el.innerHTML = `
💶
${esc(mv.formatted)}
${trendLabel ? `${trendLabel}` : ''}
Valeur marchande ${updatedStr ? `·${esc(updatedStr)}` : ''} ${highestStr ? `·${esc(highestStr)}` : ''}
${history.length >= 2 ? `` : ''} Transfermarkt
`; if (history.length >= 2) { requestAnimationFrame(() => renderDataZoneMvSparkline('dataZoneMvCanvas', history, chartColor)); } } catch(e) { /* non-fatal — banner stays hidden */ } } async function loadJriPositionAvg(p) { const el = document.getElementById('jriPosAvgLine'); if (!el) return; try { const pos = p.position || p.detailed_position; if (!pos) return; // Fetch leaderboard top entries to compute position average const res = await fetch('/api/athletes/jri-leaderboard?per_page=100&position=' + encodeURIComponent(pos)); if (!res.ok) return; const data = await res.json(); const athletes = data.athletes || data.players || data.data || []; if (!athletes.length) return; const scored = athletes.filter(a => { const s = a.bayesian_jri || a.justrate_index || a.jri || a.official_score_10; return s != null && parseFloat(s) > 0; }); if (!scored.length) return; const avg = scored.reduce((sum, a) => { const s = a.bayesian_jri || a.justrate_index || a.jri || a.official_score_10; return sum + parseFloat(s); }, 0) / scored.length; const posLabel = p.detailed_position_label || pos; const avgColor = avg >= 7 ? '#00e676' : avg >= 5 ? '#ffb300' : '#ff6b6b'; el.innerHTML = `${esc(posLabel)} · moy. poste : ${avg.toFixed(2)}`; } catch(e) { /* non-fatal */ } } load(); // ── Teaser gate — block voting + blur stats for guests ─────── (function applyPlayerTeaser() { if (typeof TeaserGate === 'undefined') return; if (TeaserGate.isLoggedIn()) return; // 1. Block rating submit buttons dynamically (rendered on demand) var rmModule = document.getElementById('playerRightCol') || document.body; function guardRatingButtons() { var btns = rmModule.querySelectorAll('#rmSubmitQuick, #rmSubmitDetailed, .rm-submit'); btns.forEach(function(btn) { if (btn.getAttribute('data-jtg-guarded')) return; btn.setAttribute('data-jtg-guarded', '1'); btn.addEventListener('click', function(e) { if (!TeaserGate.isLoggedIn()) { e.preventDefault(); e.stopImmediatePropagation(); TeaserGate.showModal('vote'); } }, true); }); } guardRatingButtons(); var observer = new MutationObserver(function() { guardRatingButtons(); }); observer.observe(document.body, { childList: true, subtree: true }); // 2. Add login CTA in right column rating module when it loads var rmWrap = document.getElementById('prcRatingBody'); if (rmWrap) { var rmObserver = new MutationObserver(function() { if (!TeaserGate.isLoggedIn()) { var rmEl = rmWrap.querySelector('.rm-module'); if (rmEl && !rmEl.getAttribute('data-jtg-overlay')) { rmEl.setAttribute('data-jtg-overlay', '1'); var banner = document.createElement('div'); banner.style.cssText = 'margin:0.85rem 1.1rem 0;padding:0.9rem;background:rgba(0,230,118,0.06);border:1px solid rgba(0,230,118,0.15);border-radius:10px;text-align:center'; banner.innerHTML = '

Connectez-vous pour noter ce joueur et influencer le JustRate Index

'; rmEl.appendChild(banner); } } }); rmObserver.observe(rmWrap, { childList: true, subtree: false }); } })(); })();