Football Player Comparison
Compare Panna ratings and stats between football players
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))
}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:#1e1e1e;color:#fff;border:1px solid #444;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:#2a2a2a;border:1px solid #444;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:#888;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 = "#383838" })
item.addEventListener("mouseleave", function() { item.style.background = "none" })
const nameSpan = document.createElement("span")
nameSpan.style.color = "#fff"
nameSpan.textContent = p.name
item.appendChild(nameSpan)
const teamSpan = document.createElement("span")
teamSpan.style.cssText = "color:#888;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:#888;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.panna || 0) > 0 && (d.total_minutes || 0) > 900)
.sort((a, b) => (b.panna || 0) - (a.panna || 0))
.slice(0, 6)
for (const tp of topPlayers) {
const chip = document.createElement("button")
chip.textContent = tp.player_name + " (" + (tp.panna || 0).toFixed(2) + ")"
chip.style.cssText = "background:#2a2a2a;border:1px solid #444;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:#2a2a2a;border:1px solid #444;color:#aaa;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:#888">
<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``
const rows = []
for (const name of selectedPlayers) {
const p = playerLookup.get(name)
if (!p) continue
rows.push({
_name: name,
player: name,
team: p.team || "",
position: p.position || "",
league: p.league || "",
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``
return html`<h3 style="margin-top:1.5rem">Current Ratings</h3>
${statsTable(rows, {
columns: ["player", "team", "position", "league", "panna", "offense", "defense", "spm_overall", "total_minutes", "panna_percentile"],
header: {
player: "Player", team: "Team", position: "Pos", league: "League",
panna: "Panna", offense: "Offense", defense: "Defense",
spm_overall: "SPM", total_minutes: "Mins", panna_percentile: "Pctl"
},
groups: [{ label: "", span: 4 }, { label: "Ratings", span: 6 }],
format: {
total_minutes: x => x?.toLocaleString() ?? ""
},
heatmap: defs.heatmap,
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>'
}
},
sort: "panna",
reverse: true,
rows: 10
})}`
}