มอดูล:Medical cases chart

local yesno = require('มอดูล:Yesno')
local barBox = require('มอดูล:Bar box')
local lang = mw.getContentLanguage()
local language = lang:getCode()
local i18n = require('มอดูล:Medical cases chart/i18n')language
assert(i18n, 'no chart translations to: ' .. mw.language.fetchLanguageName(language, 'th'))
local monthAbbrs = {}
for i = 1, 12 do
	monthAbbrsi = lang:formatDate('M', '2020-' .. ('%02d'):format(i))
end
local p = {}
function p._toggleButton(active, customtoggles, id, label)
	local on  = active and  or ' mw-collapsed'
	local off = active and ' mw-collapsed' or 
	local outString =
		 .. label ..  ..
		 .. label  .. 
	return outString
end
function p._yearToggleButton(year)
	return p._toggleButton(year.l, ' mw-customtoggle-' .. year.year, year.year, year.year)
end
function p._monthToggleButton(year, month)
	local lmon, label = lang:lc(month.mon), month.mon
	local id = (year or ) .. lmon
	local customtoggles = ' mw-customtoggle-' .. id
	if month.s then
		label = label .. ' ' .. month.s -- "Mmm ##"
		if month.s ~= month.e then -- "Mmm ##–##"
			label = label .. '–' .. month.e
		end
	else
		customtoggles = customtoggles .. (month.l and customtoggles .. month.l or )
	end
	for i, combination in ipairs(month.combinations) do
		customtoggles = customtoggles .. ' mw-customtoggle-' .. combination -- up to 2 combinations per month so no need to table.concat()
	end
	return p._toggleButton(false, customtoggles, id, label)
end
function p._lastXToggleButton(years, duration, combinationsL)
	local months, id = years#years.months, 'l' .. duration
	local i, customtoggles = #months, {' mw-customtoggle-' .. id}
	if #years > 1 then
		local year = years#years.year
		while monthsi.l do
			customtoggles#customtoggles+1 = ' mw-customtoggle-' .. year .. lang:lc(monthsi.mon) .. '-' .. id
			if i == 1 then
				if year == years#years.year then
					year = years#years-1.year
					months = years#years-1.months
					i = #months
				else -- either first month is also lastX month or lastX spans more than 2 years, which is not intended yet
					break
				end
			else
				i = i - 1
			end
		end
	else
		while i > 0 and monthsi.l do
			customtoggles#customtoggles+1 = ' mw-customtoggle-' .. lang:lc(monthsi.mon) .. '-' .. id
			i = i - 1
		end
	end
	for i, combinationL in ipairs(combinationsL) do
		customtoggles#customtoggles+1 = ' mw-customtoggle-' .. combinationL -- up to 3 combinationsL in 90 days
	end
	return p._toggleButton(true, table.concat(customtoggles), id, mw.ustring.format(i18n.lastXDays, duration))
end
function p._buildTogglesBar(dateList, duration, nooverlap)
	local years = 
	local months, combinationsL = years1.months, {}
	local function addMonth(month)
		if month.mon ~= months#months.mon then -- new month
			if month.year ~= years#years.year then -- new year
				years#years+1 = {year=month.year, months={}}
				months = years#years.months -- switch months list
			end
			months#months+1 = {mon=month.mon, combinations={}}
		end
	end
	for i = 2, #dateList do -- deduplicate years and months
		if #dateListi == 0 then -- specific date
			addMonth(dateListi)
			months#months.l = months#months.l or dateListi.l -- so that both ...-mon and ...-mon-lX classes are created
		elseif #dateListi == 1 then -- interval within month
			addMonth(dateListi1)
			months#months.l = months#months.l or dateListi.l
		else -- multimonth interval
			for j, month in ipairs(dateListi) do
				addMonth(month)
				months#months.combinations#months#months.combinations+1 = dateListi.id
			end
			combinationsL#combinationsL+1 = dateListi.id:find('-l%d+$') and dateListi.id
		end
	end
	if nooverlap then
		local lastDate = dateList#dateList
		months#months.e = tonumber(os.date('%d', lastDate.nDate or lastDate.nEndDate or lastDate.nAltEndDate)) -- end of final month
		local i = #dateList
		repeat
			i = i - 1
		until i == 0 or (dateListi.mon or dateListi1.mon) ~= months#months.mon
		if i == 0 then -- start of first and final month
			months#months.s = tonumber(os.date('%d', dateList1.nDate))
		else
			months#months.s = 1
		end
	end
	years#years.l = true -- to activate toggle and respective months bar
	local monthToggles, divs = {}, nil
	if #years > 1 then
		local yearToggles, monthsDivs = {}, {}
		for i, year in ipairs(years) do
			yearToggles#yearToggles+1 = p._yearToggleButton(year)
			monthToggles = {}
			months = year.months
			for j, month in ipairs(months) do
				monthToggles#monthToggles+1 = p._monthToggleButton(year.year, month)
			end
			monthsDivs#monthsDivs+1 =
				 .. table.concat(monthToggles) .. 
		end
		divs =  .. table.concat(yearToggles) ..  .. table.concat(monthsDivs)
	else
		for i, month in ipairs(months) do
			monthToggles#monthToggles+1 = p._monthToggleButton(nil, month)
		end
		divs =  .. table.concat(monthToggles) .. 
	end
	divs = divs ..  .. p._lastXToggleButton(years, duration, combinationsL) .. 
	return  .. divs .. 
end
p._barColors = {
	'#A50026',  --deaths
	'SkyBlue',  --recoveries
	'Tomato',   --cases or altlbl1
	'Gold',     --altlbl2
	'OrangeRed' --altlbl3
}
function p._customBarStacked(args)
	local barargs = {}
	barargs1 = args1
	local function _numwidth(i)
		local nw = args.numwidth:sub(i, i)
		if nw == 'n' then
			return 0
		elseif nw == 't' then
			return 2.45
		elseif nw == 'm' or nw == 'd' then
			return 3.5
		elseif nw == 'w' then
			return 4.55
		elseif nw == 'x' then
			return 5.6
		end
	end
	barargs2 =
		 .. (args7 or ) ..  ..
		 .. (args8 or ) .. 
	if #args.numwidth == 4 then
		local pad = args.numwidth:sub(3, 3) == 'n' and '0' or '0.3em'
		barargs.note2 =
			 .. (args9 or ) ..  ..
			 .. (args10 or ) .. 
	end
	for i = 1, 5 do
		barargs2*i + 1 = p._barColorsi
		barargs2*i + 2 = argsi+1 / args.divisor
		barargs'title' .. i = argsi+1
	end
	barargs.align = 'cdcc'
	barargs.collapsed = args.collapsed
	barargs.id = args.id
	barargs.rowstyle = args.rowheight and 'line-height:'.. args.rowheight ..';'
	return barBox._stacked(barargs)
end
function p._row(args)
	local barargs = {}
	barargs1 = (args1 or '⋮') .. (args.note0 or )
	barargs2 = args2 or 0
	barargs3 = args3 or 0
	if args'alttot1' then
		barargs4 = args'alttot1'
	elseif args4 then
		barargs4 = (args4 or 0) - (barargs2 or 0) - (barargs3 or 0)
	else
		barargs4 = 0
	end
	barargs5 = args5 or 0
	if args'alttot2' then
		barargs6 = args'alttot2'
	elseif args6 then
		barargs6 = (args6 or 0) - (barargs2 or 0) - (barargs3 or 0)
	else
		barargs6 = 0
	end
	barargs7 = args7
	local function changeArg(firstright, valuecol, changecol)
		local change = 
		if args'firstright' .. firstright then
			change = '(' .. i18n.na .. ')'
		elseif not args1 and argsvaluecol then
			change = '(=)'
		else
			change = argschangecol and '(' .. argschangecol .. ')' or 
		end
		change = change .. (args'note' .. firstright or )
		return change
	end
	barargs8 = changeArg(1, 7, 8)
	barargs9 = args9
	barargs10 = changeArg(2, 9, 10)
	barargs.divisor = args.divisor
	barargs.numwidth = args.numwidth
	barargs.rowheight = args.rowheight
	local dates
	if args.collapsible then
		local duration = args.duration
		if args.daysToEnd >= duration then
			barargs.collapsed = 'y'
		else
			barargs.collapsed = 
		end
		if args.nooverlap and args.daysToEnd  em or y > ey
			dates.l = args.daysToEnd = 2 and i  updatePeriod then
					if nDateDiff == 2 * updatePeriod then
						prevRow = {nDate=barargs.nDate-updatePeriod}
						prevRow1 = os.date('%Y-%m-%d', prevRow.nDate)
					else
						prevRow = {nStartDate=(prevRow.nDate or prevRow.nEndDate)+updatePeriod, nEndDate=barargs.nDate-updatePeriod}
					end
					rows#rows+1 = prevRow
				end
			else
				prevRow.nEndDate = barargs.nDate - updatePeriod
				if prevRow.nStartDate == prevRow.nEndDate then
					prevRow.nDate = prevRow.nEndDate
					prevRow1 = os.date('%Y-%m-%d', prevRow.nDate)
				-- as nAltStartDate assumes a minimal multiday interval, it's possible for it to be greater if a true previous span is 1 day
				elseif prevRow.nAltStartDate and prevRow.nAltStartDate >= prevRow.nEndDate then
					error('a row in a consecutive intervals group is 1 day long and misses the date parameter')
				end
			end
		else
			if barargs.enddate then
				bValid, barargs.nEndDate = pcall(getUnix, barargs.enddate)
				assert(bValid, 'invalid enddate "' .. barargs.enddate .. '"')
			end
			if prevRow.nDate or prevRow.nEndDate then
				barargs.nStartDate = (prevRow.nDate or prevRow.nEndDate) + updatePeriod
				if barargs.nStartDate == barargs.nEndDate then
					barargs.nDate = barargs.nEndDate
					barargs1 = os.date('%Y-%m-%d', barargs.nDate)
				end
			else
				prevRow.nAltEndDate = (prevRow.nStartDate or prevRow.nAltStartDate) + updatePeriod
				barargs.nAltStartDate = prevRow.nAltEndDate + updatePeriod
				if barargs.nEndDate and barargs.nAltStartDate >= barargs.nEndDate then
					error('a row in a consecutive intervals group is 1 day long and misses the date parameter')
				end
			end
		end
		local function fillCols(col, change)
			local data = args'right' .. col .. 'data'
			local changetype = args'changetype' .. col
			local value, num, prevnum
			if data == 'alttot1' then
				num = barargs.alttot1 or barargs4
				prevnum = prevRow.alttot1 or prevRow4
			elseif data == 'alttot2' then
				num = barargs.alttot2 or barargs6
				prevnum = prevRow.alttot2 or prevRow6
			elseif data then
				num = barargsdata+1
				prevnum = prevRowdata+1
			end
			if data and num then -- nothing in column, source found, and data exists
				value = changetype == 'o' and  or lang:formatNum(num) -- set value to num if changetype isn't 'o'
				if not change and not barargs'firstright' .. col then
					if prevnum and prevnum ~= 0 then -- data on previous row
						if num - prevnum ~= 0 then --data has changed since previous row
							change = num-prevnum
							if changetype == 'a' then -- change type is "absolute"
								if change > 0 then
									change = '+' .. lang:formatNum(change)
								end
							else -- change type is "percent", "only percent" or undefined
								local percent = 100 * change / prevnum -- calculate percent
								local rounding = math.abs(percent) >= 10 and '%.0f' or math.abs(percent) >= 1 and '%.1f' or '%.2f'
								percent = tonumber(rounding:format(percent)) -- round to two sigfigs
								if percent > 0 then
									change = '+' .. lang:formatNum(percent) .. '%'
								elseif percent = args.duration
	end
	local bars, dateList = {}, {}
	for i, row in ipairs(rows) do -- build rows
		row.divisor = divisor
		row.numwidth = args.numwidth
		row.rowheight = args.rowheight
		row.collapsible = args.collapsible
		row.duration = args.duration
		row.nooverlap = args.nooverlap
		row.daysToEnd = (lastDate - (row.nDate or row.nEndDate or row.nAltEndDate)) / 86400
		row.multiyear = multiyear
		bars#bars+1, dateList#dateList+1 = p._row(row)
	end
	return table.concat(bars), dateList
end
function p._legend0(args)
	return
		 ..
			 ..
				'    ' ..  ..
			' ' .. (args2 or ) .. 
end
function p._chart(args)
	for key, value in pairs(args) do
		if ({float=1, barwidth=1, numwidth=1, changetype=1})key:gsub('%d', ) then
			argskey = value:lower()
		end
	end
	local barargs = {}
	barargs.css = 'Module:Medical cases chart/styles.css'
	barargs.float = args.float or 'right'
	args.barwidth = args.barwidth or 'medium'
	local barwidth
	if args.barwidth == 'thin' then
		barwidth = 120
	elseif args.barwidth == 'medium' then
		barwidth = 280
	elseif args.barwidth == 'wide' then
		barwidth = 400
	elseif args.barwidth == 'auto' then
		barwidth = 'auto'
	else
		error('unrecognized barwidth')
	end
	local function _numwidth(i)
		local nw = args.numwidth:sub(i, i)
		if nw == 'n' then
			return 0
		elseif nw == 't' then
			return 40
		elseif nw == 'm' or nw == 'd' then
			return 55
		elseif nw == 'w' then
			return 70
		elseif nw == 'x' then
			return 85
		else
			error('unrecognized numwidth' .. i .. )
		end
	end
	args.numwidth = args.numwidth or 'mm'
	local numwidth = _numwidth(1) + 10 + _numwidth(2)
	local right1width = numwidth
	if #args.numwidth == 4 then
		numwidth = numwidth + _numwidth(3) + _numwidth(4)
		if args.numwidth:sub(3, 3) == 'n' then
			numwidth = numwidth + 6
		else
			numwidth = numwidth + 10
		end
		if not args.right2 then
			right1width = numwidth
		end
	end
	right1width = right1width - 8 -- -8 because of padding
	if tonumber(barwidth) then
		barargs.width = 85 + barwidth + numwidth .. 'px'
		barargs.barwidth = barwidth .. 'px'
	else
		barargs.width = 'auto'
		barargs.barwidth = 'auto'
	end
	local title = {}
	local function spaces(n)
		local nbsp = ' '
		return  .. nbsp:rep(n) .. 
	end
	local location = lang:ucfirst(mw.ustring.gsub(args.location, i18n.the_, ))
	local navbartitle = args.outbreak .. i18n._data .. '/' ..
		(args.location3 and args.location3 .. '/' or ) ..
		(args.location2 and args.location2 .. '/' or ) ..
		location .. i18n._medicalCasesChart
	local navbar = require('มอดูล:Navbar')._navbar
	title1 = (args.pretitle and args.pretitle .. ' ' or ) .. i18n.cases .. ' ' ..
		args.disease .. ' ' .. i18n.In .. ' ' .. args.location ..
		(args.location2 and ', ' .. args.location2 or ) ..
		(args.location3 and ', ' .. args.location3 or ) ..
		(args.posttitle and ' ' .. args.posttitle or ) .. spaces(2) ..'(' ..
		navbar({navbartitle, titleArg=':' .. mw.getCurrentFrame():getParent():getTitle(), mini=1, nodiv=1}) ..
		')'
	title2 = p._legend0({p._barColors1, i18n.deaths})
	args.recoveries = args.recoveries == nil and true or args.recoveries
	title3 = args.recoveries and spaces(3) .. p._legend0({p._barColors2, args.reclbl or i18n.recoveries}) or 
	title4 = args.altlbl1 ~= 'hide' and spaces(3) .. p._legend0({p._barColors3, args.altlbl1 or i18n.activeCases}) or 
	title5 = args.altlbl2 and spaces(3) .. p._legend0({p._barColors4, args.altlbl2}) or 
	title6 = args.altlbl3 and spaces(3) .. p._legend0({p._barColors5, args.altlbl3}) or 
	local togglesbar, buildargs = nil, {}
	args.right1 = args.right1 or i18n.noOfCases
	args.duration = args.duration or 15
	args.nooverlap = args.nooverlap or false
	buildargs.barwidth = tonumber(barwidth) or 280
	buildargs.numwidth = args.numwidth
	buildargs.rowheight = args.rowheight
	if args.datapage then
		local externalData = require('Module:Medical cases chart/data')._externalData
		buildargs.data = externalData(args)
	else
		buildargs.data = args.data
	end
	-- if no right1data and right1 title is cases, use 3rd classification
	buildargs.right1data = args.right1data or args.right1 == i18n.noOfCases and 3
	-- if no right2data and right2 title is deaths, use 1st classification
	buildargs.right2data = args.right2data or (args.right2 == i18n.noOfDeaths or args.right2 == i18n.noOfDeaths2) and 1
	buildargs.changetype1 = (args.changetype1 or args.changetype or ):sub(1, 1) -- 1st letter
	buildargs.changetype2 = (args.changetype2 or args.changetype or ):sub(1, 1) -- 1st letter
	buildargs.collapsible = args.collapsible
	buildargs.duration = args.duration
	buildargs.nooverlap = args.nooverlap
	local dateList
	barargs.bars, dateList = p._buildBars(buildargs)
	if buildargs.collapsible then
		togglesbar = p._buildTogglesBar(dateList, args.duration, args.nooverlap)
	end
	title7 = togglesbar and  .. togglesbar or 
	barargs.title = table.concat(title)
	barargs.left1 =
		 .. -- 85-8 because of padding
			"" .. i18n.date .. "" .. 
	barargs.right1 =
		 ..
			"" .. args.right1 .. "" .. 
	if args.right2 then
		local right2width = numwidth - right1width - 16 -- -8-8...
		barargs.right2 =
			 ..
				"" .. args.right2 .. "" .. 
	end
	barargs.caption = args.caption
	return barBox._box(barargs)
end
function p.chart(frame)
	local getArgs = require('Module:Arguments').getArgs
	local args = getArgs(frame, {
		valueFunc = function (key, value)
			if value and value ~=  then
				key = key:gsub('%d', )
				if ({rowheight=1, duration=1, rightdata=1})key then -- if key in {...}
					return tonumber(value) or value
				end
				if ({recoveries=1, collapsible=1, nooverlap=1})key then
					return yesno(value)
				end
				return value
			end
			return nil
		end
	})
	return p._chart(args)
end
return p