JustRate
☰
Match actuel
Statistiques
Analysebientôt
Historique
Classementbientôt
Valorisation
Bio
Comparaisonbientôt
⭐
Soyez le premier à noter ce joueur
Votre note compte dans le JustRate Index — un algorithme unique combinant votes de fans et stats officielles.
Donner une note →
🗳️ Voter — Sélectionnez un match
🎯
Sélectionnez un match dans la colonne gauche pour noter ce joueur.
À PROPOS DU JRI — JUSTRATE INDEX
Score sur 10 reflétant la vraie valeur d'un joueur, entraîneur ou arbitre.
Algorithme propriétaire combinant des milliers de paramètres : stats de performance officielles, votes des fans et pondérations dynamiques.
La vraie valeur. Mesurée par les données, validée par les fans.
`;
}
// ── 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 = `
💶
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 `
${esc(String(t.season||t.year||'—'))}
${feeDisplay ? `${feeDisplay} ` : ''}
${esc(t.from||'?')}
→
${esc(t.to||'?')}
`;
}).join('');
transfersHtml = `
🔁 Historique des transferts
${items}
`;
}
el.innerHTML = `
${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 = ``;
}
// ── 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 = ``;
}
async function loadMarketValue(player) {
const el = document.getElementById('tmktValueSection');
if (!el) return;
// Show loading state
el.innerHTML = ``;
// 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 ? `` : '',
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 => `${seasonLbl(s.season)} — ${esc(s.team_name||'')} `).join('');
// Available leagues
const leagueMap = {};
seasons.forEach(s => { if (s.league_name) leagueMap[s.league_name] = true; });
const leagueOpts = Object.keys(leagueMap).map(l => `${esc(l)} `).join('');
return `
${leagueOpts ? `
Compétition
Toutes ${leagueOpts}
` : ''}
${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 ? `
${partRows}
` : ''}
${attRows ? `
${attRows}
` : ''}
${discRows ? `
${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 ? `
${shotRows}
` : ''}
${passRows ? `
${passRows}
` : ''}
${techRows ? `
${techRows}
` : ''}
${defRows ? `
${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
${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 `
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 => `${seasonLbl(s)} `).join('');
const compOpts = comps.map(c => `${esc(c)} `).join('');
const teamOpts = teams.length > 1 ? teams.map(t => `${esc(t)} `).join('') : '';
const total = matches.length;
const totalLabel = total ? `${total} match${total>1?'s':''} ` : '';
return `
Saison
Toutes ${seasonOpts}
${compOpts ? `
Compétition
Toutes ${compOpts}
` : ''}
${teamOpts ? `
` : ''}
Période
Toutes
30 jours
90 jours
Cette saison
${totalLabel}
`;
}
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 ? `
` : `${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)} `
: `${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 = ``;
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 ``;
}
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);
}
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 = ``;
}
// 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 = ``;
}
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 =
'';
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 `${lbl} `;
}).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 = `
${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)}
${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 = `
${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 =
'';
}
}
// ── 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 ? `
` : '';
// 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 = `
⚖
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 = `
${esc(a.player_name.split(' ').pop())}
🆚 ${esc(b.player_name.split(' ').pop())}
${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)}
`;
// 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
⏱
${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 = `
${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 = `
`;
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 ``;
}).join('')
: `Aucun vote récent — soyez le premier !
`;
const isLoggedIn = !!localStorage.getItem('jr_token') || !!document.cookie.includes('jr_session');
const rateBtnHTML = `
`;
const communityHTML = `
`;
// ── 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 `
${esc(name)}
${fromY}–${toY}${stats ? ' · ' + stats : ''}
`;
}).join('');
careerHTML = `
`;
}
// ── 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
Se connecter / S\'inscrire ';
rmEl.appendChild(banner);
}
}
});
rmObserver.observe(rmWrap, { childList: true, subtree: false });
}
})();
})();