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

Football Player Comparison

Compare Panna ratings and stats between football players
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL
Show code
ratings = fetchParquet(base_url + "football/ratings.parquet")
Show code
footballPosColorsCmp = window.footballMaps.posGroupColors
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
_initPlayers = {
  const names = []
  for (let i = 1; i <= 4; i++) {
    const raw = window._getHashParam("p" + i)
    if (raw) {
      const name = raw.replace(/\+/g, " ")
      if (playerLookup.has(name)) names.push(name)
    }
  }
  return names
}
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
  })}`
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer