Module:Statistics: Difference between revisions
From eSportsAmaze
More actions
Esportsamaze (talk | contribs) No edit summary |
Esportsamaze (talk | contribs) No edit summary |
||
| Line 2: | Line 2: | ||
local cargo = mw.ext.cargo | local cargo = mw.ext.cargo | ||
local html = mw.html | local html = mw.html | ||
local function sqlEscape(s) | local function sqlEscape(s) | ||
if not s then return "" end | if not s then return "" end | ||
return s:gsub("\\", "\\\\"):gsub("'", "\\'") | |||
end | end | ||
-- Team Logo Fetcher | |||
local function getTeamLogo(teamName) | local function getTeamLogo(teamName) | ||
if not teamName then return "" end | if not teamName or teamName == "" then return "" end | ||
local cleanName = teamName:gsub("'", "") | local cleanName = teamName:gsub("'", "") | ||
local lightFile = cleanName .. '.png' | local lightFile = cleanName .. '.png' | ||
local darkFile = cleanName .. '_dark.png' | local darkFile = cleanName .. '_dark.png' | ||
local | local str = '<span class="team-logo-wrapper">' | ||
str = str .. '<span class="logo-lightmode">[[File:' .. lightFile .. '|24px|link=]]</span>' | |||
str = str .. '<span class="logo-darkmode">[[File:' .. darkFile .. '|24px|link=]]</span>' | |||
str = str .. '</span>' | |||
return str | |||
end | |||
return | |||
-- Format Number Helper | |||
local function fmt(num, decimals) | |||
if not num then return "0" end | |||
if decimals and decimals > 0 then | |||
return string.format("%." .. decimals .. "f", num) | |||
else | |||
return tostring(math.floor(num + 0.5)) | |||
end | |||
end | end | ||
-- | -- Heatmap Color Generator (Subtle) | ||
local function getHeatmap(val, min, max, isGood) | |||
if not val or not min or not max or max == min then return "" end | |||
local ratio = (val - min) / (max - min) | |||
if ratio < 0 then ratio = 0 end | |||
if ratio > 1 then ratio = 1 end | |||
local | |||
-- Very subtle opacity (Max 25% opacity) | |||
local | local opacity = 0.02 + (ratio * 0.23) | ||
-- Green for Good stats, Red for Bad stats | |||
if isGood then | |||
return string.format("background-color: rgba(16, 185, 129, %.2f);", opacity) | |||
else | |||
return string.format("background-color: rgba(239, 68, 68, %.2f);", opacity) | |||
end | end | ||
end | |||
function p.main(frame) | |||
local args = frame:getParent().args | |||
if not args.type then args = frame.args end -- fallback | |||
local statType = (args.type or "team"):lower() | |||
-- 1. Build Dynamic Where Clause (Filter based on what user asked for) | |||
local whereParts = {} | local whereParts = {} | ||
local filters = {"tournament", "tournament_type", "stage", "group_name", "map", "match_type", "tournament_day", "date", "time"} | |||
for _, f in ipairs(filters) do | |||
local val = args[f] or args[f:gsub("_name", "")] | |||
if val and val ~= "" then | |||
table.insert(whereParts, string.format("%s = '%s'", f, sqlEscape(val))) | |||
end | |||
end | |||
local | local whereClause = #whereParts > 0 and table.concat(whereParts, " AND ") or "1=1" | ||
local root = html.create('div'):addClass('stats-table-wrapper') | local root = html.create('div'):addClass('stats-table-wrapper') | ||
local tbl = root:tag('table'):addClass('flat-data-table sortable') | local tbl = root:tag('table'):addClass('flat-data-table sortable'):css('width', 'auto'):css('min-width', '100%') | ||
-- ========================================== | |||
local | -- TEAM STATISTICS AGGREGATION | ||
-- ========================================== | |||
if statType == "team" then | |||
local results = cargo.query("MatchStats_Team", | |||
"team, short_name, map, rank, wwcd, place_pts, elim_pts, total_pts, number_of_teams", | |||
{ where = whereClause, limit = 5000 } | |||
) | |||
if not results or #results == 0 then | |||
return '<div style="padding:20px; color:var(--text-muted); font-style:italic;">No team statistics available for this selection.</div>' | |||
end | |||
local tData = {} | |||
local mapSet = {} | |||
-- Loop all raw match rows | |||
for _, r in ipairs(results) do | |||
local t = r.team | |||
if not tData[t] then | |||
tData[t] = { | |||
team = t, short = r.short_name, matches = 0, | |||
place = 0, elims = 0, total = 0, wwcd = 0, | |||
top5 = 0, bot5 = 0, | |||
pts_lt10 = 0, pts_10_25 = 0, pts_gt25 = 0, | |||
map_pts = {}, map_matches = {} | |||
} | |||
end | |||
local d = tData[t] | |||
d.matches = d.matches + 1 | |||
d.place = d.place + tonumber(r.place_pts or 0) | |||
d.elims = d.elims + tonumber(r.elim_pts or 0) | |||
local pts = tonumber(r.total_pts or 0) | |||
d.total = d.total + pts | |||
d.wwcd = d.wwcd + tonumber(r.wwcd or 0) | |||
local rank = tonumber(r.rank or 99) | |||
local totalTeams = tonumber(r.number_of_teams) or 16 | |||
if rank <= 5 then d.top5 = d.top5 + 1 end | |||
if rank >= (totalTeams - 4) then d.bot5 = d.bot5 + 1 end | |||
if pts < 10 then d.pts_lt10 = d.pts_lt10 + 1 | |||
elseif pts <= 25 then d.pts_10_25 = d.pts_10_25 + 1 | |||
else d.pts_gt25 = d.pts_gt25 + 1 end | |||
if r.map and r.map ~= "" then | |||
mapSet[r.map] = true | |||
d.map_pts[r.map] = (d.map_pts[r.map] or 0) + pts | |||
d.map_matches[r.map] = (d.map_matches[r.map] or 0) + 1 | |||
end | |||
end | |||
local | local mapList = {} | ||
for m in pairs(mapSet) do table.insert(mapList, m) end | |||
table.sort(mapList) | |||
-- Calculate Averages & Percentages | |||
local list = {} | |||
local extremes = { | |||
avg_place={}, avg_elims={}, avg_total={}, win_pct={}, | |||
top5_pct={}, bot5_pct={}, pts_lt10_pct={}, pts_10_25_pct={}, pts_gt25_pct={} | |||
} | |||
for _, m in ipairs(mapList) do extremes["map_"..m] = {} end | |||
local function addExt(key, val) | |||
table.insert(extremes[key], val) | |||
end | |||
if | for _, d in pairs(tData) do | ||
if d.matches > 0 then | |||
d.avg_place = d.place / d.matches | |||
d.avg_elims = d.elims / d.matches | |||
d.avg_total = d.total / d.matches | |||
d.win_pct = (d.wwcd / d.matches) * 100 | |||
d.top5_pct = (d.top5 / d.matches) * 100 | |||
d.bot5_pct = (d.bot5 / d.matches) * 100 | |||
d.pts_lt10_pct = (d.pts_lt10 / d.matches) * 100 | |||
d.pts_10_25_pct = (d.pts_10_25 / d.matches) * 100 | |||
d.pts_gt25_pct = (d.pts_gt25 / d.matches) * 100 | |||
-- Force Tiebreakers if provided | |||
d.tiebreaker = tonumber(args["tiebreaker_" .. d.team]) or 0 | |||
addExt("avg_place", d.avg_place); addExt("avg_elims", d.avg_elims); addExt("avg_total", d.avg_total) | |||
addExt("win_pct", d.win_pct); addExt("top5_pct", d.top5_pct); addExt("bot5_pct", d.bot5_pct) | |||
addExt("pts_lt10_pct", d.pts_lt10_pct); addExt("pts_10_25_pct", d.pts_10_25_pct); addExt("pts_gt25_pct", d.pts_gt25_pct) | |||
for _, m in ipairs(mapList) do | |||
if d.map_matches[m] and d.map_matches[m] > 0 then | |||
d["avg_map_"..m] = d.map_pts[m] / d.map_matches[m] | |||
addExt("map_"..m, d["avg_map_"..m]) | |||
end | |||
end | |||
table.insert(list, d) | |||
end | |||
end | end | ||
-- Sort Data (Avg Total > Win Pct > Avg Elims > Tiebreaker) | |||
table.sort(list, function(a, b) | |||
if a.avg_total ~= b.avg_total then return a.avg_total > b.avg_total end | |||
if a.win_pct ~= b.win_pct then return a.win_pct > b.win_pct end | |||
if a.avg_elims ~= b.avg_elims then return a.avg_elims > b.avg_elims end | |||
return a.tiebreaker > b.tiebreaker | |||
end) | |||
if | |||
-- Find Min/Max for Heatmaps | |||
local bounds = {} | |||
for k, vals in pairs(extremes) do | |||
if #vals > 0 then | |||
bounds[k] = { min = math.min(unpack(vals)), max = math.max(unpack(vals)) } | |||
end | end | ||
end | |||
-- Build Table Headers | |||
local th = tbl:tag('tr') | |||
th:tag('th'):addClass('sticky-1'):wikitext('#') | |||
local | th:tag('th'):addClass('sticky-2'):wikitext('Team') | ||
th:tag('th'):wikitext('M') | |||
if | th:tag('th'):wikitext('Avg Place') | ||
th:tag('th'):wikitext('Avg Elims') | |||
th:tag('th'):wikitext('Avg Total') | |||
for _, m in ipairs(mapList) do th:tag('th'):wikitext(m) end | |||
th:tag('th'):wikitext('Win %') | |||
th:tag('th'):wikitext('Top 5 %') | |||
th:tag('th'):wikitext('Bot 5 %') | |||
th:tag('th'):wikitext('< 10 Pts') | |||
th:tag('th'):wikitext('10-25 Pts') | |||
th:tag('th'):wikitext('25+ Pts') | |||
-- Build Rows | |||
for i, d in ipairs(list) do | |||
local tr = tbl:tag('tr') | |||
tr:tag('td'):addClass('sticky-1'):css('text-align','center'):css('font-weight','800'):wikitext(i) | |||
-- Team Logic (PC vs Mobile) | |||
local teamCell = tr:tag('td'):addClass('sticky-2'):css('display','flex'):css('align-items','center'):css('gap','8px') | |||
teamCell:wikitext(getTeamLogo(d.team)) | |||
teamCell:tag('span'):addClass('pc-only'):wikitext('[[' .. d.team .. ']]') | |||
local short = (d.short and d.short ~= "") and d.short or d.team | |||
teamCell:tag('span'):addClass('mobile-only'):wikitext('[[' .. d.team .. '|' .. short .. ']]') | |||
tr:tag('td'):css('text-align','center'):wikitext(d.matches) | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.avg_place, bounds.avg_place.min, bounds.avg_place.max, true)):wikitext(fmt(d.avg_place, 1)) | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.avg_elims, bounds.avg_elims.min, bounds.avg_elims.max, true)):wikitext(fmt(d.avg_elims, 1)) | |||
tr:tag('td'):css('text-align','center'):css('font-weight','bold'):attr('style', getHeatmap(d.avg_total, bounds.avg_total.min, bounds.avg_total.max, true)):wikitext(fmt(d.avg_total, 1)) | |||
for _, m in ipairs(mapList) do | |||
local mapVal = d["avg_map_"..m] | |||
local mapCell = tr:tag('td'):css('text-align','center') | |||
if mapVal then | |||
mapCell:attr('style', getHeatmap(mapVal, bounds["map_"..m].min, bounds["map_"..m].max, true)):wikitext(fmt(mapVal, 1)) | |||
else | else | ||
mapCell:css('opacity','0.3'):wikitext('-') | |||
end | end | ||
end | end | ||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.win_pct, bounds.win_pct.min, bounds.win_pct.max, true)):wikitext(fmt(d.win_pct, 0) .. '%') | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.top5_pct, bounds.top5_pct.min, bounds.top5_pct.max, true)):wikitext(fmt(d.top5_pct, 0) .. '%') | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.bot5_pct, bounds.bot5_pct.min, bounds.bot5_pct.max, false)):wikitext(fmt(d.bot5_pct, 0) .. '%') -- False = Red for Bot 5 | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.pts_lt10_pct, bounds.pts_lt10_pct.min, bounds.pts_lt10_pct.max, false)):wikitext(fmt(d.pts_lt10_pct, 0) .. '%') -- False = Red | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.pts_10_25_pct, bounds.pts_10_25_pct.min, bounds.pts_10_25_pct.max, true)):wikitext(fmt(d.pts_10_25_pct, 0) .. '%') | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.pts_gt25_pct, bounds.pts_gt25_pct.min, bounds.pts_gt25_pct.max, true)):wikitext(fmt(d.pts_gt25_pct, 0) .. '%') | |||
end | |||
-- ========================================== | |||
-- PLAYER STATISTICS AGGREGATION | |||
-- ========================================== | |||
elseif statType == "player" then | |||
local results = cargo.query("MatchStats_Player", | |||
"player, team, player_elims", | |||
{ where = whereClause, limit = 5000 } | |||
) | |||
if not results or #results == 0 then | |||
return '<div style="padding:20px; color:var(--text-muted); font-style:italic;">No player statistics available for this selection.</div>' | |||
end | |||
local pData = {} | |||
for _, r in ipairs(results) do | |||
local p = r.player | |||
if not pData[p] then | |||
pData[p] = { | |||
player = p, team = r.team, matches = 0, | |||
elims = 0, max_elims = 0, elims_5plus = 0, elims_0 = 0 | |||
} | |||
end | |||
local d = pData[p] | |||
d.matches = d.matches + 1 | |||
local e = tonumber(r.player_elims or 0) | |||
d.elims = d.elims + e | |||
if e > d.max_elims then d.max_elims = e end | |||
if e >= 5 then d.elims_5plus = d.elims_5plus + 1 end | |||
if e == 0 then d.elims_0 = d.elims_0 + 1 end | |||
end | |||
local list = {} | |||
local ext = { elims={}, fpm={}, max_elims={}, elims_5plus={}, zero_pct={} } | |||
for _, d in pairs(pData) do | |||
if d.matches > 0 then | |||
d.fpm = d.elims / d.matches | |||
d.zero_pct = (d.elims_0 / d.matches) * 100 | |||
d.tiebreaker = tonumber(args["tiebreaker_" .. d.player]) or 0 | |||
table.insert(ext.elims, d.elims) | |||
table.insert(ext.fpm, d.fpm) | |||
table.insert(ext.max_elims, d.max_elims) | |||
table.insert(ext.elims_5plus, d.elims_5plus) | |||
table.insert(ext.zero_pct, d.zero_pct) | |||
table.insert(list, d) | |||
end | |||
end | |||
-- Sort: Elims > FPM > Max Elims | |||
table.sort(list, function(a, b) | |||
if a.elims ~= b.elims then return a.elims > b.elims end | |||
if a.fpm ~= b.fpm then return a.fpm > b.fpm end | |||
if a.max_elims ~= b.max_elims then return a.max_elims > b.max_elims end | |||
return a.tiebreaker > b.tiebreaker | |||
end) | |||
local bounds = {} | |||
for k, vals in pairs(ext) do bounds[k] = { min = math.min(unpack(vals)), max = math.max(unpack(vals)) } end | |||
-- Player Headers | |||
local th = tbl:tag('tr') | |||
th:tag('th'):addClass('sticky-1'):wikitext('#') | |||
th:tag('th'):addClass('sticky-2'):wikitext('Player') | |||
th:tag('th'):wikitext('M') | |||
th:tag('th'):wikitext('Elims') | |||
th:tag('th'):wikitext('FPM') | |||
th:tag('th'):wikitext('Max Match Elims') | |||
th:tag('th'):wikitext('5+ Elim Matches') | |||
th:tag('th'):wikitext('0 Elims %') | |||
for i, d in ipairs(list) do | |||
local tr = tbl:tag('tr') | |||
tr:tag('td'):addClass('sticky-1'):css('text-align','center'):css('font-weight','800'):wikitext(i) | |||
-- Player Cell (Name + Small Team Name + Logo) | |||
local pCell = tr:tag('td'):addClass('sticky-2'):css('display','flex'):css('align-items','center'):css('gap','12px') | |||
pCell:wikitext(getTeamLogo(d.team)) | |||
local infoDiv = pCell:tag('div'):css('line-height','1.1') | |||
infoDiv:tag('div'):css('font-weight','bold'):css('font-size','1.1em'):wikitext('[[' .. d.player .. ']]') | |||
infoDiv:tag('div'):css('font-size','0.75em'):css('color','var(--text-muted)'):css('text-transform','uppercase'):wikitext('[[' .. d.team .. ']]') | |||
tr:tag('td'):css('text-align','center'):wikitext(d.matches) | |||
tr:tag('td'):css('text-align','center'):css('font-weight','bold'):attr('style', getHeatmap(d.elims, bounds.elims.min, bounds.elims.max, true)):wikitext(d.elims) | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.fpm, bounds.fpm.min, bounds.fpm.max, true)):wikitext(fmt(d.fpm, 2)) | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.max_elims, bounds.max_elims.min, bounds.max_elims.max, true)):wikitext(d.max_elims) | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.elims_5plus, bounds.elims_5plus.min, bounds.elims_5plus.max, true)):wikitext(d.elims_5plus) | |||
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.zero_pct, bounds.zero_pct.min, bounds.zero_pct.max, false)):wikitext(fmt(d.zero_pct, 0) .. '%') -- False = Red heatmap | |||
end | end | ||
end | end | ||
return tostring(root) | return tostring(root) | ||
end | end | ||
return p | return p | ||
Revision as of 02:05, 19 May 2026
Documentation for this module may be created at Module:Statistics/doc
local p = {}
local cargo = mw.ext.cargo
local html = mw.html
local function sqlEscape(s)
if not s then return "" end
return s:gsub("\\", "\\\\"):gsub("'", "\\'")
end
-- Team Logo Fetcher
local function getTeamLogo(teamName)
if not teamName or teamName == "" then return "" end
local cleanName = teamName:gsub("'", "")
local lightFile = cleanName .. '.png'
local darkFile = cleanName .. '_dark.png'
local str = '<span class="team-logo-wrapper">'
str = str .. '<span class="logo-lightmode">[[File:' .. lightFile .. '|24px|link=]]</span>'
str = str .. '<span class="logo-darkmode">[[File:' .. darkFile .. '|24px|link=]]</span>'
str = str .. '</span>'
return str
end
-- Format Number Helper
local function fmt(num, decimals)
if not num then return "0" end
if decimals and decimals > 0 then
return string.format("%." .. decimals .. "f", num)
else
return tostring(math.floor(num + 0.5))
end
end
-- Heatmap Color Generator (Subtle)
local function getHeatmap(val, min, max, isGood)
if not val or not min or not max or max == min then return "" end
local ratio = (val - min) / (max - min)
if ratio < 0 then ratio = 0 end
if ratio > 1 then ratio = 1 end
-- Very subtle opacity (Max 25% opacity)
local opacity = 0.02 + (ratio * 0.23)
-- Green for Good stats, Red for Bad stats
if isGood then
return string.format("background-color: rgba(16, 185, 129, %.2f);", opacity)
else
return string.format("background-color: rgba(239, 68, 68, %.2f);", opacity)
end
end
function p.main(frame)
local args = frame:getParent().args
if not args.type then args = frame.args end -- fallback
local statType = (args.type or "team"):lower()
-- 1. Build Dynamic Where Clause (Filter based on what user asked for)
local whereParts = {}
local filters = {"tournament", "tournament_type", "stage", "group_name", "map", "match_type", "tournament_day", "date", "time"}
for _, f in ipairs(filters) do
local val = args[f] or args[f:gsub("_name", "")]
if val and val ~= "" then
table.insert(whereParts, string.format("%s = '%s'", f, sqlEscape(val)))
end
end
local whereClause = #whereParts > 0 and table.concat(whereParts, " AND ") or "1=1"
local root = html.create('div'):addClass('stats-table-wrapper')
local tbl = root:tag('table'):addClass('flat-data-table sortable'):css('width', 'auto'):css('min-width', '100%')
-- ==========================================
-- TEAM STATISTICS AGGREGATION
-- ==========================================
if statType == "team" then
local results = cargo.query("MatchStats_Team",
"team, short_name, map, rank, wwcd, place_pts, elim_pts, total_pts, number_of_teams",
{ where = whereClause, limit = 5000 }
)
if not results or #results == 0 then
return '<div style="padding:20px; color:var(--text-muted); font-style:italic;">No team statistics available for this selection.</div>'
end
local tData = {}
local mapSet = {}
-- Loop all raw match rows
for _, r in ipairs(results) do
local t = r.team
if not tData[t] then
tData[t] = {
team = t, short = r.short_name, matches = 0,
place = 0, elims = 0, total = 0, wwcd = 0,
top5 = 0, bot5 = 0,
pts_lt10 = 0, pts_10_25 = 0, pts_gt25 = 0,
map_pts = {}, map_matches = {}
}
end
local d = tData[t]
d.matches = d.matches + 1
d.place = d.place + tonumber(r.place_pts or 0)
d.elims = d.elims + tonumber(r.elim_pts or 0)
local pts = tonumber(r.total_pts or 0)
d.total = d.total + pts
d.wwcd = d.wwcd + tonumber(r.wwcd or 0)
local rank = tonumber(r.rank or 99)
local totalTeams = tonumber(r.number_of_teams) or 16
if rank <= 5 then d.top5 = d.top5 + 1 end
if rank >= (totalTeams - 4) then d.bot5 = d.bot5 + 1 end
if pts < 10 then d.pts_lt10 = d.pts_lt10 + 1
elseif pts <= 25 then d.pts_10_25 = d.pts_10_25 + 1
else d.pts_gt25 = d.pts_gt25 + 1 end
if r.map and r.map ~= "" then
mapSet[r.map] = true
d.map_pts[r.map] = (d.map_pts[r.map] or 0) + pts
d.map_matches[r.map] = (d.map_matches[r.map] or 0) + 1
end
end
local mapList = {}
for m in pairs(mapSet) do table.insert(mapList, m) end
table.sort(mapList)
-- Calculate Averages & Percentages
local list = {}
local extremes = {
avg_place={}, avg_elims={}, avg_total={}, win_pct={},
top5_pct={}, bot5_pct={}, pts_lt10_pct={}, pts_10_25_pct={}, pts_gt25_pct={}
}
for _, m in ipairs(mapList) do extremes["map_"..m] = {} end
local function addExt(key, val)
table.insert(extremes[key], val)
end
for _, d in pairs(tData) do
if d.matches > 0 then
d.avg_place = d.place / d.matches
d.avg_elims = d.elims / d.matches
d.avg_total = d.total / d.matches
d.win_pct = (d.wwcd / d.matches) * 100
d.top5_pct = (d.top5 / d.matches) * 100
d.bot5_pct = (d.bot5 / d.matches) * 100
d.pts_lt10_pct = (d.pts_lt10 / d.matches) * 100
d.pts_10_25_pct = (d.pts_10_25 / d.matches) * 100
d.pts_gt25_pct = (d.pts_gt25 / d.matches) * 100
-- Force Tiebreakers if provided
d.tiebreaker = tonumber(args["tiebreaker_" .. d.team]) or 0
addExt("avg_place", d.avg_place); addExt("avg_elims", d.avg_elims); addExt("avg_total", d.avg_total)
addExt("win_pct", d.win_pct); addExt("top5_pct", d.top5_pct); addExt("bot5_pct", d.bot5_pct)
addExt("pts_lt10_pct", d.pts_lt10_pct); addExt("pts_10_25_pct", d.pts_10_25_pct); addExt("pts_gt25_pct", d.pts_gt25_pct)
for _, m in ipairs(mapList) do
if d.map_matches[m] and d.map_matches[m] > 0 then
d["avg_map_"..m] = d.map_pts[m] / d.map_matches[m]
addExt("map_"..m, d["avg_map_"..m])
end
end
table.insert(list, d)
end
end
-- Sort Data (Avg Total > Win Pct > Avg Elims > Tiebreaker)
table.sort(list, function(a, b)
if a.avg_total ~= b.avg_total then return a.avg_total > b.avg_total end
if a.win_pct ~= b.win_pct then return a.win_pct > b.win_pct end
if a.avg_elims ~= b.avg_elims then return a.avg_elims > b.avg_elims end
return a.tiebreaker > b.tiebreaker
end)
-- Find Min/Max for Heatmaps
local bounds = {}
for k, vals in pairs(extremes) do
if #vals > 0 then
bounds[k] = { min = math.min(unpack(vals)), max = math.max(unpack(vals)) }
end
end
-- Build Table Headers
local th = tbl:tag('tr')
th:tag('th'):addClass('sticky-1'):wikitext('#')
th:tag('th'):addClass('sticky-2'):wikitext('Team')
th:tag('th'):wikitext('M')
th:tag('th'):wikitext('Avg Place')
th:tag('th'):wikitext('Avg Elims')
th:tag('th'):wikitext('Avg Total')
for _, m in ipairs(mapList) do th:tag('th'):wikitext(m) end
th:tag('th'):wikitext('Win %')
th:tag('th'):wikitext('Top 5 %')
th:tag('th'):wikitext('Bot 5 %')
th:tag('th'):wikitext('< 10 Pts')
th:tag('th'):wikitext('10-25 Pts')
th:tag('th'):wikitext('25+ Pts')
-- Build Rows
for i, d in ipairs(list) do
local tr = tbl:tag('tr')
tr:tag('td'):addClass('sticky-1'):css('text-align','center'):css('font-weight','800'):wikitext(i)
-- Team Logic (PC vs Mobile)
local teamCell = tr:tag('td'):addClass('sticky-2'):css('display','flex'):css('align-items','center'):css('gap','8px')
teamCell:wikitext(getTeamLogo(d.team))
teamCell:tag('span'):addClass('pc-only'):wikitext('[[' .. d.team .. ']]')
local short = (d.short and d.short ~= "") and d.short or d.team
teamCell:tag('span'):addClass('mobile-only'):wikitext('[[' .. d.team .. '|' .. short .. ']]')
tr:tag('td'):css('text-align','center'):wikitext(d.matches)
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.avg_place, bounds.avg_place.min, bounds.avg_place.max, true)):wikitext(fmt(d.avg_place, 1))
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.avg_elims, bounds.avg_elims.min, bounds.avg_elims.max, true)):wikitext(fmt(d.avg_elims, 1))
tr:tag('td'):css('text-align','center'):css('font-weight','bold'):attr('style', getHeatmap(d.avg_total, bounds.avg_total.min, bounds.avg_total.max, true)):wikitext(fmt(d.avg_total, 1))
for _, m in ipairs(mapList) do
local mapVal = d["avg_map_"..m]
local mapCell = tr:tag('td'):css('text-align','center')
if mapVal then
mapCell:attr('style', getHeatmap(mapVal, bounds["map_"..m].min, bounds["map_"..m].max, true)):wikitext(fmt(mapVal, 1))
else
mapCell:css('opacity','0.3'):wikitext('-')
end
end
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.win_pct, bounds.win_pct.min, bounds.win_pct.max, true)):wikitext(fmt(d.win_pct, 0) .. '%')
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.top5_pct, bounds.top5_pct.min, bounds.top5_pct.max, true)):wikitext(fmt(d.top5_pct, 0) .. '%')
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.bot5_pct, bounds.bot5_pct.min, bounds.bot5_pct.max, false)):wikitext(fmt(d.bot5_pct, 0) .. '%') -- False = Red for Bot 5
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.pts_lt10_pct, bounds.pts_lt10_pct.min, bounds.pts_lt10_pct.max, false)):wikitext(fmt(d.pts_lt10_pct, 0) .. '%') -- False = Red
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.pts_10_25_pct, bounds.pts_10_25_pct.min, bounds.pts_10_25_pct.max, true)):wikitext(fmt(d.pts_10_25_pct, 0) .. '%')
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.pts_gt25_pct, bounds.pts_gt25_pct.min, bounds.pts_gt25_pct.max, true)):wikitext(fmt(d.pts_gt25_pct, 0) .. '%')
end
-- ==========================================
-- PLAYER STATISTICS AGGREGATION
-- ==========================================
elseif statType == "player" then
local results = cargo.query("MatchStats_Player",
"player, team, player_elims",
{ where = whereClause, limit = 5000 }
)
if not results or #results == 0 then
return '<div style="padding:20px; color:var(--text-muted); font-style:italic;">No player statistics available for this selection.</div>'
end
local pData = {}
for _, r in ipairs(results) do
local p = r.player
if not pData[p] then
pData[p] = {
player = p, team = r.team, matches = 0,
elims = 0, max_elims = 0, elims_5plus = 0, elims_0 = 0
}
end
local d = pData[p]
d.matches = d.matches + 1
local e = tonumber(r.player_elims or 0)
d.elims = d.elims + e
if e > d.max_elims then d.max_elims = e end
if e >= 5 then d.elims_5plus = d.elims_5plus + 1 end
if e == 0 then d.elims_0 = d.elims_0 + 1 end
end
local list = {}
local ext = { elims={}, fpm={}, max_elims={}, elims_5plus={}, zero_pct={} }
for _, d in pairs(pData) do
if d.matches > 0 then
d.fpm = d.elims / d.matches
d.zero_pct = (d.elims_0 / d.matches) * 100
d.tiebreaker = tonumber(args["tiebreaker_" .. d.player]) or 0
table.insert(ext.elims, d.elims)
table.insert(ext.fpm, d.fpm)
table.insert(ext.max_elims, d.max_elims)
table.insert(ext.elims_5plus, d.elims_5plus)
table.insert(ext.zero_pct, d.zero_pct)
table.insert(list, d)
end
end
-- Sort: Elims > FPM > Max Elims
table.sort(list, function(a, b)
if a.elims ~= b.elims then return a.elims > b.elims end
if a.fpm ~= b.fpm then return a.fpm > b.fpm end
if a.max_elims ~= b.max_elims then return a.max_elims > b.max_elims end
return a.tiebreaker > b.tiebreaker
end)
local bounds = {}
for k, vals in pairs(ext) do bounds[k] = { min = math.min(unpack(vals)), max = math.max(unpack(vals)) } end
-- Player Headers
local th = tbl:tag('tr')
th:tag('th'):addClass('sticky-1'):wikitext('#')
th:tag('th'):addClass('sticky-2'):wikitext('Player')
th:tag('th'):wikitext('M')
th:tag('th'):wikitext('Elims')
th:tag('th'):wikitext('FPM')
th:tag('th'):wikitext('Max Match Elims')
th:tag('th'):wikitext('5+ Elim Matches')
th:tag('th'):wikitext('0 Elims %')
for i, d in ipairs(list) do
local tr = tbl:tag('tr')
tr:tag('td'):addClass('sticky-1'):css('text-align','center'):css('font-weight','800'):wikitext(i)
-- Player Cell (Name + Small Team Name + Logo)
local pCell = tr:tag('td'):addClass('sticky-2'):css('display','flex'):css('align-items','center'):css('gap','12px')
pCell:wikitext(getTeamLogo(d.team))
local infoDiv = pCell:tag('div'):css('line-height','1.1')
infoDiv:tag('div'):css('font-weight','bold'):css('font-size','1.1em'):wikitext('[[' .. d.player .. ']]')
infoDiv:tag('div'):css('font-size','0.75em'):css('color','var(--text-muted)'):css('text-transform','uppercase'):wikitext('[[' .. d.team .. ']]')
tr:tag('td'):css('text-align','center'):wikitext(d.matches)
tr:tag('td'):css('text-align','center'):css('font-weight','bold'):attr('style', getHeatmap(d.elims, bounds.elims.min, bounds.elims.max, true)):wikitext(d.elims)
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.fpm, bounds.fpm.min, bounds.fpm.max, true)):wikitext(fmt(d.fpm, 2))
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.max_elims, bounds.max_elims.min, bounds.max_elims.max, true)):wikitext(d.max_elims)
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.elims_5plus, bounds.elims_5plus.min, bounds.elims_5plus.max, true)):wikitext(d.elims_5plus)
tr:tag('td'):css('text-align','center'):attr('style', getHeatmap(d.zero_pct, bounds.zero_pct.min, bounds.zero_pct.max, false)):wikitext(fmt(d.zero_pct, 0) .. '%') -- False = Red heatmap
end
end
return tostring(root)
end
return p