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.statsEscstatsTable =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 =awaitwindow.fetchParquet(window.DATA_BASE_URL+"football/ratings.parquet")if (d ==null) return dconst 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 =awaitfetch(window.DATA_BASE_URL+"football/ratings.parquet", { method:"HEAD" })const lm = res.headers.get("last-modified")if (!lm) return"Latest"const d =newDate(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" }}
// ── Render table ─────────────────────────────────────────────{if (football_data ==null|| football_data.length===0) {returnhtml`<p class="text-muted">Ratings data could not be loaded. Try refreshing the page.</p>` }const footballPosColors =window.footballMaps.posColorsconst posBadge = (val) =>window.posBadge(val, footballPosColors)const isValue = statCategory ==="Value"const defs =window.footballStatDefs|| {}// Stat category viewif (!isValue) {const data = statTableDataif (!data || data.length===0) {const failed = footballSkills ===nullreturnhtml`<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] :nullif (!catDef) returnhtml`<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.editorialfunctionbtnTile(num, capParts) {const tile =document.createElement("div")const n =document.createElement("div"); n.className="btn-num"; n.textContent= numconst 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).lengthconst totalRated = football_data.lengthconst medPiero = (() => {const vals = football_data.map(d => d.piero).filter(x => x !=null).sort((a, b) => a - b)if (!vals.length) returnnullconst m = vals.length/2return vals.length%2===0? (vals[m-1] + vals[m]) /2: vals[Math.floor(m)] })()// Block 1: Last Updatedconst 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 Numbersconst 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: Aboutconst 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 Nextconst 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.titleconst 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}