AFL Player Comparison
Compare TORP ratings and stats between AFL players over time
Show code
Show code
Show code
playerLookup = {
if (!ratings) return new Map()
const latest = new Map()
for (const d of ratings) {
const prev = latest.get(d.player_id)
if (!prev || d.season > prev.season || (d.season === prev.season && (d.round || 0) > (prev.round || 0))) {
latest.set(d.player_id, d)
}
}
return latest
}
playerList = {
if (!playerLookup || playerLookup.size === 0) return []
return [...playerLookup.values()]
.map(d => ({ id: d.player_id, name: d.player_name, team: predToFull[d.team] || d.team, position_group: d.position_group }))
.sort((a, b) => a.name.localeCompare(b.name))
}Show code
Show code
// Player selection UI
viewof selectedPlayers = {
if (!ratings || playerList.length === 0) return html`<p class="text-muted">Loading player data...</p>`
const MAX_PLAYERS = 4
const PALETTE = ["#e8b84b", "#5dadec", "#e06666", "#6fcf97", "#c084fc", "#f4845f", "#7dd3fc", "#fbbf24"]
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 input
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) {
const pid = state[idx]
const p = playerLookup.get(pid)
const teamColor = p ? aflTeamColors2[predToFull[p.team] || p.team] : null
return teamColor || PALETTE[idx % PALETTE.length]
}
function renderCards() {
while (cardsRow.firstChild) cardsRow.removeChild(cardsRow.firstChild)
for (let i = 0; i < state.length; i++) {
const pid = state[i]
const p = playerLookup.get(pid)
if (!p) continue
const team = predToFull[p.team] || p.team
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 logo = teamLogo(team)
if (logo) {
const img = document.createElement("img")
img.src = logo
img.alt = ""
img.style.cssText = "width:20px;height:20px"
card.appendChild(img)
}
const nameEl = document.createElement("a")
nameEl.href = "player#id=" + encodeURIComponent(pid)
nameEl.textContent = p.player_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)
}
// Toggle empty-state suggestions
if (typeof suggestWrap !== "undefined") {
suggestWrap.style.display = state.length === 0 ? "block" : "none"
}
}
function updateHash() {
const params = state.map(function(id, i) { return "p" + (i + 1) + "=" + encodeURIComponent(id) }).join("&")
const metric = window._getHashParam("metric")
const hash = params + (metric ? "&metric=" + metric : "")
history.replaceState(null, "", "#" + hash)
}
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.id) && 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 logo = teamLogo(p.team)
if (logo) {
const img = document.createElement("img")
img.src = logo
img.alt = ""
img.style.cssText = "width:18px;height:18px"
item.appendChild(img)
}
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(playerId) {
item.addEventListener("click", function() {
state.push(playerId)
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.id)
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-TORP suggestion chips (empty state only)
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.torp || 0) > 0)
.sort((a, b) => (b.torp || 0) - (a.torp || 0))
.slice(0, 6)
for (const tp of topPlayers) {
const chip = document.createElement("button")
chip.textContent = tp.player_name + " (" + (Math.round((tp.torp || 0) * 10) / 10).toFixed(1) + ")"
chip.style.cssText = "background:#2a2a2a;border:1px solid #444;color:#e8b84b;padding:0.3rem 0.6rem;border-radius:14px;font-size:0.8rem;cursor:pointer"
;(function(pid) {
chip.addEventListener("click", function() {
state.push(pid)
renderCards()
updateHash()
input.placeholder = state.length >= MAX_PLAYERS ? "Max 4 players" : "Search for a player..."
input.disabled = state.length >= MAX_PLAYERS
suggestWrap.style.display = state.length === 0 ? "block" : "none"
container.value = [...state]
container.dispatchEvent(new Event("input", { bubbles: true }))
})
})(tp.player_id)
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
viewof compareMetric = {
if (!ratings) return html``
const metrics = ["torp", "epr", "psr", "osr", "dsr"]
const labels = { torp: "TORP", epr: "EPR", psr: "PSR", osr: "OSR", dsr: "DSR" }
const initMetric = window._getHashParam("metric") || "torp"
const wrap = document.createElement("div")
wrap.className = "epv-toggle"
wrap.value = metrics.includes(initMetric) ? initMetric : "torp"
for (const m of metrics) {
const btn = document.createElement("button")
btn.className = "epv-toggle-btn" + (m === wrap.value ? " active" : "")
btn.textContent = labels[m]
btn.addEventListener("click", function() {
wrap.querySelectorAll(".epv-toggle-btn").forEach(function(b) { b.classList.remove("active") })
btn.classList.add("active")
wrap.value = m
wrap.dispatchEvent(new Event("input", { bubbles: true }))
// Update hash metric param
var currentHash = window.location.hash.replace(/([&?]?)metric=[^&]*/g, "").replace(/^#/, "")
history.replaceState(null, "", "#" + currentHash + (currentHash ? "&" : "") + "metric=" + m)
})
wrap.appendChild(btn)
}
return wrap
}Show code
// Timeline chart
{
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 over time.</p>
<p style="font-size:0.85rem">Tip: You can link here from any player profile using the "Compare" link.</p>
</div>`
}
const metric = compareMetric || "torp"
const labels = { torp: "TORP", epr: "EPR", psr: "PSR", osr: "OSR", dsr: "DSR" }
const PALETTE = ["#e8b84b", "#5dadec", "#e06666", "#6fcf97", "#c084fc", "#f4845f", "#7dd3fc", "#fbbf24"]
// Build per-player data sorted chronologically
const playerDataMap = new Map()
for (const pid of selectedPlayers) {
const rows = ratings.filter(function(d) { return d.player_id === pid })
.sort(function(a, b) { return a.season - b.season || (a.round || 0) - (b.round || 0) })
playerDataMap.set(pid, rows)
}
// Build shared x-axis: union of all (season, round) pairs
const allPairsSet = new Set()
for (const rows of playerDataMap.values()) {
for (const d of rows) allPairsSet.add(d.season + "-" + (d.round || 0))
}
const allPairs = [...allPairsSet]
.map(function(s) { var parts = s.split("-").map(Number); return { season: parts[0], round: parts[1] } })
.sort(function(a, b) { return a.season - b.season || a.round - b.round })
const pairIndex = new Map()
allPairs.forEach(function(p, i) { pairIndex.set(p.season + "-" + p.round, i) })
// Compute period breaks and labels
const periodBreaks = []
const periodLabels = []
var prevSeason = null
for (var i = 0; i < allPairs.length; i++) {
if (prevSeason != null && allPairs[i].season !== prevSeason) {
periodBreaks.push({ x: i })
periodLabels.push(String(prevSeason))
}
prevSeason = allPairs[i].season
}
if (prevSeason != null) periodLabels.push(String(prevSeason))
// Build series with distinct colors
const series = []
const usedColors = []
var idx = 0
for (const pid of selectedPlayers) {
const rows = playerDataMap.get(pid)
const p = playerLookup.get(pid)
const team = p ? predToFull[p.team] || p.team : ""
var color = aflTeamColors2[team] || PALETTE[idx % PALETTE.length]
// Check for color clash with existing series
for (const used of usedColors) {
if (color === used) {
color = PALETTE[(idx + 2) % PALETTE.length]
break
}
}
usedColors.push(color)
const data = rows.map(function(d) {
return {
x: pairIndex.get(d.season + "-" + (d.round || 0)) || 0,
y: d[metric] || 0,
season: d.season,
round: d.round
}
})
series.push({
label: p ? p.player_name : pid,
color: color,
data: data,
endLabel: p ? p.player_name.split(" ").pop() : ""
})
idx++
}
const container = document.createElement("div")
window.chartHelpers.drawTimelineChart(container, {
series: series,
periodBreaks: periodBreaks,
periodLabels: periodLabels,
yLabel: labels[metric],
zeroLine: true,
width: 780,
height: 280,
tooltipFn: function(p, s) {
return {
title: s.label,
rows: [
["Season", String(p.season)],
["Round", p.round != null ? String(p.round) : "—"],
[labels[metric], (Math.round(p.y * 100) / 100).toFixed(2)]
]
}
}
})
return container
}Show code
// Comparison table — latest ratings side by side
{
if (!ratings || !selectedPlayers || selectedPlayers.length === 0) return html``
const defs = window.aflStatDefs ? window.aflStatDefs.ratings : null
if (!defs) return html``
const metric = compareMetric || "torp"
const rows = []
for (var i = 0; i < selectedPlayers.length; i++) {
const pid = selectedPlayers[i]
const p = playerLookup.get(pid)
if (!p) continue
const team = predToFull[p.team] || p.team
const det = details ? details.find(function(d) { return d.player_id === pid }) : null
var age = ""
if (det && det.date_of_birth) {
age = Math.floor((Date.now() - new Date(det.date_of_birth).getTime()) / 31557600000)
}
rows.push({
_pid: pid,
player: p.player_name,
team: team,
position_group: p.position_group || "",
age: age,
season: p.season,
torp: +(p.torp || 0).toFixed(2),
epr: +(p.epr || 0).toFixed(2),
psr: +(p.psr || 0).toFixed(2),
osr: +(p.osr || 0).toFixed(2),
dsr: +(p.dsr || 0).toFixed(2)
})
}
if (rows.length === 0) return html``
return html`<h3 style="margin-top:1.5rem">Current Ratings</h3>
${statsTable(rows, {
columns: ["player", "team", "position_group", "age", "torp", "epr", "psr", "osr", "dsr"],
header: { player: "Player", team: "Team", position_group: "Pos", age: "Age", ...defs.header },
groups: [{ label: "", span: 4 }, { label: "Ratings", span: 5 }],
heatmap: defs.heatmap,
heatmapData: rows,
render: {
player: function(v, row) {
return '<a href="player#id=' + encodeURIComponent(row._pid || "") + '" class="player-link" style="font-weight:600">' + statsEsc(v) + '</a>'
}
},
sort: metric,
reverse: true,
rows: 10
})}`
}