In The Game
  • Home
  • Blog
  • AFL
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Matches
    • Ladder
    • Definitions
  • Football
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Leagues
    • Matches
    • Definitions
  • About
Skip to content

AFL Player Comparison

Compare TORP ratings and stats between AFL players over time
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL
Show code
ratings = fetchParquet(base_url + "afl/ratings.parquet")
details = fetchParquet(base_url + "afl/player-details.parquet")
Show code
_aflMaps = {
  if (!window.aflTeamMaps) throw new Error("AFL team mappings failed to load. Try refreshing the page.")
  return window.aflTeamMaps
}
aflTeamColors2 = _aflMaps.aflTeamColors2
teamLogo = _aflMaps.teamLogo
predToFull = _aflMaps.predToFull
posBadgeMap = _aflMaps.posColors
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
// Read initial players from hash params
_initPlayers = {
  const ids = []
  for (let i = 1; i <= 4; i++) {
    const id = window._getHashParam("p" + i)
    if (id && playerLookup.has(id)) ids.push(id)
  }
  return ids
}
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
  })}`
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer