Module:ABVRS Points
From zunagi
Documentation for this module may be created at Module:ABVRS Points/doc
local p = {}
-- Base ranking points by finishing place (1st-16th), from the 2024/25 ABVRS Points Table
-- (https://zunagi.com/images/2024-25-ABVRS-Points-Table.pdf).
local base = {
[1] = 30, [2] = 28, [3] = 25, [4] = 23, [5] = 21, [6] = 20,
[7] = 19, [8] = 18, [9] = 17, [10] = 16.5, [11] = 16, [12] = 15.5,
[13] = 15, [14] = 14.5, [15] = 14, [16] = 13.5,
}
-- Ladder of competition tiers, highest (-4) to lowest (6), with each tier's points
-- multiplier. -4 = FIVB World Championships, -3 = Beach Pro Tour Elite 16, -2 = Beach Pro
-- Tour Challenge/Asian Championships, -1 = Beach Pro Tour Futures/AVC Open/Australian
-- Championships (these four are all "No Star - International" in the PDF, differentiated
-- from each other by tournament name rather than star count), 0 = ABVT Elite (5 star),
-- 1 = ABVT Challenger/Pacific Games (4 star), 2 = Highest State (3 star), 3 = Competitive
-- State (2 star), 4 = Entry Level State (1 star), 5 = Local Competitive (no star),
-- 6 = Local Club Entry level (no star).
local multiplier = {
[-4] = 80, [-3] = 70, [-2] = 50, [-1] = 30,
[0] = 20, [1] = 12, [2] = 8, [3] = 5, [4] = 3, [5] = 2, [6] = 1,
}
-- Event rating -> ladder index that Division 1 starts at. Division 2 is one tier lower,
-- Division 3 two tiers lower, and so on.
--
-- "No Star - International" is only wired up to the Australian Championships tier (-1) for
-- now, since that's the only one of the four international tiers this calendar currently
-- needs (ABVC Australian Championships). If a future event needs one of the other three,
-- add it here the same way, e.g. ["no star international asian championships"] = -2.
local startIndex = {
["4 star"] = 1, ["4"] = 1,
["3 star"] = 2, ["3"] = 2,
["2 star"] = 3, ["2"] = 3,
["1 star"] = 4, ["1"] = 4,
["no star"] = 5, ["none"] = 5, ["0"] = 5,
["no star international"] = -1, ["international"] = -1,
}
-- Draw size -> number of "Place" rows to show.
local drawSizes = { ["12"] = 12, ["12 teams"] = 12, ["16"] = 16, ["16 teams"] = 16 }
-- Event rating -> a clean display label. No longer used by the subheading (which now uses
-- tierLabel/startIndex instead, for star notation), but still used as p.ratingLabel's
-- fallback for a rating with no startIndex entry. Keys must stay in sync with startIndex.
local ratingLabel = {
["4 star"] = "4-Star", ["4"] = "4-Star",
["3 star"] = "3-Star", ["3"] = "3-Star",
["2 star"] = "2-Star", ["2"] = "2-Star",
["1 star"] = "1-Star", ["1"] = "1-Star",
["no star"] = "No Star", ["none"] = "No Star", ["0"] = "No Star",
["no star international"] = "No Star - International", ["international"] = "No Star - International",
}
local ordinal = {
[1] = "1st", [2] = "2nd", [3] = "3rd", [4] = "4th", [5] = "5th", [6] = "6th",
[7] = "7th", [8] = "8th", [9] = "9th", [10] = "10th", [11] = "11th", [12] = "12th",
[13] = "13th", [14] = "14th", [15] = "15th", [16] = "16th",
}
-- Column headers show the star rating each division's tier corresponds to, instead of a
-- generic "Div N" label -- e.g. a 3-Star event's 3 divisions read "3β
/ 2β
/ 1β
" rather
-- than "Div 1 / Div 2 / Div 3". Tiers 5 and 6 ("Local Competitive" and "Local Club Entry")
-- are both genuinely zero-star, so both display the same "0β
" -- and the international
-- tiers aren't star-rated at all, so those fall back to "Int'l" instead of a star count.
--
-- Uses the plain "β
" text glyph (U+2605), not the "βοΈ" colour emoji -- the emoji forces
-- its own yellow rendering and an oversized glyph that doesn't match the surrounding
-- text, where "β
" just renders in the header's own font colour and size.
local tierStars = {
[0] = "5β
", -- ABVT Elite
[1] = "4β
", -- ABVT Challenger / Pacific Games
[2] = "3β
", -- Highest State
[3] = "2β
", -- Competitive State
[4] = "1β
", -- Entry Level State
[5] = "0β
", -- Local Competitive
[6] = "0β
", -- Local Club Entry level
}
local function tierLabel(tier)
if tierStars[tier] then
return tierStars[tier]
elseif tier < 0 then
return "Int'l"
else
return "" -- outside the ladder entirely -- matches the blank data cells below it
end
end
local function trim(s)
if not s then return nil end
s = mw.text.trim(s)
if s == "" then return nil end
return s
end
-- Lowercase, and treat hyphens as spaces so "No Star - International" and "No Star
-- International" (and similar minor punctuation variants) match the same lookup key.
local function normalizeRating(s)
s = string.lower(s)
s = s:gsub("%-", " ")
s = s:gsub("%s+", " ")
return mw.text.trim(s)
end
-- Show "240" rather than "240.0", but keep the ".5" values (e.g. "82.5") as-is.
local function formatNumber(n)
if n == math.floor(n) then
return tostring(math.floor(n))
end
return tostring(n)
end
-- {{#invoke:ABVRS Points|ratingLabel|rating=3 Star}} -> "3β
(highest division rating)".
-- Used by Template:Beach Event to display the event's own ABVRS rating on the page's info
-- table. Rather than printing the plain rating name (e.g. "3-Star"), this shows the same
-- star notation as the points table's own Division 1 column header, plus a short label
-- clarifying that it's the event's highest division -- since divisions below it step down
-- the ladder to lower star ratings, "3-Star" alone read as if the whole event were 3-Star,
-- when only its top division is.
--
-- Reuses startIndex/tierLabel rather than duplicating a separate lookup table, so this
-- always matches whatever the points table's own top-division header shows -- including
-- "Int'l" for a "No Star - International" rating, which has no star count of its own.
function p.ratingLabel(frame)
local rating = trim(frame.args.rating)
if not rating then
return ""
end
local key = normalizeRating(rating)
local start = startIndex[key]
if start then
local label = tierLabel(start)
if label ~= "" then
return label .. " (highest division)"
end
end
return ratingLabel[key] or rating
end
-- {{#invoke:ABVRS Points|table|rating=3 Star|draw_size=12|divisions=3}}
function p.table(frame)
local args = frame.args
local rating = trim(args.rating)
local drawSizeRaw = trim(args.draw_size)
local divisionsRaw = trim(args.divisions)
-- All three hidden fields must be filled in, or nothing is shown.
if not rating or not drawSizeRaw or not divisionsRaw then
return ""
end
local start = startIndex[normalizeRating(rating)]
local drawSize = drawSizes[string.lower(drawSizeRaw)]
local divisions = tonumber(divisionsRaw)
if not start or not drawSize or not divisions then
return ""
end
divisions = math.floor(divisions)
if divisions < 1 then
return ""
end
-- Subheading reads "Division 1 is a 3β
ABVRS event with a 12-team draw" -- using the
-- same star notation as Division 1's own column header (tierLabel(start), the tier
-- Division 1 starts at) rather than the plain rating name, so it's consistent with
-- both the points table's headers and the info table's "ABVRS Rating" row above it.
-- "Int'l" is the only label this produces that takes "an" rather than "a".
local topLabel = tierLabel(start)
local article = "a"
if topLabel:match("^[AEIOUaeiou]") then
article = "an"
end
local tab1Text = "Division 1 is " .. article .. " " .. topLabel .. " ABVRS event with a " .. drawSize .. "-team draw"
-- Mobile tabs always split into "first 3 divisions" / "the rest", so tab 2 always
-- starts at Division 4. Division N's tier is start + N - 1 (same ladder-stepping the
-- table's own columns already use), so Division 4 is start + 3. Only computed when
-- that tier still has a label (tierLabel returns "" once the ladder runs out) -
-- otherwise both tabs show the same columns and there's nothing to swap to.
local tab2Text = nil
if divisions >= 4 then
local tab2Label = tierLabel(start + 3)
if tab2Label ~= "" then
local tab2Article = "a"
if tab2Label:match("^[AEIOUaeiou]") then
tab2Article = "an"
end
tab2Text = "Division 4 is " .. tab2Article .. " " .. tab2Label .. " ABVRS event with a " .. drawSize .. "-team draw"
end
end
local out = mw.html.create("")
out:tag("div")
:addClass("zunagi-points-heading")
:css("font-size", "1.1em")
:css("font-weight", "bold")
:css("margin", "1em 0 0")
:wikitext("ABVRS Points on Offer")
local subheading = out:tag("div")
:addClass("zunagi-points-subheading")
:css("font-size", "0.8em")
:css("color", "#666")
:css("margin", "4px 0 12px 0")
:attr("data-tab1-text", tab1Text)
:wikitext(tab1Text)
if tab2Text then
subheading:attr("data-tab2-text", tab2Text)
end
-- Explicit width:100% (not just "auto") on the wrapper. overflow-x:auto puts this div
-- into its own block formatting context, and on some mobile browsers a BFC root with
-- no explicit width shrinks to fit its content instead of filling the space available
-- -- fine when there are enough columns that the table's natural width already fills
-- the screen, but visible as unwanted empty space next to a narrower table (e.g. only
-- 2 divisions). Giving the wrapper itself width:100% removes that ambiguity so the
-- table's own width:100% (below) resolves against the full available width.
local wrapper = out:tag("div")
:addClass("zunagi-points-table-wrapper")
:css("overflow-x", "auto")
:css("width", "100%")
:css("box-sizing", "border-box")
-- Border/shading colours match .zunagi-event-record (the info table above this one on
-- the event page), for a consistent look: #f8f9fa header shading, #eaecf0 cell
-- dividers, #c8ccd1 outer border.
--
-- Columns use a percentage width (100 / number of columns) rather than a fixed pixel
-- width. Fixed pixel widths looked right on desktop, but on a narrow phone screen the
-- site's own responsive CSS still squeezes the table down to fit -- and since that
-- squeeze isn't proportional across fixed-width columns, they end up uneven instead
-- of just uniformly smaller. A percentage of the table's own width shrinks (or grows)
-- every column by the same proportion no matter how wide the table itself ends up.
local columns = divisions + 1
local colWidth = string.format("%.4f%%", 100 / columns)
local tbl = wrapper:tag("table")
:addClass("wikitable zunagi-points-table")
:css("width", "100%")
:css("margin-top", "0")
:css("border-collapse", "collapse")
:css("table-layout", "fixed")
:css("font-size", "1em")
:css("border-top", "1px solid #c8ccd1")
:css("border-bottom", "1px solid #c8ccd1")
local headerRow = tbl:tag("tr")
headerRow:tag("th")
:css("padding", "6px 10px")
:css("background-color", "#f8f9fa")
:css("border", "1px solid #eaecf0")
:css("width", colWidth)
:wikitext("Place")
for d = 1, divisions do
local tier = start + d - 1
local th = headerRow:tag("th")
:css("padding", "6px 14px")
:css("background-color", "#f8f9fa")
:css("border", "1px solid #eaecf0")
:css("width", colWidth)
:css("text-align", "center")
:css("white-space", "nowrap")
if tierStars[tier] then
-- Same font-size as the rest of the table (no more 1.15em bump) now that
-- the table's own base size has been raised to match the rest of the page.
th:tag("span")
:wikitext(tierStars[tier])
else
-- "Int'l" (the only tier without a star-notation label) stays bold like
-- the rest of the header row.
th:tag("span")
:css("font-weight", "bold")
:wikitext(tierLabel(tier))
end
end
for place = 1, drawSize do
local row = tbl:tag("tr")
row:tag("th")
:css("padding", "6px 10px")
:css("background-color", "#f8f9fa")
:css("border", "1px solid #eaecf0")
:css("width", colWidth)
:wikitext(ordinal[place] or (place .. "th"))
for d = 1, divisions do
local tier = start + d - 1
local mult = multiplier[tier]
local cell = row:tag("td")
:css("text-align", "center")
:css("padding", "6px 14px")
:css("background-color", "#fff")
:css("border", "1px solid #eaecf0")
:css("width", colWidth)
if mult and base[place] then
cell:wikitext(formatNumber(base[place] * mult))
end
end
end
return tostring(out)
end
return p