In The Game
  • Home
  • World Cup
  • Blog
  • Games
  • AFL
    • Overview

    • Matches
    • Ladder

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck
    • Age Curves

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • Football
    • Overview

    • Matches
    • Leagues

    • World Cup 2026
    • Simulator
    • Wall Chart
    • Pick Your Bracket
    • Title Race
    • Group Projections
    • Match Predictions
    • Player Stats
    • Player Ratings
    • Venues
    • Team Strength
    • Head to Head

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • About

Football Player Ratings

Skip to content

Football > Player Ratings

Football · The Panna Project · 15 Leagues

Who’s actually moving the scoreboard?

Piero is one number for a player’s all-round quality — a panna-led blend of Panna, EPR and PSR on the Panna scale. Two sides of the ball, every player in 15 leagues compared the same way, with minutes, position, and competition baked in.

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable

// Load ratings + attach Piero (composite player rating: z-blend of panna/EPR/PSR
// on panna's scale). EPR/PSR ship in ratings.parquet from the pannadata build;
// until they do, computePlayerRating degrades to panna so the column still
// renders (Piero ≈ Panna). Mutating the loaded rows means every downstream
// cell (search, heatmap, table) sees `piero` with no other change.
football_data = {
  const d = await window.fetchParquet(window.DATA_BASE_URL + "football/ratings.parquet")
  if (d == null) return d
  const piero = window.pieroRating.computePlayerRating(d, { scaleTo: "panna" })
  for (let i = 0; i < d.length; i++) d[i].piero = piero[i]
  // piero_rank (1 = highest Piero) — the leaderboard's primary order. Parquet
  // ships panna_rank; Piero is computed client-side, so rank it here too.
  const order = d.map((_, i) => i).sort((a, b) => (d[b].piero ?? -Infinity) - (d[a].piero ?? -Infinity))
  order.forEach((idx, r) => { d[idx].piero_rank = r + 1 })
  return d
}

// "As at <date>" label from the parquet's Last-Modified header on R2.
// Wording standardized to "As at" across all sports (matches AFL pages
// and the now-standardized football siblings). Football ratings.parquet
// has no per-row matchday column, so we use file freshness as the signal
// rather than the per-row match_date approach used by player-stats and
// team-stats.
ratingsAsAt = {
  try {
    const res = await fetch(window.DATA_BASE_URL + "football/ratings.parquet", { method: "HEAD" })
    const lm = res.headers.get("last-modified")
    if (!lm) return "Latest"
    const d = new Date(lm)
    if (isNaN(d.getTime())) return "Latest"
    return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
  } catch (e) { console.warn("[player-ratings] last-modified fetch failed:", e); return "Latest" }
}
Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>${statsEsc(ratingsAsAt)}</strong></span>
  <span><a href="../blog/2026-04-24-understanding-panna/">Methodology &darr;</a></span>
  <span><a href="definitions.html">Definitions &nearr;</a></span>
  <span>&approx; 5 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
Show code
{ window._footballRatingsSkillsPromise = window.fetchParquet(window.DATA_BASE_URL + "football/player-skills.parquet").catch(e => { console.warn("[football-ratings] skills load failed:", e); return null }) }
Show code
leagueOptions = {
  if (!football_data) return []
  const leagues = [...new Set(football_data.map(d => d.league).filter(Boolean))].sort()
  return ["All Leagues", ...leagues]
}

leagueDisplay = {
  const m = new Map(leagueOptions.map(l => [l, l.replace(/_/g, " ")]))
  return m
}

viewof leagueFilter = {
  if (football_data == null) return html`<p></p>`

  const makeSelect = window.footballMaps.makeSelect

  const bar = document.createElement("div")
  bar.className = "player-filter-bar"
  const row = document.createElement("div")
  row.className = "filter-row"

  const { wrap, sel } = makeSelect(leagueOptions, "All Leagues", "League", x => leagueDisplay.get(x) || x)
  row.appendChild(wrap)
  bar.appendChild(row)

  bar.value = "All Leagues"
  sel.addEventListener("change", () => {
    bar.value = sel.value
    bar.dispatchEvent(new Event("input", { bubbles: true }))
  })

  return bar
}

leagueFiltered = {
  if (!football_data) return null
  if (leagueFilter === "All Leagues") return football_data
  return football_data.filter(d => d.league === leagueFilter)
}

// Local alias for window.footballMaps.optaToPanna so this OJS cell can
// reference it by a shorter name. ratings.parquet carries the same 7 Opta
// values mapped there — no extra entries needed here.
posAbbrMap = window.footballMaps.optaToPanna

viewof posFilter = {
  const positions = ["All", "GK", "DEF", "MID", "FWD"]
  const _key = "_posFilter_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = positions.includes(window[_key]) ? window[_key] : "All"
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const p of positions) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (p === _saved ? " active" : "")
    btn.dataset.pos = p
    btn.textContent = p
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = p
      window[_key] = p
      container.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  container.value = _saved
  return container
}

posFiltered = {
  if (!leagueFiltered) return null
  if (posFilter === "All") return leagueFiltered
  const allowed = window.footballMaps.posGroups[posFilter] || []
  return leagueFiltered.filter(d => allowed.includes(d.position))
}
Show code
viewof statCategory = {
  const defs = window.footballStatDefs || {}
  const catKeys = Object.keys(defs).filter(k => !defs[k].compute && k !== "value")
  const options = ["Value", ...catKeys.map(k => defs[k].label)]
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "Value"
  const _default = options.includes(_saved) ? _saved : "Value"
  const container = html`<div class="pos-pills">
    ${options.map(o => `<button class="pos-pill ${o === _default ? 'active' : ''}" data-val="${o}">${o}</button>`).join('')}
  </div>`
  container.value = _default
  container.querySelectorAll('.pos-pill').forEach(btn => {
    btn.addEventListener('click', () => {
      container.querySelectorAll('.pos-pill').forEach(b => b.classList.remove('active'))
      btn.classList.add('active')
      container.value = btn.dataset.val
      window[_key] = container.value
      container.dispatchEvent(new Event('input', {bubbles: true}))
    })
  })
  return container
}

// Join skills data for stat categories
footballSkills = {
  if (statCategory === "Value") return null
  return await window._footballRatingsSkillsPromise
}

statTableData = {
  if (statCategory === "Value" || !footballSkills || !posFiltered) return null

  const defs = window.footballStatDefs || {}
  // Find catDef by label
  const catKey = Object.keys(defs).find(k => defs[k].label === statCategory)
  const catDef = catKey ? defs[catKey] : null
  if (!catDef) return null

  // Build skills lookup
  const skillsMap = new Map()
  for (const s of footballSkills) skillsMap.set(s.player_name, s)

  // Join skills _p90 columns to ratings data
  return posFiltered.map(r => {
    const sk = skillsMap.get(r.player_name)
    const row = { ...r }
    if (sk) {
      for (const col of catDef.columns) {
        // Map stat-defs column to _p90 column in skills parquet
        const p90Key = col + "_p90"
        if (sk[p90Key] != null) row[col + "_r"] = sk[p90Key]
      }
    }
    return row
  })
}
Show code
// ── View toggle (Table / Scatter) ───────────────────────────
{
  const _key = "_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  if (!window[_key]) window[_key] = "Table"
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const label of ["Table", "Scatter"]) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (label === window[_key] ? " active" : "")
    btn.textContent = label
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      window[_key] = label
      const isTable = label === "Table"
      const tableView = document.querySelector(".ratings-table-view")
      const scatterView = document.querySelector(".ratings-scatter-view")
      if (tableView) tableView.style.display = isTable ? "" : "none"
      if (scatterView) scatterView.style.display = isTable ? "none" : ""
    })
    container.appendChild(btn)
  }
  return container
}
Show code
viewof football_search = {
  const data = statCategory === "Value" ? posFiltered : statTableData
  if (!data) return html``
  return Inputs.search(data, {placeholder: "Search players…"})
}
Show code
// ── Scatter plot (always renders, starts hidden) ─────────────
{
  const data = statCategory === "Value" ? posFiltered : statTableData
  if (!data || data.length === 0) return html``

  const footballPosColors = window.footballMaps.posColors
  const isValue = statCategory === "Value"
  const defs = window.footballStatDefs || {}

  let metricOpts
  if (isValue) {
    metricOpts = [
      { value: "offense", label: "Offense" },
      { value: "defense", label: "Defense" },
      { value: "panna", label: "Panna" },
      { value: "spm_overall", label: "SPM" }
    ]
  } else {
    const catKey = Object.keys(defs).find(k => defs[k].label === statCategory)
    const catDef = catKey ? defs[catKey] : null
    metricOpts = catDef ? catDef.columns.map(c => ({ value: c + "_r", label: catDef.header[c] || c })) : []
  }

  const defaultX = metricOpts.find(m => m.value === "offense")?.value || metricOpts[0]?.value
  const defaultY = metricOpts.find(m => m.value === "defense")?.value || (metricOpts[1]?.value || metricOpts[0]?.value)

  const headerSrc = Object.fromEntries(metricOpts.map(m => [m.value, m.label]))

  const wrapper = document.createElement("div")
  wrapper.className = "ratings-scatter-view"
  wrapper.style.display = window["_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")] === "Scatter" ? "" : "none"

  // Axis selectors
  const axisBar = document.createElement("div")
  axisBar.className = "scatter-axis-bar"

  const xLabel = document.createElement("label")
  xLabel.textContent = "X: "
  const xSel = document.createElement("select")
  for (const opt of metricOpts) {
    const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; xSel.appendChild(o)
  }
  xSel.value = defaultX
  xLabel.appendChild(xSel)

  const yLabel = document.createElement("label")
  yLabel.textContent = "Y: "
  const ySel = document.createElement("select")
  for (const opt of metricOpts) {
    const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; ySel.appendChild(o)
  }
  ySel.value = defaultY
  yLabel.appendChild(ySel)

  axisBar.appendChild(xLabel)
  axisBar.appendChild(yLabel)
  wrapper.appendChild(axisBar)

  const chartDiv = document.createElement("div")
  wrapper.appendChild(chartDiv)

  const colorMap = {}
  for (const [pos, info] of Object.entries(footballPosColors)) colorMap[pos] = info.c || "#888"
  const activePositions = new Set(Object.keys(footballPosColors))

  function drawChart(xCol, yCol) {
    while (chartDiv.firstChild) chartDiv.removeChild(chartDiv.firstChild)
    const filtered = activePositions.size === Object.keys(footballPosColors).length ? data : data.filter(d => activePositions.has(d.position))
    window.chartHelpers.drawScatterPlot(chartDiv, {
      data: filtered, xCol, yCol,
      xLabel: headerSrc[xCol] || xCol,
      yLabel: headerSrc[yCol] || yCol,
      labelCol: "player_name",
      colorCol: "position",
      colorMap,
      hrefFn: (row) => `player.html#name=${encodeURIComponent(row.player_name)}`,
      format: { [xCol]: v => Number(v).toFixed(3), [yCol]: v => Number(v).toFixed(3) },
      tooltipFn: (tip, row, xC, yC, xL, yL, f) => {
        const header = document.createElement("div")
        header.className = "scatter-tip-header"
        const crest = window.footballMaps.teamCrest(row.team)
        if (crest) {
          const badge = document.createElement("img")
          badge.className = "scatter-tip-headshot"
          badge.src = crest
          badge.alt = ""
          badge.style.borderRadius = "4px"
          header.appendChild(badge)
        }
        const info = document.createElement("div")
        const nameEl = document.createElement("div")
        nameEl.className = "scatter-tip-name"
        nameEl.textContent = row.player_name || ""
        info.appendChild(nameEl)
        const teamRow = document.createElement("div")
        teamRow.className = "scatter-tip-team"
        teamRow.textContent = (row.team || "") + (row.position ? " · " + (footballPosColors[row.position]?.a || row.position) : "")
        info.appendChild(teamRow)
        header.appendChild(info)
        tip.appendChild(header)
        const fX = f[xC] ? f[xC](row[xC]) : Number(row[xC]).toFixed(3)
        const fY = f[yC] ? f[yC](row[yC]) : Number(row[yC]).toFixed(3)
        window.chartHelpers.buildFieldTooltip(tip, "", [[xL, fX], [yL, fY]], true)
        const title = tip.querySelector(".ft-title")
        if (title && !title.textContent) title.remove()
      }
    })
  }

  drawChart(defaultX, defaultY)

  xSel.addEventListener("change", () => drawChart(xSel.value, ySel.value))
  ySel.addEventListener("change", () => drawChart(xSel.value, ySel.value))

  // Legend
  const legend = document.createElement("div")
  legend.style.cssText = "display:flex;gap:0.75rem;flex-wrap:wrap;margin-top:0.5rem;font-size:0.7rem;font-family:var(--bs-font-monospace)"
  for (const [pos, info] of Object.entries(footballPosColors)) {
    const swatch = document.createElement("span")
    swatch.style.cssText = "width:8px;height:8px;border-radius:50%;background:" + info.c + ";display:inline-block"
    const item = document.createElement("span")
    item.style.cssText = "display:inline-flex;align-items:center;gap:0.25rem;color:var(--site-muted-color);cursor:pointer;user-select:none"
    item.appendChild(swatch)
    item.appendChild(document.createTextNode(info.a || pos))
    item.addEventListener("click", () => {
      if (activePositions.has(pos)) { activePositions.delete(pos); item.style.opacity = "0.3" }
      else { activePositions.add(pos); item.style.opacity = "1" }
      drawChart(xSel.value, ySel.value)
    })
    legend.appendChild(item)
  }
  wrapper.appendChild(legend)

  return wrapper
}
Show code
// ── Render table ─────────────────────────────────────────────
{
  if (football_data == null || football_data.length === 0) {
    return html`<p class="text-muted">Ratings data could not be loaded. Try refreshing the page.</p>`
  }

  const footballPosColors = window.footballMaps.posColors
  const posBadge = (val) => window.posBadge(val, footballPosColors)
  const isValue = statCategory === "Value"
  const defs = window.footballStatDefs || {}

  // Stat category view
  if (!isValue) {
    const data = statTableData
    if (!data || data.length === 0) {
      const failed = footballSkills === null
      return html`<p class="text-muted">${failed ? "Rating data could not be loaded. Try refreshing." : "Loading rating data..."}</p>`
    }

    const catKey = Object.keys(defs).find(k => defs[k].label === statCategory)
    const catDef = catKey ? defs[catKey] : null
    if (!catDef) return html`<p class="text-muted">Category not found.</p>`

    const statCols = catDef.columns.map(c => c + "_r").filter(c => data[0] && data[0][c] !== undefined)
    const headerMap = {}; const heatmapMap = {}; const tooltipMap = {}
    for (const col of catDef.columns) {
      headerMap[col + "_r"] = catDef.header[col] || col
      heatmapMap[col + "_r"] = catDef.heatmap?.[col] || "high-good"
      if (catDef.tooltip?.[col]) tooltipMap[col + "_r"] = catDef.tooltip[col]
    }

    const mStatCols = (catDef.mobileCols || catDef.columns.slice(0, 3)).map(c => c + "_r").filter(c => statCols.includes(c))
    const tableEl = statsTable(football_search, {
      columns: ["player_name", "league", "position", ...statCols],
      mobileCols: ["player_name", "position", ...mStatCols],
      header: { player_name: "Player", league: "League", position: "Pos", ...headerMap },
      groups: [{ label: "Player", span: 3 }, { label: statCategory + " (per 90 min)", span: statCols.length }],
      format: Object.fromEntries(statCols.map(c => [c, x => x?.toFixed(2) ?? ""])),
      tooltip: tooltipMap,
      render: {
        player_name: (v, row) => {
          const pos = footballPosColors[row.position] || { a: String(row.position || "").substring(0, 3), c: "#9ca3af" }
          const crest = window.footballMaps.teamCrest(row.team)
          const badge = crest ? `<img src="${statsEsc(crest)}" alt="" style="width:14px;height:14px;object-fit:contain;vertical-align:middle;margin-right:2px">` : ""
          return `<a href="player.html#name=${encodeURIComponent(v)}" class="player-link"><strong>${statsEsc(v)}</strong></a><span class="player-sub">${badge}<a href="team.html#team=${encodeURIComponent(row.team)}" class="team-link">${statsEsc(row.team)}</a> · ${statsEsc(pos.a)}</span>`
        },
        league: (v) => statsEsc(String(v || "").replace(/_/g, " ")),
        position: posBadge
      },
      heatmap: heatmapMap,
      heatmapData: data,
      sort: statCols[0] || "player_name", reverse: true, rows: 25
    })
    const wrap = document.createElement("div")
    wrap.className = "ratings-table-view"
    wrap.style.display = window["_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")] === "Table" ? "" : "none"
    wrap.appendChild(tableEl)
    return wrap
  }

  // Value view (original)
  const valueEl = statsTable(football_search, {
    columns: ["piero_rank", "player_name", "league", "position", "piero", "panna", "offense", "defense", "spm_overall", "total_minutes", "panna_percentile"],
    mobileCols: ["piero_rank", "player_name", "position", "piero", "panna"],
    header: {
      piero_rank: "#",
      player_name: "Player",
      league: "League",
      position: "Pos",
      piero: "Piero",
      panna: "Panna",
      offense: "Off",
      defense: "Def",
      spm_overall: "SPM",
      total_minutes: "Mins",
      panna_percentile: "Pctl"
    },
    groups: [
      { label: "", span: 1 },
      { label: "Player", span: 3 },
      { label: "Rating", span: 2 },
      { label: "Components", span: 3 },
      { label: "", span: 2 }
    ],
    tooltip: {
      piero: "Piero — composite player rating: a panna-led blend of Panna, EPR and PSR (0.5/0.3/0.2), on the Panna scale. Falls back to Panna where EPR/PSR are unavailable."
    },
    format: {
      piero: x => x?.toFixed(3) ?? "",
      panna: x => x?.toFixed(3) ?? "",
      offense: x => x?.toFixed(3) ?? "",
      defense: x => x?.toFixed(3) ?? "",
      spm_overall: x => x?.toFixed(3) ?? "",
      total_minutes: x => x != null ? Math.round(x).toLocaleString() : "",
      panna_percentile: x => x != null ? x.toFixed(1) : ""
    },
    render: {
      player_name: (v, row) => {
        const pos = footballPosColors[row.position] || { a: String(row.position || "").substring(0, 3), c: "#9ca3af" }
        const href = `player.html#name=${encodeURIComponent(v)}`
        const crest = window.footballMaps.teamCrest(row.team)
        const badge = crest ? `<img src="${statsEsc(crest)}" alt="" style="width:14px;height:14px;object-fit:contain;vertical-align:middle;margin-right:2px">` : ""
        return `<a href="${href}" class="player-link"><strong>${statsEsc(v)}</strong></a><span class="player-sub">${badge}<a href="team.html#team=${encodeURIComponent(row.team)}" class="team-link">${statsEsc(row.team)}</a> · ${statsEsc(pos.a)}</span>`
      },
      piero_rank: (v) => `<span style="color:#8b929e">${statsEsc(String(v ?? ""))}</span>`,
      league: (v) => statsEsc(String(v || "").replace(/_/g, " ")),
      position: posBadge
    },
    heatmap: {
      piero: "high-good",
      panna: "high-good",
      offense: "high-good",
      defense: "low-good",
      spm_overall: "high-good",
      panna_percentile: "high-good"
    },
    heatmapData: football_data,
    filters: {
      piero: "range",
      panna: "range",
      total_minutes: "range",
      panna_percentile: "range"
    },
    sort: "piero_rank",
    rows: 25
  })

  const wrap = document.createElement("div")
  wrap.className = "ratings-table-view"
  wrap.appendChild(valueEl)
  return wrap
}
Show code
// ── Source attribution row beneath the table ────────────────
{
  const src = document.createElement("div")
  src.className = "table-source"
  const left = document.createElement("span")
  left.appendChild(document.createTextNode("Source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/pannadata"
  a.target = "_blank"; a.rel = "noopener"
  a.textContent = "pannadata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Pete Owen · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = "As at " + (ratingsAsAt || "Latest") + " · Filter by league + position via the toggles above"
  src.appendChild(left); src.appendChild(right)
  return src
}
Show code
// ── Editorial side rail: By The Numbers, About, Updated, Related ──
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"

  const { railBlock } = window.editorial
  function btnTile(num, capParts) {
    const tile = document.createElement("div")
    const n = document.createElement("div"); n.className = "btn-num"; n.textContent = num
    const c = document.createElement("div"); c.className = "btn-cap"
    for (const p of capParts) {
      if (p.br) { c.appendChild(document.createElement("br")); continue }
      if (p.bold) { const s = document.createElement("strong"); s.textContent = p.text; c.appendChild(s) }
      else c.appendChild(document.createTextNode(p.text))
    }
    tile.appendChild(n); tile.appendChild(c)
    return tile
  }

  if (!football_data || football_data.length === 0) {
    const lb = railBlock("Loading")
    const p = document.createElement("p")
    p.style.cssText = "color: var(--site-muted-color); font-size: 0.85rem; font-family: 'Source Serif 4', Georgia, serif; margin: 0;"
    p.textContent = "Resolving the latest player ratings…"
    lb.appendChild(p); inner.appendChild(lb); return inner
  }

  // BTN computations from football ratings (one row per player)
  const sorted = [...football_data].sort((a, b) => (b.piero ?? -Infinity) - (a.piero ?? -Infinity))
  const top = sorted[0]
  const topPiero = top?.piero != null ? top.piero.toFixed(2) : "—"
  const topName = top?.player_name || "—"
  const topTeam = top?.team || ""
  const aboveOne = football_data.filter(d => (d.piero ?? -Infinity) >= 1.0).length
  const totalRated = football_data.length
  const medPiero = (() => {
    const vals = football_data.map(d => d.piero).filter(x => x != null).sort((a, b) => a - b)
    if (!vals.length) return null
    const m = vals.length / 2
    return vals.length % 2 === 0 ? (vals[m-1] + vals[m]) / 2 : vals[Math.floor(m)]
  })()

  // Block 1: Last Updated
  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = ratingsAsAt || "Latest"
  upd.appendChild(stamp)
  const updP = document.createElement("p")
  updP.style.cssText = "font-family: 'Source Serif 4', Georgia, serif; font-size: 0.85rem; color: var(--site-muted-color); margin: 0.7rem 0 0; line-height: 1.55;"
  updP.appendChild(document.createTextNode("Ratings refresh on the daily "))
  const code = document.createElement("code")
  code.style.cssText = "font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--site-body-color)"
  code.textContent = "pannadata"
  updP.appendChild(code)
  updP.appendChild(document.createTextNode(" pipeline (Opta scrape → RAPM+SPM → R2)."))
  upd.appendChild(updP)
  inner.appendChild(upd)

  // Block 2: By The Numbers
  const btn = railBlock("By the Numbers")
  const grid = document.createElement("div"); grid.className = "btn-block"
  grid.appendChild(btnTile(topPiero, [
    { text: "Highest Piero", bold: true },
    { text: " · " + topName },
    ...(topTeam ? [{ br: true }, { text: topTeam }] : [])
  ]))
  grid.appendChild(btnTile(aboveOne, [
    { text: "Above 1.00 Piero", bold: true },
    { text: " · top-end starters across 15 leagues" }
  ]))
  grid.appendChild(btnTile(medPiero != null ? medPiero.toFixed(2) : "—", [
    { text: "Median Piero", bold: true },
    { text: " across the rated player pool" }
  ]))
  grid.appendChild(btnTile(totalRated.toLocaleString(), [
    { text: "Players rated", bold: true },
    { text: " across all 15 leagues" }
  ]))
  btn.appendChild(grid); inner.appendChild(btn)

  // Block 3: About
  const about = railBlock("About Panna"); about.classList.add("about-block")
  const p1 = document.createElement("p")
  const s1 = document.createElement("strong"); s1.textContent = "Panna"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(" = Offense − Defense, in xG per 90. A RAPM+SPM blend that adjusts for team-mates, opponents, and minutes played."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  const s2 = document.createElement("strong"); s2.textContent = "Offense"; p2.appendChild(s2)
  p2.appendChild(document.createTextNode(" = xG created. "))
  const s3 = document.createElement("strong"); s3.textContent = "Defense"; p2.appendChild(s3)
  p2.appendChild(document.createTextNode(" = xG prevented (negative = better). Components sum to the headline rating."))
  about.appendChild(p2)
  const p3 = document.createElement("p")
  p3.appendChild(document.createTextNode("Open source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/panna"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "panna"
  p3.appendChild(a); p3.appendChild(document.createTextNode(" on GitHub."))
  about.appendChild(p3)
  inner.appendChild(about)

  // Block 4: Read Next
  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "../blog/2026-04-24-understanding-panna/", title: "Understanding Panna", meta: "Blog · Methodology deep-dive" },
    { href: "../blog/2026-04-24-expected-goals-explained/", title: "Expected Goals Explained", meta: "Blog · xG fundamentals" },
    { href: "compare.html", title: "Player Comparison", meta: "Side-by-side ratings tool" },
    { href: "player-stats.html", title: "Per-match Stats", meta: "Game-by-game box scores" }
  ]
  for (const l of links) {
    const li = document.createElement("li")
    const ax = document.createElement("a")
    ax.href = l.href; ax.textContent = l.title
    const meta = document.createElement("span"); meta.className = "rail-meta"; meta.textContent = l.meta
    ax.appendChild(meta); li.appendChild(ax); ul.appendChild(li)
  }
  read.appendChild(ul); inner.appendChild(read)

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer