Module:Tennis performance timeline
Appearance
local p = {}
local concat = table.concat
local insert = table.insert
local format = mw.ustring.format
local tConfig = mw.loadData("Module:Tennis performance timeline/data")
local rounds = tConfig.rounds
local tournaments = tConfig.tournaments
local environments = tConfig.environments
local surfaces = tConfig.surfaces
local tOrders = tConfig.orders
local curYear = os.date("!*t").year
local calendar = mw.loadData("Module:Tennis performance timeline/calendar")[tostring(curYear)]
local genders = {
men = "Men"
}
local types = {
single = "Single"
}
--[[Utility functions]]
local function checkNonNil(value, type)
if value == nil then
error("Expected " .. type .. ", but is nil", 2)
end
return value
end
local function checkFormat(str, pattern, type)
if str == mw.ustring.match(str, pattern) then
return str
else
error("Invalid " .. type .. ": " .. str, 2)
end
end
local function checkYear(year)
checkNonNil(year, "year")
return checkFormat(year, "%d%d%d%d", "year")
end
local function checkNum(num, diagMsg)
diagMsg = "number" .. (diagMsg and " for " .. diagMsg or "")
checkNonNil(num, diagMsg);
return checkFormat(num, "%d+", diagMsg)
end
local function checkMember(elem, arr, type, diagMsg)
if arr[elem] then
return elem
else
diagMsg = type .. (diagMsg and " for " .. diagMsg or "")
checkNonNil(elem, diagMsg)
local message = {}
insert(message, "Invalid ")
insert(message, diagMsg)
insert(message, ": ")
insert(message, elem)
error(concat(message))
end
end
-- parser for data tables
local function parse(entry, year, tStats)
local entryType = type(entry)
if entryType == "string" then
return entry
elseif entryType == "table" then
if entry.type == "chrono" then
local numericYear = tonumber(year)
for _,elem in ipairs(entry) do
if type(elem) == "table" and numericYear >= elem[1] then
return parse(elem[2], year, tStats)
end
end
return parse(entry.default, year, tStats)
elseif entry.type == "switch" then
local param = entry.param
local arg = param == "year" and year or tStats[param]
if entry[arg] then return parse(entry[arg], year, tStats) end
return parse(entry.default, year, tStats)
else
return entry
end
end
end
-- Format an HTML element with link, tooltip, and colors.
local function tooltip(tag, link, tooltip, text, spec)
spec = spec or {}
if spec.color then
tag:css('color', spec.color)
end
if spec.bold then
tag:wikitext("'''");
end
if link then
tag:wikitext("[[" .. link .. "|")
end
if tooltip then
tag:tag('abbr'):attr('title', tooltip):wikitext(text)
else
tag:wikitext(text)
end
if link then tag:wikitext("]]") end
if spec.bold then
tag:wikitext("'''");
end
end
-- Substitute "$[`param`]$" appearing in `str` with `value`.
-- For example, subst("$year$ ATP Tour", "year", "1990") -> "1990 ATP Tour"
local function subst(str, param, value)
if str == nil then return str end
return str:gsub("%$" .. param .. "%$", value)
end
-- Transform `param` entry in `array` with supplied data.
local function transform(array, param, data, tournament, year)
local entry = array[param]
entry = subst(entry, "gender", genders[data.gender])
entry = subst(entry, "type", types[data.type])
entry = subst(entry, "year", year)
if tournaments[tournament] and tournaments[tournament].region then
entry = subst(entry, "region",
tournaments[tournament].region[data.country]
or tournaments[tournament].region.default)
end
if data[tournament] and data[tournament][year] then
local tStats = data[tournament][year]
if tournaments[tournament].group then
entry = subst(entry, "group", tournaments[tournament].group[tStats.group] or "")
end
end
array[param] = entry
end
local years
local data
--[[-
Prepare the header row of the performance timeline table.
@return three elements:
- table header wikimarkup
- header row
- the first cell in the header row
]]
local function header()
local row = mw.html.create('tr')
local headerCell = row:tag('th'):attr('scope', 'col')
if data.categories.count > 1 then
headerCell:attr('colspan', 2):wikitext("Tournament")
end
for _,year in ipairs(years) do
local link = subst(parse(tConfig.tours.ATP.link, year), "year", year)
row:tag('th'):attr('scope', 'col')
:wikitext(link and format("[[%s|%s]]", link, year) or year)
end
tooltip(row:tag('th'):attr('scope', 'col'), nil, "Win–Loss", "W–L")
tooltip(row:tag('th'):attr('scope', 'col'), nil, "Strike rate", "SR")
row:tag('th'):attr('scope', 'col')
:wikitext("Win %")
return "{|class=\"plainrowheaders wikitable\" style=text-align:center", row, headerCell
end
-- Return wiki page title for the tournament in a given year.
local function annualTournamentLink(year, tStats, tInfo)
local annualLink = tInfo.annualLink
return parse(annualLink, year, tStats)
end
local function outputSpan(row, span)
local cell = row:tag('td'):attr('colspan', span.span)
tooltip(cell, nil,
span.span < span.info.minSpellCols and span.info.tooltip,
span.span >= span.info.minSpellCols and span.info.tooltip or span.round,
span.info)
end
local footnoteCount
-- Return the year a given tournament was first held.
local function eventFirstYear(entries, yearInfos, year, yearEntry)
year = tonumber(year)
local startYear
if #entries > 0 then
local endYear = entries[#entries][4]
for testYear = endYear + 1, year do
local testYearTournament = parse(yearInfos, testYear)
if testYearTournament ~= entries[#entries][1] then
entries[#entries][4] = testYear - 1
break;
end
end
for testYear = year, endYear, -1 do
local testYearTournament = parse(yearInfos, testYear)
if testYearTournament ~= yearEntry then
startYear = testYear + 1
break;
end
end
end
return startYear
end
-- Format a footnote.
local function footnoteText(footnoteChunks, wikilinkFn)
if #footnoteChunks > 1 then
local footnote = {}
local footnoteChunk = {"Held as"}
for pos,entry in ipairs(footnoteChunks) do
local link, name, abbr = wikilinkFn(entry)
insert(footnoteChunk, format("[[%s%s]]",
link and link .. "|" or "", name))
-- Add abbr if doesn't appear in name
if abbr and not name:match(abbr) then
insert(footnoteChunk, "(" .. abbr .. ")")
end
if entry[3] == entry[4] then
-- One-year event
insert(footnoteChunk, "in")
insert(footnoteChunk, entry[3])
elseif pos > 1 then
insert(footnoteChunk, "from")
insert(footnoteChunk, entry[3])
end
if pos < #footnoteChunks then
insert(footnoteChunk, pos == 1 and "until" or "to")
insert(footnoteChunk, entry[4])
end
insert(footnote, concat(footnoteChunk, (" ")))
footnoteChunk = {}
end
footnote[#footnote] = "and " .. footnote[#footnote]
return "<ref group=\"lower-alpha\">" ..
concat(footnote, #footnote > 2 and ", " or " ") ..
"</ref>"
end
end
-- Prepare a Win-Loss row in the performance timeline table.
local function winLossStatsRow(row, info, stats, bold, percentageRowspan)
local boldmarkup = bold and "'''" or ""
local headerName = info.name and info.name .. " " or ""
row:tag('th'):attr('scope', 'row')
:wikitext(format("%s%sWin–Loss%s", boldmarkup, headerName, boldmarkup))
local span
for _,year in ipairs(years) do
local yStats = stats[year]
local display = {}
if yStats and yStats.wins + yStats.losses > 0 then
display.text = format("%s%d−%d%s", boldmarkup, yStats.wins, yStats.losses, boldmarkup)
else
display.text = format("%s–%s", boldmarkup, boldmarkup)
if info.absence then
local aConfig = parse(info.absence, year)
if aConfig then
display.text = aConfig.round
display.config = aConfig
end
end
end
if span then
if span.info.round == display.text then
span.span = span.span + 1
else
outputSpan(row, span)
span = nil
end
end
if not span then
if display.config and display.config.span then
span = {round = display.text, span = 1, info = display.config}
else
row:tag('td'):wikitext(display.text)
end
end
end
if span then
outputSpan(row, span)
span = nil
end
-- − is (nonbreaking) minus sign.
row:tag('td'):wikitext(format("%s%d−%d%s", boldmarkup, stats.wins, stats.losses, boldmarkup))
row:tag('td'):css('white-space', 'nowrap')
:wikitext(format("%s%d / %d%s", boldmarkup, stats.champs, stats.count, boldmarkup))
local matches = stats.wins + stats.losses
local percentageCell = row:tag('td')
if percentageRowspan then
percentageCell:attr('rowspan', percentageRowspan)
end
if matches > 0 then
percentageCell:wikitext(format("%s%.1f%%%s", boldmarkup, stats.wins * 100 / matches, boldmarkup))
else
percentageCell:wikitext(format("%s–%s", boldmarkup, boldmarkup))
end
end
-- Create a fresh statistics table.
local function statsFactory()
return {count = 0, champs = 0, finals = 0, wins = 0, losses = 0}
end
-- Generate performance timeline rows for a given tournament level.
local function body(level, levelHeaderCell)
local entries = {}
local stats = statsFactory()
local levelInfo = tOrders[level]
-- Count the number of needed rows.
local tournamentCount = 0
for _,tournament in ipairs(levelInfo) do
local tournamentType = type(tournament)
if tournamentType == "string" then
if data[tournament] then
tournamentCount = tournamentCount + 1
end
elseif tournamentType == "table" then
for _,entry in ipairs(tournament) do
if data[entry[2]] then
tournamentCount = tournamentCount + 1
end
end
if data[tournament.default] then
tournamentCount = tournamentCount + 1
end
end
end
if tournamentCount == 0 then return nil end
local levelInfos = {}
local levelLastAppearance
for pos,tournament in ipairs(levelInfo) do
local count = 0
local champs = 0;
local wins = 0
local losses = 0
local row = mw.html.create('tr')
if #entries == 0 and data.categories.count > 1 then
levelHeaderCell = row:tag('th'):attr('scope', 'row')
end
local headerCell = row:tag('th'):attr('scope', 'row')
local tInfos = {}
local lastAppearance
local appearances = 0
local span
for _,year in ipairs(years) do
local yearLevelName = parse(levelInfo.name, year)
if #entries == 0 and (#levelInfos == 0 or levelInfos[#levelInfos][1] ~= yearLevelName) then
-- Add footnote noting series transition.
local startYear = eventFirstYear(levelInfos, levelInfo.name, year, yearLevelName)
insert(levelInfos, {
yearLevelName,
{link = parse(levelInfo.link, year), abbr = parse(levelInfo.abbr, year)},
startYear, tonumber(year)
})
else
levelInfos[#levelInfos][4] = tonumber(year)
end
local yearTournament = parse(tournament, year)
local tInfo = checkNonNil(tournaments[yearTournament], "entry for " .. yearTournament)
if #tInfos == 0 or tInfos[#tInfos][2] ~= tInfo then
-- Add footnote noting tournament transition.
local startYear = eventFirstYear(tInfos, tournament, year, yearTournament)
insert(tInfos, {yearTournament, tInfo, startYear, tonumber(year)})
else
tInfos[#tInfos][4] = tonumber(year)
end
local display = {}
if data[yearTournament] and data[yearTournament][year] then
if not levelLastAppearance or levelLastAppearance < year then
levelLastAppearance = year
end
lastAppearance = tInfo
local tStats = data[yearTournament][year]
appearances = appearances + 1
if not rounds[tStats.round].nocount then
count = count + 1
stats.count = stats.count + 1
end
if rounds[tStats.round].strike then
champs = champs + 1
stats.champs = stats.champs + 1
end
wins = wins + tStats.wins
losses = losses + tStats.losses
if not stats[year] then
stats[year] = statsFactory()
end
stats[year].wins = stats[year].wins + tStats.wins
stats.wins = stats.wins + tStats.wins
stats[year].losses = stats[year].losses + tStats.losses
stats.losses = stats.losses + tStats.losses
local annualLink = annualTournamentLink(year, tStats, tInfo)
display.round = tStats.round
display.group = tStats.group
display.link = annualLink
transform(display, "link", data, yearTournament, year)
else
local round = parse(tInfo.absence, year) or "A"
if tonumber(year) < curYear or round == "NH" then
display.round = round
elseif tonumber(year) == curYear and calendar and data.last then
local tWeek = calendar.week[yearTournament]
local lWeek = calendar.week[data.last]
if tWeek and lWeek and tWeek <= lWeek then
display.round = round
end
end
end
local roundInfo = {}
setmetatable(roundInfo, {__index = rounds[display.round]})
transform(roundInfo, "tooltip", data, yearTournament, year)
if display.round == "Z" then
roundInfo.round = display.round .. display.group
else
roundInfo.round = display.round
end
if span then
if span.round == display.round then
span.span = span.span + 1
else
outputSpan(row, span)
span = nil
end
end
if not span then
if roundInfo.span then
span = {round = display.round, span = 1, info = roundInfo}
else
local cell = row:tag('td')
if roundInfo.bgcolor then
cell:css('background', roundInfo.bgcolor)
end
tooltip(cell, display.link, roundInfo.tooltip, roundInfo.round, roundInfo)
end
end
end
if span then
outputSpan(row, span)
span = nil
end
if appearances > 0 then
headerCell:wikitext(
format("[[%s%s]]",
lastAppearance.link and lastAppearance.link .. "|" or
lastAppearance.abbr and lastAppearance.name .. "|" or "",
lastAppearance.abbr or lastAppearance.name))
if #tInfos > 1 then
local footnote = footnoteText(tInfos,
function(entry)
local tInfo = entry[2]
return tInfo.link, tInfo.name, tInfo.abbr
end)
headerCell:wikitext(footnote)
footnoteCount = footnoteCount + 1
end
row:tag('td'):wikitext(format("%d–%d", wins, losses))
row:tag('td'):wikitext(format("%d / %d", champs, count))
local matches = wins + losses
if matches > 0 then
row:tag('td'):wikitext(format("%.1f%%", wins * 100 / matches))
else
row:tag('td'):wikitext("–")
end
insert(entries, row)
end
end
if #entries == 0 then return nil end
if levelHeaderCell then
if data.categories.count > 1 then
levelHeaderCell:attr('rowspan', #entries + 1)
end
local levelLink = parse(levelInfo.link, levelLastAppearance)
local levelName = parse(levelInfo.name, levelLastAppearance)
local levelAbbr = parse(levelInfo.abbr, levelLastAppearance)
local levelTooltip = parse(levelInfo.tooltip, levelLastAppearance)
tooltip(levelHeaderCell, levelLink or levelAbbr and levelName,
levelTooltip or levelLink, levelAbbr or levelName, {bold = true})
if #levelInfos > 1 then
local footnote = footnoteText(levelInfos,
function(entry)
return entry[2].link, entry[1], entry[2].abbr
end)
levelHeaderCell:wikitext(footnote)
footnoteCount = footnoteCount + 1
end
levelHeaderCell = nil
end
local row = mw.html.create('tr')
winLossStatsRow(row, {}, stats, true)
insert(entries, row)
local result = {}
for _,entry in ipairs(entries) do
insert(result, tostring(entry))
end
return concat(result, "\n")
end
-- Generate rows for career performance timeline.
local function summary(envSummary)
local entries = {}
local stats = statsFactory()
local environmentInfo = tOrders.environments
local surfaceInfo = tOrders.surfaces
local surfaceCount = 0
for _,environment in ipairs(environmentInfo) do
if data[environment] then
for _,surface in ipairs(surfaceInfo) do
if data[environment][surface] then
surfaceCount = surfaceCount + 1
end
end
end
end
if surfaceCount == 0 then return nil end
-- Aggregate data.
local eStats = {}
local sStats = {}
for _,env in ipairs(environmentInfo) do
if data[env] then
for _,surface in ipairs(surfaceInfo) do
if data[env][surface] then
for _,year in ipairs(years) do
local esyStats = data[env][surface][year]
if esyStats then
if not eStats[env] then
eStats[env] = statsFactory()
end
if not eStats[env][year] then
eStats[env][year] = statsFactory()
end
if not sStats[surface] then
sStats[surface] = statsFactory()
end
if not sStats[surface][year] then
sStats[surface][year] = statsFactory()
end
if not stats[year] then
stats[year] = statsFactory()
end
for key,value in pairs(esyStats) do
sStats[surface][year][key] = sStats[surface][year][key] + value
sStats[surface][key] = sStats[surface][key] + value
eStats[env][year][key] = eStats[env][year][key] + value
eStats[env][key] = eStats[env][key] + value
stats[year][key] = stats[year][key] + value
stats[key] = stats[key] + value
end
end
end
end
end
end
end
local headerCell
local function venueWinLossStatsRow(venues, vInfo, vStats)
for _,venue in ipairs(venues) do
if vStats[venue] then
local row = mw.html.create('tr')
if #entries == 0 and data.categories.count > 1 then
-- Add header cell.
headerCell = row:tag('th'):attr('scope', 'row')
:wikitext("'''Career'''")
end
winLossStatsRow(row, vInfo[venue], vStats[venue])
insert(entries, row)
end
end
end
venueWinLossStatsRow(surfaceInfo, surfaces, sStats)
if envSummary then
venueWinLossStatsRow(environmentInfo, environments, eStats)
end
local row = mw.html.create('tr')
winLossStatsRow(row, {name = "Overall"}, stats, true, 3)
insert(entries, row)
local row = mw.html.create('tr')
row:tag('th'):attr('scope', 'row')
:wikitext("'''Win %'''")
for _,year in ipairs(years) do
local wins = stats[year].wins
local losses = stats[year].losses
local matches = wins + losses
if matches > 0 then
row:tag('td'):wikitext(format("'''%.1f%%'''", wins * 100 / matches))
else
row:tag('td'):wikitext("'''–'''")
end
end
local percentSRcell = row:tag('td'):attr('colspan', 2)
:css('text-align', 'right')
:wikitext(format("'''%.1f%% ", stats.champs * 100 / stats.count))
tooltip(percentSRcell, nil, "Strike rate", "SR")
percentSRcell:wikitext("'''")
insert(entries, row)
local function counterStatsRow(row, name, type, bold)
local boldmarkup = bold and "'''" or ""
row:tag('th'):attr('scope', 'row')
:wikitext(format("%s%s%s", boldmarkup, name, boldmarkup))
for _,year in ipairs(years) do
row:tag('td'):wikitext(format("%s%d%s", boldmarkup, stats[year][type], boldmarkup))
end
row:tag('td'):attr('colspan', 2)
:wikitext(format("%s%d total%s", boldmarkup, stats[type], boldmarkup))
end
local row = mw.html.create('tr')
counterStatsRow(row, "Tournaments played", "count")
insert(entries, row)
if stats.finals > 0 then
local row = mw.html.create('tr')
counterStatsRow(row, "Finals reached", "finals")
row:tag('td'):attr('rowspan', 2)
:wikitext(format("'''%.1f%%'''", stats.champs * 100 / stats.finals))
insert(entries, row)
local row = mw.html.create('tr')
counterStatsRow(row, "Titles", "champs", true)
insert(entries, row)
end
local row = mw.html.create('tr')
row:tag('th'):attr('scope', 'row')
:wikitext("'''Year-end ranking'''")
for _,year in ipairs(years) do
local cell = row:tag('td')
if data.rank and data.rank[year] then
local rank = data.rank[year]
local rankConfig = tConfig.rankings[rank] or {}
if rankConfig.bgcolor then
cell:css('background', rankConfig.bgcolor)
end
local boldmarkup = rankConfig.bold and "'''" or ""
cell:wikitext(format("%s%s%s", boldmarkup, rank, boldmarkup))
end
end
local cell = row:tag('td'):attr('colspan', 3)
if data.prizemoney then
tooltip(cell, nil, "Career prize money", data.prizemoney, {bold = true})
end
insert(entries, row)
if headerCell then headerCell:attr('rowspan', #entries) end
local result = {}
for _,entry in ipairs(entries) do
insert(result, tostring(entry))
end
return concat(result, "\n")
end
-- Generate wikitext to conclude performance timeline table, including footnotes.
local function footer()
local result = {"|-\n|}"}
if footnoteCount > 0 then
local reflistTag = mw.html.create("div")
reflistTag:addClass("reflist"):css('list-style-type', 'lower-alpha')
:wikitext("<references group=\"lower-alpha\" />")
insert(result, tostring(reflistTag))
end
return concat(result, "\n")
end
function p._main(args, frame)
data = {}
years = {}
footnoteCount = 0
data.categories = {}
if args.types then
local count = 0
for _,type in ipairs(mw.text.split(args.types, ",")) do
if type == "Career" or tConfig.orders[type] then
data.categories[type] = true
count = count + 1
end
end
data.categories.count = count
else
local count = 0
for _, type in ipairs(tConfig.orders.order) do
data.categories[type] = true
count = count + 1
end
data.categories.Career = true
data.categories.count = count
end
data.gender = args.gender or "men"
data.type = args.type or "single"
data.country = args.country or "UNK"
local idx = 1
local environmentSummary = true
local year
while args[idx] do
local arg = args[idx]
if arg == "year" then
idx = idx + 1
year = checkYear(args[idx])
insert(years, year)
elseif tournaments[arg] then
local tournament = arg
local diagMsg = year .." " .. tournament
local tStats = {}
idx = idx + 1
local round = args[idx]
-- Handle zones for Davis Cup.
if round:sub(1, 1) == "Z" then
tStats.round = "Z"
tStats.group = checkNum(round:sub(2), diagMsg .. " zone")
else
tStats.round = round
end
checkMember(tStats.round, rounds, "round", diagMsg)
idx = idx + 1
tStats.wins = checkNum(args[idx], diagMsg)
idx = idx + 1
tStats.losses = checkNum(args[idx], diagMsg)
if data[tournament] == nil then data[tournament] = {} end
data[tournament][year] = tStats
elseif environments[arg] then
local environment = arg
local diagMsg = year .. " " .. " " .. environment
idx = idx + 1
local surface = checkNonNil(args[idx], diagMsg .. " surface")
if surfaces[surface] then
diagMsg = diagMsg .. " " .. surface
local sStats = {}
idx = idx + 1
sStats.count = checkNum(args[idx], diagMsg)
idx = idx + 1
sStats.wins = checkNum(args[idx], diagMsg)
idx = idx + 1
sStats.losses = checkNum(args[idx], diagMsg)
idx = idx + 1
sStats.champs = checkNum(args[idx], diagMsg)
idx = idx + 1
sStats.finals = sStats.champs + checkNum(args[idx], diagMsg)
if data[environment] == nil then data[environment] = {} end
if data[environment][surface] == nil then
data[environment][surface] = {}
end
data[environment][surface][year] = sStats
else
error(format("Unknown surface (%s %s): %s", year, environment, arg))
end
elseif surfaces[arg] then
local surface = arg
local diagMsg = year .. " " .. surface
local sStats = {}
idx = idx + 1
sStats.count = checkNum(args[idx], diagMsg)
idx = idx + 1
sStats.wins = checkNum(args[idx], diagMsg)
idx = idx + 1
sStats.losses = checkNum(args[idx], diagMsg)
idx = idx + 1
sStats.champs = checkNum(args[idx], diagMsg)
idx = idx + 1
sStats.finals = sStats.champs + checkNum(args[idx], diagMsg)
if data.outdoor == nil then data.outdoor = {} end
if data["outdoor"][surface] == nil then data["outdoor"][surface] = {} end
data["outdoor"][surface][year] = sStats
-- Disable summary by environment.
environmentSummary = false
elseif arg == "rank" then
idx = idx + 1
if data.rank == nil then data.rank = {} end
data.rank[year] = checkNum(args[idx], year .. " rank")
else
error(format("Unknown argument at position %d (%s): %s", idx, year, arg))
end
idx = idx + 1
end
data.prizemoney = args.prizemoney
data.last = args.last
local result = {}
local tableHeader, headerRow, headerCell = header()
result[#result+1] = tableHeader
for _,level in ipairs(tConfig.orders.order) do
if data.categories[level] then
local levelRows = body(level, data.categories.count == 1 and headerCell)
if #result == 1 then
result[#result+1] = tostring(headerRow)
end
result[#result+1] = levelRows
end
end
if data.categories.Career then
result[#result+1] = summary(environmentSummary)
end
result[#result+1] = footer()
return concat(result, "\n")
end
function p.main(frame)
-- Import module function to work with passed arguments
local getArgs = require('Module:Arguments').getArgs
local args = getArgs(frame)
return p._main(args, frame)
end
return p