Module:Tennis performance timeline

MyWikiBiz, Author Your Legacy — Sunday December 29, 2024
Jump to navigationJump to search

Documentation for this module may be created at Module:Tennis performance timeline/doc

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")

local genders = {
	men = "Men's",
	women = "Women's"
}

local matchTypes = {
	singles = "Singles",
	doubles = "Doubles"
}

--[[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

-- 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.italic then
		tag:wikitext("''");
	end
	if spec.bold then
		tag:wikitext("'''");
	end
	if link then
		tag:wikitext("[[" .. link .. "|")
	end
	if tooltip then
		if spec.abbr then
			tag:tag('abbr'):attr('title', tooltip):wikitext(text)
		else
			tag:attr('title', tooltip):wikitext(text)
		end
	else
		tag:wikitext(text)
	end
	if link then tag:wikitext("]]") end
	if spec.bold then
		tag:wikitext("'''");
	end
	if spec.italic 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

local function tr()
	return mw.html.create('tr')
end

local function th(row)
	return row:tag('th')
end

local function td(row)
	return row:tag('td')
end

local years
local data
local usedRounds

-- 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 param == "gender" and data.gender 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

-- Transform `param` entry in `array` with supplied data.
local function transform(array, param, data, tournament, year, country)
	local entry = array[param]
	entry = subst(entry, "gender", genders[data.gender])
	entry = subst(entry, "matchType", matchTypes[data.matchType])
	entry = subst(entry, "year", year)
	if data[tournament] and data[tournament][year] then
		local tStats = data[tournament][year]
		if tournaments[tournament] and tournaments[tournament].region then
			entry = subst(entry, "region",
				parse(tournaments[tournament].region[country]
					or tournaments[tournament].region.default, year, tStats))
		end
		if tournaments[tournament].group then
			entry = subst(entry, "group", tournaments[tournament].group[tStats.group] or "")
		end
	end
	array[param] = entry
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

--[[-
Prepare the header row of the performance timeline table.
@return three elements:
- table header wikitext
- header row
- the first cell in the header row
]]
local function header()
	local rows = {}
	local row = tr()
	local headerCell = th(row):attr('scope', 'col'):addClass('unsortable')
	local tooltipSpec = {abbr = true}
	if years.amateur or years.professional then
		headerCell:attr('rowspan', 2)
		local eras = {}
		if years.amateur then
			eras[#eras+1] = {years.amateur, "Amateur"}
		end
		if years.professional then
			eras[#eras+1] = {years.professional, "Professional"}
		end
		eras[#eras+1] = {"1968", "Open Era"}
		eras[#eras+1] = {tostring(years.last + 1)}
		local lastEra = "?"
		local idx = 1
		for _,era in ipairs(eras) do
			local count = 0
			while years[idx] and years[idx] ~= era[1] do
				count = count + 1
				idx = idx + 1
			end
			if count > 0 then
				th(row):attr('scope', 'col'):attr('colspan', count):wikitext(lastEra)
			end
			lastEra = era[2]
		end
		tooltip(th(row):attr('scope', 'col'):attr('rowspan', 2), nil, "Strike rate", "SR", tooltipSpec)
		tooltip(th(row):attr('scope', 'col'):attr('rowspan', 2), nil, "Win–Loss", "W–L", tooltipSpec)
		th(row):attr('scope', 'col'):attr('rowspan', 2):wikitext("Win %")
		insert(rows, row)
		row = tr()
	end
	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.link, year), "year", year)
		th(row):attr('scope', 'col'):addClass('unsortable')
			:wikitext(link and format("[[%s|%s]]", link, year) or year)
	end
	if #rows == 0 then
		tooltip(th(row):attr('scope', 'col'), nil, "Strike rate", "SR", tooltipSpec)
		tooltip(th(row):attr('scope', 'col'), nil, "Win–Loss", "W–L", tooltipSpec)
		th(row):attr('scope', 'col'):wikitext("Win %")
	end
	insert(rows, row)

	-- Add hatnote if needed.
	local headerText = {}
	if years.last and data.last and tournaments[data.last] then
		local tInfo = tournaments[data.last]
		local annualLink = {link = annualTournamentLink(years.last, nil, tInfo)}
		transform(annualLink, "link", data, data.last, years.last)
		local hatnote = {}
		insert(hatnote, "''This table includes results through the conclusion of the [[")
		annualLink = annualLink.link:gsub(" – .*$", "")
		local annualName = annualLink
		local annualSubst = tInfo.annualSubst or {}
		for _,subst in ipairs(annualSubst) do
			annualName = annualName:gsub(subst[1], subst[2])
		end
		if annualLink ~= annualName then
			insert(hatnote, annualLink)
			insert(hatnote, "|")
		end
		insert(hatnote, annualName)
		insert(hatnote, "]].''")
		insert(headerText, concat(hatnote))
	end
	insert(headerText, "{|class=\"plainrowheaders wikitable sortable\" style=text-align:center")
	return concat(headerText, "\n"), rows, headerCell
end

local function outputSpan(row, span, seenRounds)
	local cell = td(row):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)
	if seenRounds and span.span < span.info.minSpellCols then
		seenRounds[span.info.round] = span.info
	end
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

local frame_

-- 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])
			else
				if 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
			end
			insert(footnote, concat(footnoteChunk, (" ")))
			footnoteChunk = {}
		end
		footnote[#footnote] = "and " .. footnote[#footnote]
		return frame_:callParserFunction{name = '#tag:ref', args = {
			concat(footnote, #footnote > 2 and ", " or " "),
			group = "lower-alpha"
		}}
	end
end

local function winLossStatsSummary(row, stats, boldmarkup, summaryRowspan)
	boldmarkup = boldmarkup or ""
	-- &#8722; is (nonbreaking) minus sign.
	local srCell = td(row):attr('data-sort-value', stats.count > 0 and stats.champs / stats.count or -1)
		:addClass('nowrap')
		:wikitext(format("%s%d / %d%s", boldmarkup, stats.champs, stats.count, boldmarkup))
	local matches = stats.wins + stats.losses
	local wlCell = td(row):attr('data-sort-value', matches > 0 and stats.wins / matches or -1)
	local wrCell = td(row)
	if summaryRowspan then
		wlCell:attr('rowspan', summaryRowspan)
		wrCell:attr('rowspan', summaryRowspan)
		srCell:attr('rowspan', summaryRowspan)
	end
	if matches > 0 then
		wlCell:wikitext(format("%s%d–%d%s", boldmarkup, stats.wins, stats.losses, boldmarkup))
		wlCell:addClass("nowrap")
		wrCell:wikitext(format("%s%.2f%%%s", boldmarkup, stats.wins * 100 / matches, boldmarkup))
	else
		wlCell:wikitext(format("%s–%s", boldmarkup, boldmarkup))
		wrCell:attr('data-sort-value', '-1%')
			:wikitext(format("%s–%s", boldmarkup, boldmarkup))
	end
end

-- Prepare a Win-Loss row in the performance timeline table.
local function winLossStatsRow(row, info, stats, bold, summaryRowspan)
	local boldmarkup = bold and "'''" or ""
	local headerName = info.name and info.name .. " " or ""
	th(row):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)
		    bg_col = 'background-color:EAECF0'
		else
			display.text = format("%s–%s", boldmarkup, boldmarkup)
			bg_col = 'background-color:EAECF0'
			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
				td(row):wikitext(display.text)
					:addClass("nowrap")
			end
		end
	end
	if span then
		outputSpan(row, span)
		span = nil
	end
	winLossStatsSummary(row, stats, boldmarkup, summaryRowspan)
end

-- Return true if the player appears in a given tournament.
local function hasTournamentAppearance(tournament)
	local tournamentType = type(tournament)
	if tournamentType == "string" then
		if data[tournament] then
			return true
		end
	elseif tournamentType == "table" then
		if tournament.type == "chrono" then
			for _,entry in ipairs(tournament) do
				if data[entry[2]] then
					return true
				end
			end
			if data[tournament.default] then
				return true
			end
		else
			-- TODO other table type
		end
	end
	return false
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 = parse(tOrders[level])
	local levelInfos = {}
	local levelLastAppearance
	for pos,tournament in ipairs(levelInfo) do
		if hasTournamentAppearance(tournament) then
			local tStats = statsFactory()
			local row = tr()
			if #entries == 0 and data.categories.count > 1 then
				levelHeaderCell = th(row):attr('scope', 'row')
									:css('width', '6.5em')
									:css('max-width', '10em')
			end
			local headerCell = th(row):attr('scope', 'row')
			local tInfos = {}
			local lastAppearance
			local seenRounds = {}
			local span
			local country = data.country.default
			for _,year in ipairs(years) do
				country = data.country[year] or country
				local yearLevelName = parse(levelInfo.name or levelInfo.tooltip, year)
				if #entries == 0 and (#levelInfos == 0 or levelInfos[#levelInfos][1] ~= yearLevelName) then
					-- Add footnote noting series transition.
					local startYear = eventFirstYear(levelInfos, levelInfo.name or levelInfo.tooltip, 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 tyStats = data[yearTournament][year]
					if not rounds[tyStats.round].nocount then
						tStats.count = tStats.count + 1
						stats.count = stats.count + 1
					end
					if rounds[tyStats.round].strike then
						tStats.champs = tStats.champs + 1
						stats.champs = stats.champs + 1
					end
					if not stats[year] then
						stats[year] = statsFactory()
					end
					tStats.wins = tStats.wins + tyStats.wins
					stats[year].wins = stats[year].wins + tyStats.wins
					stats.wins = stats.wins + tyStats.wins
					tStats.losses = tStats.losses + tyStats.losses
					stats[year].losses = stats[year].losses + tyStats.losses
					stats.losses = stats.losses + tyStats.losses
					local annualLink = annualTournamentLink(year, tyStats, tInfo)
					display.round = tyStats.round
					display.group = tyStats.group
					display.link = annualLink
					transform(display, "link", data, yearTournament, year, country)
				else
					display.round = parse(tInfo.absence, year) or "A"
				end
				local round = rounds[display.round]
				if round.absence then
					local absence = false
					if year < years.last or not data.last and tonumber(year) < curYear then
						absence = true
					elseif year == years.last and data.last and calendar[year] and calendar[year][data.gender] then
						local tWeek = calendar[year][data.gender].week[yearTournament]
						local lWeek = calendar[year][data.gender].week[data.last]
						if tWeek and lWeek and tWeek <= lWeek then
							absence = true
						end
					end
					if not absence then display.round = nil end
				end
				local roundInfo = {}
				setmetatable(roundInfo, {__index = rounds[display.round]})
				transform(roundInfo, "tooltip", data, yearTournament, year)
				if roundInfo.group then
					roundInfo.round = display.round .. display.group
				else
					roundInfo.round = display.round
				end
				display.round = roundInfo.name or roundInfo.round
				if span then
					if span.round == display.round then
						span.span = span.span + 1
					else
						outputSpan(row, span, seenRounds)
						span = nil
					end
				end
				if not span then
					if roundInfo.span then
						span = {round = display.round, span = 1, info = roundInfo}
					else
						local cell = td(row)
						if roundInfo.round then
							if roundInfo.bgcolor then
								cell:css('background', roundInfo.bgcolor)
							end
							tooltip(cell, display.link, roundInfo.tooltip, display.round, roundInfo)
							seenRounds[roundInfo.round] = roundInfo
						end
					end
				end
			end
			if span then
				outputSpan(row, span, seenRounds)
				span = nil
			end
			if lastAppearance 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
				winLossStatsSummary(row, tStats)
				insert(entries, row)
				for _,roundInfo in pairs(seenRounds) do
					local round = roundInfo.name and not usedRounds[roundInfo.name] and roundInfo.name or roundInfo.round
					usedRounds[round] = roundInfo
				end
			end
		end
	end
	if #entries == 0 then return nil end
	if levelHeaderCell then
		local levelLink = parse(levelInfo.link, levelLastAppearance)
		local levelName = parse(levelInfo.name, levelLastAppearance)
		local levelAbbr = parse(levelInfo.abbr, levelLastAppearance)
		local levelTooltip = parse(levelInfo.tooltip, levelLastAppearance)
		local levelString = levelAbbr or levelName
		if data.categories.count > 1 then
			levelHeaderCell:attr('rowspan', #entries + 1)
		end
		tooltip(levelHeaderCell, levelLink or levelAbbr and levelName,
			levelTooltip, levelString, {bold = true, abbr = 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
	end

	local row = 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, headerCell)
	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 function venueWinLossStatsRow(venues, vInfo, vStats)
		for _,venue in ipairs(venues) do
			if vStats[venue] then
				local row = tr()
				if #entries == 0 and data.categories.count > 1 then
					-- Add header cell.
					headerCell = th(row):attr('scope', 'row')
				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 = tr()
	winLossStatsRow(row, {name = "Overall"}, stats, true, 2)
	insert(entries, row)

	local row = tr()
	local wrHdrCell = th(row):attr('scope', 'row'):wikitext("'''Win %'''")
	for _,year in ipairs(years) do
		local cellContent = "'''–'''"
		if stats[year] then
			local wins = stats[year].wins
			local losses = stats[year].losses
			local matches = wins + losses
			if matches > 0 then
				cellContent = format("'''%.1f%%'''", wins * 100 / matches)
			end
		end
		td(row):wikitext(cellContent)
	end
	insert(entries, row)
	if headerCell then 
		if data.categories.count > 1 then
			headerCell:attr('rowspan', #entries)
		end
		headerCell:wikitext("'''Career'''")
	end

	local function counterStatsRow(row, name, type, bold)
		local boldmarkup = bold and "'''" or ""
		local rowHdrCell = th(row):attr('scope', 'row'):css('text-align', 'right')
			:wikitext(format("%s%s%s", boldmarkup, name, boldmarkup))
		if data.categories.count > 1 then rowHdrCell:attr('colspan', 2) end
		for _,year in ipairs(years) do
			td(row):wikitext(format("%s%s%s",
				boldmarkup,
				stats[year] and tostring(stats[year][type]) or "–",
				boldmarkup))
		end
		td(row):attr('colspan', 2)
			:wikitext(format("%s%d total%s", boldmarkup, stats[type], boldmarkup))
	end

	local row = tr():addClass('sortbottom')
	counterStatsRow(row, "Tournaments played", "count")
	td(row):wikitext(format("'''%.1f%%'''", stats.champs * 100 / stats.count))
	insert(entries, row)

	if stats.finals > 0 then
		local row = tr():addClass('sortbottom')
		counterStatsRow(row, "Finals reached", "finals")
		td(row):attr('rowspan', 2)
			:wikitext(format("'''%.1f%%'''", stats.champs * 100 / stats.finals))
		insert(entries, row)
	
		local row = tr():addClass('sortbottom')
		counterStatsRow(row, "Titles", "champs", true)
		insert(entries, row)
	end

	local row = tr():addClass('sortbottom')
	local yearEndHdrCell = th(row):attr('scope', 'row'):css('text-align', 'right')
		:wikitext("'''Year-end ranking'''")
	if data.categories.count > 1 then yearEndHdrCell:attr('colspan', 2) end
	for _,year in ipairs(years) do
		local cell = td(row)
		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 = td(row):attr('colspan', 3)
	if data.prizemoney then
		tooltip(cell, nil, "Career prize money", data.prizemoney, {bold = true, abbr = true})
	end
	insert(entries, row)

	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 = {}
	insert(result, "|-\n|}")
	if footnoteCount > 0 then
		local reflistTag = mw.html.create("div")
		reflistTag:addClass("reflist"):css('list-style-type', 'lower-alpha')
			:wikitext(frame_:callParserFunction{
				name = '#tag:references', args = {"", group = "lower-alpha"}
			})
		insert(result, tostring(reflistTag))
	end
	return concat(result, "\n")
end

-- Return true if the player appears in a given tournament level.
local function hasLevelAppearance(level)
	local levelInfo = parse(tOrders[level])
	for _,tournament in ipairs(levelInfo) do
		if hasTournamentAppearance(tournament) then return true end
	end
	return false
end

function p._main(args, frame)
	frame_ = frame
	data = {}
	years = {}
	usedRounds = {}
	footnoteCount = 0
	data.gender = args.gender or "men"
	data.matchType = args.matchType or "singles"
	data.country = {}
	data.country.default = 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])
			if years.last and year <= years.last then
				error(format("Nonincreasing year: %s appears after %s", year, years.last))
			end
			years.last = year
			insert(years, year)
		elseif arg == "country" then
			idx = idx + 1
			local country = checkNonNil(args[idx], year .. " country")
			data.country[year] = args[idx]
		elseif arg == "amateur" then
			years.amateur = year
		elseif arg == "professional" then
			years.professional = 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 and round:sub(1, 2) == "WG" then
				tStats.round = "WG"
				tStats.group = round:sub(3)
			elseif round and round:sub(1, 2) == "PO" then
				tStats.round = "PO"
				tStats.group = round:sub(3)
			elseif round and 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
	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] and hasLevelAppearance(type) then
				data.categories[type] = true
				count = count + 1
			end
		end
		data.categories.count = count
	else
		local count = 0
		for _,type in ipairs(parse(tConfig.orders.order)) do
			if hasLevelAppearance(type) then
				data.categories[type] = true
				count = count + 1
			end
		end
		data.categories.Career = true
		data.categories.count = count + 1
	end
	local result = {}
	local tableHeader, headerRows, headerCell = header()
	insert(result, tableHeader)
	local function insertHeaderRowsIfNeeded()
		if #result == 1 then
			for _,headerRow in ipairs(headerRows) do
				insert(result, tostring(headerRow))
			end
		end
	end
	for _,level in ipairs(parse(tConfig.orders.order)) do
		if data.categories[level] then
			local levelRows = body(level, data.categories.count == 1 and headerCell)
			insertHeaderRowsIfNeeded()
			insert(result, levelRows)
		end
	end
	if data.categories.Career then
		local careerRows = summary(environmentSummary, data.categories.count == 1 and headerCell)
		insertHeaderRowsIfNeeded()
		if careerRows then insert(result, careerRows) end
	end
	insert(result, 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