# Module:Medical cases chart

local yesno = require('Module:Yesno')  
local barBox = require('Module:Bar box')  

local lang = mw.getContentLanguage  
local language = lang:getCode  
local i18n = require('Module:Medical cases chart/i18n')[language]  
assert(i18n, 'no chart translations to: ' .. mw.language.fetchLanguageName(language, 'ta'))  
local monthAbbrs = {}  
for i = 1, 12 do  
monthAbbrs[i] = 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 =  
'<span id="mw-customcollapsible-' .. id .. '" ' ..  
'>' .. label .. '</span>' ..  
'<span id="mw-customcollapsible-' .. id .. '">' .. label .. '</span>'  
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 .. '&nbsp;' .. 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 months[i].l do  
customtoggles[#customtoggles+1] = ' mw-customtoggle-' .. year .. lang:lc(months[i].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 months[i].l do  
customtoggles[#customtoggles+1] = ' mw-customtoggle-' .. lang:lc(months[i].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 = years[1].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 #dateList[i] == 0 then -- specific date  
addMonth(dateList[i])  
months[#months].l = months[#months].l or dateList[i].l -- so that both ...-mon and ...-mon-lX classes are created  
elseif #dateList[i] == 1 then -- interval within month  
addMonth(dateList[i][1])  
months[#months].l = months[#months].l or dateList[i].l  
else -- multimonth interval  
for j, month in ipairs(dateList[i]) do  
addMonth(month)  
months[#months].combinations[#months[#months].combinations+1] = dateList[i].id  
end  
combinationsL[#combinationsL+1] = dateList[i].id:find('-l%d+$') and dateList[i].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 (dateList[i].mon or dateList[i][1].mon) ~= months[#months].mon  
if i == 0 then -- start of first and final month  
months[#months].s = tonumber(os.date('%d', dateList[1].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] =  
'<div id="mw-customcollapsible-' .. year.year .. '">' .. table.concat(monthToggles) .. '</div>'  
end  
divs = '<div>' .. table.concat(yearToggles) .. '</div>' .. table.concat(monthsDivs)  
else  
for i, month in ipairs(months) do  
monthToggles[#monthToggles+1] = p._monthToggleButton(nil, month)  
end  
divs = '<div>' .. table.concat(monthToggles) .. '</div>'  
end  
divs = divs .. '<div>' .. p._lastXToggleButton(years, duration, combinationsL) .. '</div>'  
return '<div>' .. divs .. '</div>'  
end  

p._barColors = {  
'#A50026', --deaths  
'SkyBlue', --recoveries  
'Tomato', --cases or altlbl1  
'Gold', --altlbl2  
'OrangeRed' --altlbl3  
}  

function p._customBarStacked(args)  
local barargs = {}  

barargs[1] = args[1]  

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  

barargs[2] =  
'<span>' .. (args[7] or '') .. '</span>' ..  
'<span>' .. (args[8] or '') .. '</span>'  

if #args.numwidth == 4 then  
local pad = args.numwidth:sub(3, 3) == 'n' and '0' or '0.3em'  
barargs.note2 =  
'<span>' .. (args[9] or '') .. '</span>' ..  
'<span>' .. (args[10] or '') .. '</span>'  
end  

for i = 1, 5 do  
barargs[2*i + 1] = p._barColors[i]  
barargs[2*i + 2] = args[i+1] / args.divisor  
barargs['title' .. i] = args[i+1]  
end  

barargs.  
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 = {}  

barargs[1] = (args[1] or '⋮') .. (args.note0 or '')  
barargs[2] = args[2] or 0  
barargs[3] = args[3] or 0  

if args['alttot1'] then  
barargs[4] = args['alttot1']  
elseif args[4] then  
barargs[4] = (args[4] or 0) - (barargs[2] or 0) - (barargs[3] or 0)  
else  
barargs[4] = 0  
end  

barargs[5] = args[5] or 0  

if args['alttot2'] then  
barargs[6] = args['alttot2']  
elseif args[6] then  
barargs[6] = (args[6] or 0) - (barargs[2] or 0) - (barargs[3] or 0)  
else  
barargs[6] = 0  
end  

barargs[7] = args[7]  

local function changeArg(firstright, valuecol, changecol)  
local change = ''  
if args['firstright' .. firstright] then  
change = '(' .. i18n.na .. ')'  
elseif not args[1] and args[valuecol] then  
change = '(=)'  
else  
change = args[changecol] and '(' .. args[changecol] .. ')' or ''  
end  
change = change .. (args['note' .. firstright] or '')  

return change  
end  

barargs[8] = changeArg(1, 7, 8)  
barargs[9] = args[9]  
barargs[10] = 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 < duration then  
barargs.id = 'l' .. duration  
elseif args.nDate then  
dates = {year=tonumber(os.date('%Y', args.nDate)), mon=lang:formatDate('M', os.date('%Y-%m', args.nDate)),  
l=args.daysToEnd < duration and '-l' .. duration, nDate=args.nDate}  
barargs.id = (args.multiyear and dates.year or *) .. lang:lc(dates.mon) .. (dates.l or *)  
else  
local id, y, m, ey, em = {},  
tonumber(os.date('%Y', args.nStartDate or args.nAltStartDate)),  
tonumber(os.date('%m', args.nStartDate or args.nAltStartDate)),  
tonumber(os.date('%Y', args.nEndDate or args.nAltEndDate )),  
tonumber(os.date('%m', args.nEndDate or args.nAltEndDate ))  
dates = {nStartDate=args.nStartDate, nAltStartDate=args.nAltStartDate, nEndDate=args.nEndDate, nAltEndDate=args.nAltEndDate}  

repeat  
id[#id+1] = (args.multiyear and y or '') .. lang:lc(monthAbbrs[m])  
dates[#dates+1] = {year=y, mon=monthAbbrs[m]}  
y = y + math.floor(m / 12)  
m = m % 12 + 1  
until y == ey and m > em or y > ey  

dates.l = args.daysToEnd < duration and '-l' .. duration  
id = table.concat(id, '-') .. (dates.l or '')  
barargs.id = id  
dates.id = id  
end  
else  
barargs.collapsed = ''  
barargs.id = ''  
end  

return p._customBarStacked(barargs), dates  
end  

function p._buildBars(args)  
local = mw.getCurrentFrame  
local updatePeriod = 86400 -- temporary implementation only supports daily updates  

local function getUnix(timestamp)  
return lang:formatDate('U', timestamp)  
end  

local rows, prevRow = {}, {}  
for line in mw.text.gsplit(args.data, '\n') do  
local i, barargs = 1, {}  
-- parameter parsing, basic type/missing value handling  
for parameter in mw.text.gsplit(line, ';') do  
if parameter:find('^%s*%a') then  
parameter = mw.text.split(parameter, '=')  
parameter[1] = mw.text.trim(parameter[1])  
if parameter[1]:find('^alttot') then  
parameter[2] = tonumber(:callParserFunction('#expr', parameter[2]))  
else  
parameter[2] = mw.text.trim(parameter[2])  
if parameter[1]:find('^firstright') then  
parameter[2] = yesno(parameter[2])  
elseif parameter[2] == '' then  
parameter[2] = nil  
end  
end  
barargs[parameter[1]] = parameter[2]  
else  
parameter = mw.text.trim(parameter)  
if parameter ~= '' then  
if i >= 2 and i <= 6 then  
parameter = tonumber(:callParserFunction('#expr', parameter))  
assert(parameter, 'data parameters 2 to 6 must not be formatted')  
end  
barargs[i] = parameter  
end  
i = i + 1  
end  
end  

local bValid, nDateDiff  
-- get relevant date info based on previous row  
if barargs[1] then  
bValid, barargs.nDate = pcall(getUnix, barargs[1])  
assert(bValid, 'invalid date "' .. barargs[1] .. '"')  
if prevRow.nDate or prevRow.nEndDate then  
nDateDiff = barargs.nDate - (prevRow.nDate or prevRow.nEndDate)  
if nDateDiff > updatePeriod then  
if nDateDiff == 2 * updatePeriod then  
prevRow = {nDate=barargs.nDate-updatePeriod}  
prevRow[1] = 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  
prevRow[1] = 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  
barargs[1] = 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['' .. col .. 'data']  
local changetype = args['changetype' .. col]  
local value, num, prevnum  

if data == 'alttot1' then  
num = barargs.alttot1 or barargs[4]  
prevnum = prevRow.alttot1 or prevRow[4]  
elseif data == 'alttot2' then  
num = barargs.alttot2 or barargs[6]  
prevnum = prevRow.alttot2 or prevRow[6]  
elseif data then  
num = barargs[data+1]  
prevnum = prevRow[data+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 < 0 then  
change = lang:formatNum(percent) .. '%'  
else  
change = '='  
end  
end  
else -- data has not changed since previous row  
change = '='  
end  
else -- no data on previous row  
barargs['firstright' .. col] = true -- set to (n.a.)  
end  
end  
end  

return value, change  
end  

if not barargs[7] then  
barargs[7], barargs[8] = fillCols(1, barargs[8])  
end  
if not barargs[9] then  
barargs[9], barargs[10] = fillCols(2, barargs[10])  
end  

rows[#rows+1] = barargs  
prevRow = barargs  
end  

-- calculate and pass repetitive (except daysToEnd) parameters to each row  
local lastRow = rows[#rows]  
local total = {lastRow[2] or 0, lastRow[3] or 0, [4]=lastRow[5] or 0}  
total[3] = lastRow.alttot1 or lastRow[4] and lastRow[4] - total[1] - total[2] or 0  
total[5] = lastRow.alttot2 or lastRow[6] and lastRow[6] - total[1] - total[2] or 0  
local divisor = (total[1] + total[2] + total[3] + total[4] + total[5]) / (0.95 * args.barwidth)  
local firstDate, lastDate = rows[1].nDate, lastRow.nDate or lastRow.nEndDate  
local multiyear = os.date('%Y', firstDate) ~= os.date('%Y', lastDate - (args.nooverlap and args.duration * 86400 or 0))  
if args.collapsible ~= false then  
args.collapsible = (lastDate - firstDate) / 86400 >= 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  
'<span>' ..  
'<span>' ..  
'&nbsp;&nbsp;&nbsp;&nbsp;' .. '</span>' ..  
'&nbsp;' .. (args[2] or '') .. '</span>'  
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  
args[key] = value:lower  
end  
end  

local barargs = {}  

barargs.css = 'Module:Medical cases chart/styles.css'  
barargs.float = args.float or ''  

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  
bar  
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.  
barargs.bar  
end  

local title = {}  

local function spaces(n)  
local nbsp = '&nbsp;'  
return '<span>' .. nbsp:rep(n) .. '</span>'  
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('Module:Navbar')._navbar  
title[1] = (args.pretitle and args.pretitle .. ' ' or '') ..  
args.disease .. ' ' .. i18n.casesIn .. ' ' .. 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}) ..  
')  
'  

title[2] = p._legend0({p._barColors[1], i18n.deaths})  
args.recoveries = args.recoveries == nil and true or args.recoveries  
title[3] = args.recoveries and spaces(3) .. p._legend0({p._barColors[2], args.reclbl or i18n.recoveries}) or ''  
title[4] = args.altlbl1 ~= 'hide' and spaces(3) .. p._legend0({p._barColors[3], args.altlbl1 or i18n.activeCases}) or ''  
title[5] = args.altlbl2 and spaces(3) .. p._legend0({p._barColors[4], args.altlbl2}) or ''  
title[6] = args.altlbl3 and spaces(3) .. p._legend0({p._barColors[5], 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  

title[7] = togglesbar and '  
' .. togglesbar or ''  
barargs.title = table.concat(title)  

barargs.left1 =  
'<div>' .. -- 85-8 because of padding  
"**" .. i18n.date .. "**" .. '</div>'  

barargs.right1 =  
'<div>' ..  
"**" .. args.right1 .. "**" .. '</div>'  

if args.right2 then  
local right2width = numwidth - right1width - 16 -- -8-8...  
barargs.right2 =  
'<div>' ..  
"**" .. args.right2 .. "**" .. '</div>'  
end  

barargs.caption = args.caption  
return barBox._box(barargs)  
end  

function p.chart  
local getArgs = require('Module:Arguments').getArgs  
local args = getArgs(, {  
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