Football Player Comparison
Compare Piero ratings and stats between football players
Show code
ratings = {
const d = await fetchParquet(base_url + "football/ratings.parquet")
if (d == null) return d
// Attach Piero (composite player rating); pool-relative, so compute over all
// rows. Degrades to panna until ratings.parquet ships EPR/PSR.
const piero = window.pieroRating.computePlayerRating(d, { scaleTo: "panna" })
for (let i = 0; i < d.length; i++) d[i].piero = piero[i]
return d
}Show code
playerLookup = {
if (!ratings) return new Map()
const map = new Map()
for (const d of ratings) {
map.set(d.player_name, d)
}
return map
}
playerList = {
if (!playerLookup || playerLookup.size === 0) return []
return [...playerLookup.values()]
.map(d => ({ name: d.player_name, team: d.team || "", position: d.position || "", panna: d.panna }))
.sort((a, b) => a.name.localeCompare(b.name))
}
// Chain-derived detailed position lookup. URL-keyed cache (shared with
// player.qmd / player-stats.qmd) — only stores non-empty arrays to avoid
// a transient empty-fetch poisoning the entire session.
_playerPositions = {
const url = window.DATA_BASE_URL + "football/player-positions.parquet"
const cache = window._playerPositionsCache
if (cache && cache.url === url && cache.data) return cache.data
try {
const data = await window.fetchParquet(url)
if (Array.isArray(data) && data.length > 0) {
window._playerPositionsCache = { url, data }
}
return data
} catch (e) {
console.error("[football-compare] player-positions.parquet load failed:", e)
return null
}
}Show code
Show code
viewof selectedPlayers = {
if (!ratings || playerList.length === 0) return html`<p class="text-muted">Loading player data...</p>`
const MAX_PLAYERS = 4
const PALETTE = ["#22c55e", "#5dadec", "#e06666", "#e8b84b", "#c084fc", "#f4845f"]
const state = [..._initPlayers]
const container = document.createElement("div")
container.value = [...state]
// Cards row
const cardsRow = document.createElement("div")
cardsRow.style.cssText = "display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:0.75rem"
container.appendChild(cardsRow)
// Search wrap
const searchWrap = document.createElement("div")
searchWrap.style.cssText = "position:relative;max-width:320px;z-index:50"
const input = document.createElement("input")
input.type = "text"
input.placeholder = state.length >= MAX_PLAYERS ? "Max 4 players" : "Search for a player..."
input.className = "form-control"
input.style.cssText = "background:var(--site-surface-bg);color:var(--site-body-color);border:1px solid var(--site-surface-border);padding:0.4rem 0.6rem;font-size:0.9rem;width:100%"
searchWrap.appendChild(input)
const dropdown = document.createElement("div")
dropdown.style.cssText = "position:absolute;top:100%;left:0;right:0;background:var(--site-surface-raised);border:1px solid var(--site-surface-border);border-top:none;max-height:240px;overflow-y:auto;display:none;z-index:100;border-radius:0 0 6px 6px"
searchWrap.appendChild(dropdown)
container.appendChild(searchWrap)
function getPlayerColor(idx) { return PALETTE[idx % PALETTE.length] }
function renderCards() {
while (cardsRow.firstChild) cardsRow.removeChild(cardsRow.firstChild)
for (let i = 0; i < state.length; i++) {
const name = state[i]
const p = playerLookup.get(name)
if (!p) continue
const color = getPlayerColor(i)
const card = document.createElement("div")
card.style.cssText = "display:flex;align-items:center;gap:0.4rem;padding:0.35rem 0.6rem;background:" + color + "18;border:1px solid " + color + "50;border-radius:6px;font-size:0.85rem"
const nameEl = document.createElement("a")
nameEl.href = "player#name=" + encodeURIComponent(name)
nameEl.textContent = name
nameEl.style.cssText = "color:" + color + ";font-weight:600;text-decoration:none"
card.appendChild(nameEl)
const removeBtn = document.createElement("button")
removeBtn.textContent = "\u00d7"
removeBtn.style.cssText = "background:none;border:none;color:var(--site-muted-color);cursor:pointer;font-size:1.1rem;padding:0 0.2rem;margin-left:0.2rem"
;(function(index) {
removeBtn.addEventListener("click", function() {
state.splice(index, 1)
renderCards()
updateHash()
input.placeholder = state.length >= MAX_PLAYERS ? "Max 4 players" : "Search for a player..."
input.disabled = state.length >= MAX_PLAYERS
container.value = [...state]
container.dispatchEvent(new Event("input", { bubbles: true }))
})
})(i)
card.appendChild(removeBtn)
cardsRow.appendChild(card)
}
if (typeof suggestWrap !== "undefined") {
suggestWrap.style.display = state.length === 0 ? "block" : "none"
}
}
function updateHash() {
const params = state.map(function(name, i) { return "p" + (i + 1) + "=" + encodeURIComponent(name) }).join("&")
history.replaceState(null, "", "#" + params)
}
function showDropdown(query) {
while (dropdown.firstChild) dropdown.removeChild(dropdown.firstChild)
if (!query || query.length < 2) { dropdown.style.display = "none"; return }
const q = query.toLowerCase()
const matches = playerList.filter(function(p) { return !state.includes(p.name) && p.name.toLowerCase().includes(q) }).slice(0, 12)
if (matches.length === 0) { dropdown.style.display = "none"; return }
for (const p of matches) {
const item = document.createElement("div")
item.style.cssText = "padding:0.4rem 0.6rem;cursor:pointer;display:flex;align-items:center;gap:0.4rem;font-size:0.85rem"
item.addEventListener("mouseenter", function() { item.style.background = "rgba(var(--site-overlay-rgb), 0.08)" })
item.addEventListener("mouseleave", function() { item.style.background = "none" })
const nameSpan = document.createElement("span")
nameSpan.style.color = "var(--site-body-color)"
nameSpan.textContent = p.name
item.appendChild(nameSpan)
const teamSpan = document.createElement("span")
teamSpan.style.cssText = "color:var(--site-muted-color);font-size:0.8rem;margin-left:auto"
teamSpan.textContent = p.team
item.appendChild(teamSpan)
;(function(playerName) {
item.addEventListener("click", function() {
state.push(playerName)
input.value = ""
dropdown.style.display = "none"
renderCards()
updateHash()
input.placeholder = state.length >= MAX_PLAYERS ? "Max 4 players" : "Search for a player..."
input.disabled = state.length >= MAX_PLAYERS
container.value = [...state]
container.dispatchEvent(new Event("input", { bubbles: true }))
})
})(p.name)
dropdown.appendChild(item)
}
dropdown.style.display = "block"
}
input.addEventListener("input", function() { showDropdown(input.value) })
input.addEventListener("focus", function() { showDropdown(input.value) })
document.addEventListener("click", function(e) { if (!searchWrap.contains(e.target)) dropdown.style.display = "none" })
// Top-Panna suggestion chips (empty state)
var suggestWrap = document.createElement("div")
suggestWrap.style.cssText = "margin-top:0.75rem;display:none"
const suggestLabel = document.createElement("div")
suggestLabel.textContent = "Try comparing top-rated players:"
suggestLabel.style.cssText = "color:var(--site-muted-color);font-size:0.8rem;margin-bottom:0.4rem"
suggestWrap.appendChild(suggestLabel)
const suggestRow = document.createElement("div")
suggestRow.style.cssText = "display:flex;flex-wrap:wrap;gap:0.35rem"
suggestWrap.appendChild(suggestRow)
container.appendChild(suggestWrap)
const topPlayers = [...playerLookup.values()]
.filter(d => (d.piero || 0) > 0 && (d.total_minutes || 0) > 900)
.sort((a, b) => (b.piero || 0) - (a.piero || 0))
.slice(0, 6)
for (const tp of topPlayers) {
const chip = document.createElement("button")
chip.textContent = tp.player_name + " (" + (tp.piero || 0).toFixed(2) + ")"
chip.style.cssText = "background:var(--site-surface-raised);border:1px solid var(--site-surface-border);color:#22c55e;padding:0.3rem 0.6rem;border-radius:14px;font-size:0.8rem;cursor:pointer"
;(function(pname) {
chip.addEventListener("click", function() {
state.push(pname)
renderCards()
updateHash()
input.placeholder = state.length >= MAX_PLAYERS ? "Max 4 players" : "Search for a player..."
input.disabled = state.length >= MAX_PLAYERS
container.value = [...state]
container.dispatchEvent(new Event("input", { bubbles: true }))
})
})(tp.player_name)
suggestRow.appendChild(chip)
}
// Share link button
const shareBtn = document.createElement("button")
shareBtn.textContent = "Copy share link"
shareBtn.style.cssText = "margin-top:0.5rem;background:var(--site-surface-raised);border:1px solid var(--site-surface-border);color:var(--site-muted-color);padding:0.3rem 0.7rem;border-radius:4px;font-size:0.8rem;cursor:pointer;margin-left:0.5rem"
shareBtn.addEventListener("click", function() {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(window.location.href).then(function() {
const prev = shareBtn.textContent
shareBtn.textContent = "Copied!"
shareBtn.style.color = "#6fcf97"
setTimeout(function() { shareBtn.textContent = prev; shareBtn.style.color = "#aaa" }, 1500)
})
}
})
searchWrap.appendChild(shareBtn)
input.disabled = state.length >= MAX_PLAYERS
renderCards()
return container
}Show code
// Comparison table — current ratings side by side
{
if (!ratings || !selectedPlayers || selectedPlayers.length === 0) {
return html`<div style="text-align:center;padding:3rem 1rem;color:var(--site-muted-color)">
<p style="font-size:1.1rem">Select players above to compare their ratings.</p>
<p style="font-size:0.85rem">Tip: You can link here from any player profile using the "Compare" link.</p>
</div>`
}
const defs = window.footballStatDefs ? window.footballStatDefs.ratings : null
if (!defs) return html``
// Build a (player_name + league_short_code) → row lookup so a multi-league
// player shows the role + centroid for their primary league. Keyed on
// player_name because the ratings data here doesn't carry player_id.
const positionRowByName = new Map()
if (_playerPositions) {
for (const r of _playerPositions) {
if (!r.player_name) continue
const k = r.player_name + "|" + r.league
if (!positionRowByName.has(k)) positionRowByName.set(k, r)
}
}
const optaToPanna = (window.footballMaps && window.footballMaps.optaToPanna) || {}
const pannaInv = (window.footballMaps && window.footballMaps.pannaLeagueMapInverse) || {}
const badgeMap = (window.footballMaps && window.footballMaps.pannaBadge) || {}
const rows = []
for (const name of selectedPlayers) {
const p = playerLookup.get(name)
if (!p) continue
const leagueCode = pannaInv[p.league] || p.league
const posRow = positionRowByName.get(name + "|" + leagueCode)
const derived = posRow?.detailed_position
// Prefer derived (granular) → Opta-mapped short code → raw long-form
const posDisplay = derived || optaToPanna[p.position] || p.position || ""
rows.push({
_name: name,
_posRow: posRow, // carries avg_x / avg_y / n_touches for the centroid render
_posColour: badgeMap[posDisplay] || "#9ca3af",
player: name,
team: p.team || "",
position: posDisplay,
pitch: posRow?.avg_x != null ? "•" : "", // placeholder cell value; render() draws the SVG
league: p.league || "",
piero: +(p.piero || 0).toFixed(3),
panna: +(p.panna || 0).toFixed(3),
offense: +(p.offense || 0).toFixed(3),
defense: +(p.defense || 0).toFixed(3),
spm_overall: +(p.spm_overall || 0).toFixed(3),
total_minutes: p.total_minutes || 0,
panna_percentile: +(p.panna_percentile || 0).toFixed(1)
})
}
if (rows.length === 0) return html``
// Inline SVG pitch renderer for the Pitch column. Tiny ~60x40 with one
// dot at the player's chain centroid (broadcast convention — attacker's
// right at the bottom, matches player-profile fingerprint + team formation).
function renderMiniPitch(_, row) {
const pr = row._posRow
if (!pr || pr.avg_x == null || pr.avg_y == null) return '<span class="text-muted" style="font-size:0.7rem">—</span>'
const cx = pr.avg_x.toFixed(1)
const cy = (68 - (pr.avg_y / 100) * 68).toFixed(1)
const col = statsEsc(row._posColour || "#9ca3af")
const tip = statsEsc(`${row._name} · x=${pr.avg_x.toFixed(0)}, y=${pr.avg_y.toFixed(0)} · ${(pr.n_touches || 0).toLocaleString()} touches`)
return `<svg viewBox="-2 -2 104 72" style="width:60px;height:auto;display:block;margin:auto" role="img" aria-label="${tip}"><title>${tip}</title>` +
`<rect x="0" y="0" width="100" height="68" rx="2" fill="rgba(34,40,32,0.4)" stroke="rgba(255,255,255,0.15)" stroke-width="0.6"/>` +
`<line x1="50" y1="0" x2="50" y2="68" stroke="rgba(255,255,255,0.12)" stroke-width="0.4"/>` +
`<circle cx="${cx}" cy="${cy}" r="6" fill="${col}" stroke="rgba(0,0,0,0.6)" stroke-width="0.6"/></svg>`
}
return html`<h3 style="margin-top:1.5rem">Current Ratings</h3>
${statsTable(rows, {
columns: ["player", "team", "position", "pitch", "league", "piero", "panna", "offense", "defense", "spm_overall", "total_minutes", "panna_percentile"],
header: {
player: "Player", team: "Team", position: "Pos", pitch: "Pitch", league: "League",
piero: "Piero", panna: "Panna", offense: "Offense", defense: "Defense",
spm_overall: "SPM", total_minutes: "Mins", panna_percentile: "Pctl"
},
groups: [{ label: "", span: 5 }, { label: "Ratings", span: 7 }],
format: {
total_minutes: x => x?.toLocaleString() ?? ""
},
tooltip: { piero: "Piero — composite player rating (panna+EPR+PSR blend), on the Panna scale" },
heatmap: { ...defs.heatmap, piero: "high-good" },
heatmapData: rows,
render: {
player: function(v, row) {
return '<a href="player#name=' + encodeURIComponent(row._name || "") + '" class="player-link" style="font-weight:600">' + statsEsc(v) + '</a>'
},
pitch: renderMiniPitch
},
sort: "piero",
reverse: true,
rows: 10
})}`
}Show code
// ── Source attribution row beneath the comparison table ─────
{
if (selectedPlayers.length === 0) return html``
const asAt = await compareAsAt
return window.editorial.tableSource({
source: "pannadata",
sourceUrl: "https://github.com/peteowen1/pannadata",
asAt,
hint: "Latest career ratings per player"
})
}