catDef = statDefs[category]
tableData = {
if (category === "custom") return null
// Value/Ratings tabs use career ratings (not per-game match stats).
// ratings.parquet has no position field — fall back to matchStats most-common position per player.
if ((catDef.source === "gameLogs" || catDef.source === "ratings") && _ratingsRaw) {
let data = _ratingsRaw
if (leagueFilter !== "All Leagues") data = data.filter(d => d.league === leagueFilter)
const posLookup = _ratingsPositions || {}
const resolvePos = (d) => d.position || posLookup[d.player_name] || null
if (posFilter !== "All") {
const validPositions = window.footballMaps.posGroups[posFilter] || []
data = data.filter(d => validPositions.includes(resolvePos(d)))
}
return data.map(d => {
const pos = resolvePos(d)
return {
player_name: d.player_name, position: pos, team: d.team,
gp: null, mins: d.total_minutes != null ? Math.round(d.total_minutes) : null,
panna: d.panna, offense: d.offense, defense: d.defense, spm_overall: d.spm_overall,
panna_percentile: d.panna_percentile, total_minutes: d.total_minutes,
// Optional EPV/WPA/PSV columns — present once pannadata ships the enriched ratings.parquet.
epv_total: d.epv_total, epv_passing: d.epv_passing, epv_shooting: d.epv_shooting,
epv_dribbling: d.epv_dribbling, epv_defending: d.epv_defending,
wpa_total: d.wpa_total, wpa_as_actor: d.wpa_as_actor, wpa_as_receiver: d.wpa_as_receiver,
psv: d.psv, osv: d.osv, dsv: d.dsv, panna_value_p90: d.panna_value_p90,
pos_group: (window.footballMaps.posToGroup || {})[pos] || "",
}
})
}
if (!matchStats) return null
let games = matchStats
const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter
// Season filter
if (effectiveSeason) games = games.filter(d => String(d.season) === effectiveSeason)
// Matchweek filter — filter to games within the selected matchweek date range
if (matchdayFilter && matchdayFilter !== "All Matchweeks") {
const mw = _matchweekRanges.find(w => w.label === matchdayFilter)
if (mw) {
games = games.filter(d => {
const md = d.match_date ? String(d.match_date).replace("Z", "").slice(0, 10) : ""
return md >= mw.startDate && md <= mw.endDate
})
}
}
// Position filter
if (posFilter !== "All") {
const validPositions = posGroups[posFilter] || []
games = games.filter(d => validPositions.includes(d.position))
}
// Group by player
const statCols = catDef.columns.filter(c => c !== "pass_pct")
const grouped = new Map()
for (const g of games) {
const key = g.player_id || g.player_name
if (!grouped.has(key)) {
grouped.set(key, { vals: {}, count: 0, mins: 0, player_name: g.player_name, team: g.team_name, position: g.position, league: g.league })
for (const col of statCols) grouped.get(key).vals[col] = 0
}
const entry = grouped.get(key)
entry.count++
entry.mins += g.minsPlayed || 0
entry.team = g.team_name
if (g.player_name) entry.player_name = g.player_name
for (const col of statCols) {
const v = Number(g[col])
if (!isNaN(v)) entry.vals[col] += v
}
}
const result = []
for (const [pid, entry] of grouped) {
if (entry.count === 0) continue
const row = {
player_name: entry.player_name || pid,
team: entry.team,
position: (entry.position === "Substitute" || entry.position === "Sub") ? (_ratingsPositions[entry.player_name] || entry.position) : entry.position,
pos_group: posToGroup[(entry.position === "Substitute" || entry.position === "Sub") ? (_ratingsPositions[entry.player_name] || entry.position) : entry.position] || "",
league: entry.league,
gp: entry.count,
mins: entry.mins
}
for (const col of statCols) {
if (aggMode === "avg") {
row[col] = +(entry.vals[col] / entry.count).toFixed(3)
} else if (aggMode === "p90") {
row[col] = entry.mins > 0 ? +(entry.vals[col] / entry.mins * 90).toFixed(3) : 0
} else {
row[col] = entry.vals[col]
}
}
// Computed columns
if (catDef.compute) {
row.pass_pct = row.passes > 0 ? Math.round(100 * row.passes_accurate / row.passes) : null
}
result.push(row)
}
return result
}