Module:Chart

--[[
 keywords are used for languages: they are the names of the actual
 parameters of the template
]]
local keywords = {
 barChart = 'bar chart',
 pieChart = 'pie chart',
 width = 'width',
 height = 'height',
 stack = 'stack',
 colors = 'colors',
 group = 'group',
 xlegend = 'x legends',
 yticks = 'y tick marks',
 tooltip = 'tooltip',
 accumulateTooltip = 'tooltip value accumulation',
 links = 'links',
 defcolor = 'default color',
 scalePerGroup = 'scale per group',
 unitsPrefix = 'units prefix',
 unitsSuffix = 'units suffix',
 groupNames = 'group names',
 hideGroupLegends = 'hide group legends',
 slices = 'slices',
 slice = 'slice',
 radius = 'radius',
 percent = 'percent',
} -- here is what you want to translate
local defColors = mw.loadData("Module:Chart/Default colors")
local hideGroupLegends
local function nulOrWhitespace( s )
 return not s or mw.text.trim( s ) == ''
end
local function createGroupList( tab, legends, cols )
 if #legends > 1 and not hideGroupLegends then
 table.insert( tab, mw.text.tag( 'div' ) )
 local list = {}
 local spanStyle = "padding:0 1em;background-color:%s;border:1px solid %s;margin-right:1em;-webkit-print-color-adjust:exact;"
 for gi = 1, #legends do
 local span = mw.text.tag( 'span', { style = string.format( spanStyle, cols[gi], cols[gi] ) }, ' ' ) .. ' '.. legends[gi]
 table.insert( list, mw.text.tag( 'li', {}, span ) )
 end
 table.insert( tab,
 mw.text.tag( 'ul',
 {style="width:100%;list-style:none;column-width:12em;"},
 table.concat( list, '\n' )
 )
 )
 table.insert( tab, '</div>' )
 end
end
function pieChart( frame )
 local res, imslices, args = {}, {}, frame.args
 local radius
 local values, colors, names, legends, links = {}, {}, {}, {}, {}
 local delimiter = args.delimiter or ':'
 local lang = mw.getContentLanguage()
 function getArg( s, def, subst, with )
 local result = args[keywords[s]] or def or ''
 if subst and with then result = mw.ustring.gsub( result, subst, with ) end
 return result
 end
 function analyzeParams()
 function addSlice( i, slice )
 local value, name, color, link = unpack( mw.text.split( slice, '%s*' .. delimiter .. '%s*' ) )
 values[i] = tonumber( lang:parseFormattedNumber( value ) )
 or error( string.format( 'Slice %d: "%s", first item("%s") could not be parsed as a number', i, value or , sliceStr ) )
 colors[i] = not nulOrWhitespace( color ) and color or defColors[i * 2]
 names[i] = name or ''
 links[i] = link
 end
 
 radius = getArg( 'radius', 150 )
 hideGroupLegends = not nulOrWhitespace( args[keywords.hideGroupLegends] )
 local slicesStr = getArg( 'slices' )
 local prefix = getArg( 'unitsPrefix', , '_', ' ' )
 local suffix = getArg( 'unitsSuffix', , '_', ' ' )
 local percent = args[keywords.percent]
 local sum = 0
 local i, value = 0
 for slice in mw.ustring.gmatch( slicesStr or , "%b()" ) do
 i = i + 1
 addSlice( i, mw.ustring.match( slice, '^%(%s*(.-)%s*%)$' ) )
 end
 
 for k, v in pairs(args) do
 local ind = mw.ustring.match( k, '^' .. keywords.slice .. '%s+(%d+)$' )
 if ind then addSlice( tonumber( ind ), v ) end
 end
 
 for _, val in ipairs( values ) do sum = sum + val end
 for i, value in ipairs( values ) do
 local addprec = percent and string.format( ' (%0.1f%%)', value / sum * 100 ) or ''
 legends[i] = mw.ustring.format( '%s: %s%s%s%s', names[i], prefix, lang:formatNum( value ), suffix, addprec )
 links[i] = mw.text.trim( links[i] or mw.ustring.format( '%s', legends[i] ) )
 end
 end
 function addRes( ... )
 for _, v in pairs( { ... } ) do
 table.insert( res, v )
 end
 end
 function createImageMap()
 addRes( '' )
 end
 function drawSlice( i, q, start )
 local color = colors[i]
 local angle = start * 2 * math.pi
 local sin, cos = math.abs( math.sin( angle ) ), math.abs( math.cos( angle ) )
 local wsin, wcos = sin * radius, cos * radius
 local s1, s2, w1, w2, w3, w4, width, border
 local style
 if q == 1 then
 border = 'left'
 w1, w2, w3, w4 = 0, 0, wsin, wcos
 s1, s2 = 'bottom', 'left'
 elseif q == 2 then
 border = 'bottom'
 w1, w2, w3, w4 = 0, wcos, wsin, 0
 s1, s2 = 'bottom', 'right'
 elseif q == 3 then
 border = 'right'
 w1, w2, w3, w4 = wsin, wcos, 0, 0
 s1, s2 = 'top', 'right'
 else
 border = 'top'
 w1, w2, w3, w4 = wsin, 0, 0, wcos
 s1, s2 = 'top', 'left'
 end
 local style = string.format( 'border:solid transparent;position:absolute;%s:%spx;%s:%spx;width:%spx;height:%spx', s1, radius, s2, radius, radius, radius )
 if start <= ( q - 1 ) * 0.25 then
 style = string.format( '%s;border:0;background-color:%s', style, color )
 else
 style = string.format( '%s;border-width:%spx %spx %spx %spx;border-%s-color:%s', style, w1, w2, w3, w4, border, color )
 end
 addRes( mw.text.tag( 'div', { style = style }, ) )
 end
 function createSlices()
 function coordsOfAngle( angle )
 return ( 100 + math.floor( 100 * math.cos( angle ) ) ) .. ' ' .. ( 100 - math.floor( 100 * math.sin( angle ) ) )
 end
 local sum, start = 0, 0
 for _, value in ipairs( values ) do sum = sum + value end
 for i, value in ipairs(values) do
 local poly = { 'poly 100 100' }
 local startC, endC = start / sum, ( start + value ) / sum
 local startQ, endQ = math.floor( startC * 4 + 1 ), math.floor( endC * 4 + 1 )
 for q = startQ, math.min( endQ, 4 ) do drawSlice( i, q, startC ) end
 for angle = startC * 2 * math.pi, endC * 2 * math.pi, 0.02 do
 table.insert( poly, coordsOfAngle( angle ) )
 end
 table.insert( poly, coordsOfAngle( endC * 2 * math.pi ) .. ' 100 100 ' .. links[i] )
 table.insert( imslices, table.concat( poly, ' ' ) )
 start = start + values[i]
 end
 end
 analyzeParams()
 if #values == 0 then error( "no slices found - can't draw pie chart" ) end
 addRes( mw.text.tag( 'div', { class = 'chart noresize', style = string.format( 'margin-top:0.5em;max-width:%spx;', radius * 2 ) } ) )
 addRes( mw.text.tag( 'div', { style = string.format( 'position:relative;min-width:%spx;min-height:%spx;max-width:%spx;overflow:hidden;', radius * 2, radius * 2, radius * 2 ) } ) )
 createSlices()
 addRes( mw.text.tag( 'div', { style = string.format( 'position:absolute;min-width:%spx;min-height:%spx;overflow:hidden;', radius * 2, radius * 2 ) } ) )
 createImageMap()
 addRes( '</div>' ) -- close "position:relative" div that contains slices and imagemap.
 addRes( '</div>' ) -- close "position:relative" div that contains slices and imagemap.
 createGroupList( res, legends, colors ) -- legends
 addRes( '</div>' ) -- close containing div
 return frame:preprocess( table.concat( res, '\n' ) )
end
function barChart( frame )
 local res = {}
 local args = frame.args -- can be changed to frame:getParent().args
 local values, xlegends, colors, tooltips, yscales = {}, {}, {}, {} ,{}, {}, {}
 local groupNames, unitsSuffix, unitsPrefix, links = {}, {}, {}, {}
 local width, height, yticks, stack, delimiter = 500, 350, -1, false, args.delimiter or ':'
 local chartWidth, chartHeight, defcolor, scalePerGroup, accumulateTooltip
 local numGroups, numValues
 local scaleWidth
 function validate()
 function asGroups( name, tab, toDuplicate, emptyOK )
 if #tab == 0 and not emptyOK then
 error( "must supply values for " .. keywords[name] )
 end
 if #tab == 1 and toDuplicate then
 for i = 2, numGroups do tab[i] = tab[1] end
 end
 if #tab > 0 and #tab ~= numGroups then
 error ( keywords[name] .. ' must contain the same number of items as the number of groups, but it contains ' .. #tab .. ' items and there are ' .. numGroups .. ' groups')
 end
 end
 -- do all sorts of validation here, so we can assume all params are good from now on.
 -- among other things, replace numerical values with mw.language:parseFormattedNumber() result
 chartHeight = height - 80
 numGroups = #values
 numValues = #values[1]
 defcolor = defcolor or 'blue'
 colors[1] = colors[1] or defcolor
 scaleWidth = scalePerGroup and 80 * numGroups or 100
 chartWidth = width -scaleWidth
 asGroups( 'unitsPrefix', unitsPrefix, true, true )
 asGroups( 'unitsSuffix', unitsSuffix, true, true )
 asGroups( 'colors', colors, true, true )
 asGroups( 'groupNames', groupNames, false, false )
 if stack and scalePerGroup then
 error( string.format( 'Illegal settings: %s and %s are incompatible.', keyword.stack, keyword.scalePerGroup ) )
 end
 for gi = 2, numGroups do
 if #values[gi] ~= numValues then error( keywords.group .. " " .. gi .. " does not have same number of values as " .. keywords.group .. " 1" ) end
 end
 if #xlegends ~= numValues then error( 'Illegal number of ' .. keywords.xlegend .. '. Should be exactly ' .. numValues ) end
 end
 function extractParams()
 function testone( keyword, key, val, tab )
 i = keyword == key and 0 or key:match( keyword .. "%s+(%d+)" )
 if not i then return end
 i = tonumber( i ) or error("Expect numerical index for key " .. keyword .. " instead of '" .. key .. "'")
 if i > 0 then tab[i] = {} end
 for s in mw.text.gsplit( val, '%s*' .. delimiter .. '%s*' ) do
 table.insert( i == 0 and tab or tab[i], s )
 end
 return true
 end
 for k, v in pairs( args ) do
 if k == keywords.width then
 width = tonumber( v )
 if not width or width < 200 then
 error( 'Illegal width value (must be a number, and at least 200): ' .. v )
 end
 elseif k == keywords.height then
 height = tonumber( v )
 if not height or height < 200 then
 error( 'Illegal height value (must be a number, and at least 200): ' .. v )
 end
 elseif k == keywords.stack then stack = true
 elseif k == keywords.yticks then yticks = tonumber(v) or -1
 elseif k == keywords.scalePerGroup then scalePerGroup = true
 elseif k == keywords.defcolor then defcolor = v
 elseif k == keywords.accumulateTooltip then accumulateTooltip = not nulOrWhitespace( v )
 elseif k == keywords.hideGroupLegends then hideGroupLegends = not nulOrWhitespace( v )
 else
 for keyword, tab in pairs( {
 group = values,
 xlegend = xlegends,
 colors = colors,
 tooltip = tooltips,
 unitsPrefix = unitsPrefix,
 unitsSuffix = unitsSuffix,
 groupNames = groupNames,
 links = links,
 } ) do
 if testone( keywords[keyword], k, v, tab )
 then break
 end
 end
 end
 end
 end
 function roundup( x ) -- returns the next round number: eg., for 30 to 39.999 will return 40, for 3000 to 3999.99 wil return 4000. for 10 - 14.999 will return 15.
 local ordermag = 10 ^ math.floor( math.log10( x ) )
 local normalized = x / ordermag
 local top = normalized >= 1.5 and ( math.floor( normalized + 1 ) ) or 1.5
 return ordermag * top, top, ordermag
 end
 function calcHeightLimits() -- if limits were passed by user, use them, otherwise calculate. for "stack" there's only one limet.
 if stack then
 local sums = {}
 for _, group in pairs( values ) do
 for i, val in ipairs( group ) do sums[i] = ( sums[i] or 0 ) + val end
 end
 local sum = math.max( unpack( sums ) )
 for i = 1, #values do yscales[i] = sum end
 else
 for i, group in ipairs( values ) do yscales[i] = math.max( unpack( group ) ) end
 end
 for i, scale in ipairs( yscales ) do yscales[i] = roundup( scale * 0.9999 ) end
 if not scalePerGroup then for i = 1, #values do yscales[i] = math.max( unpack( yscales ) ) end end
 end
 function tooltip( gi, i, val )
 if tooltips and tooltips[gi] and not nulOrWhitespace( tooltips[gi][i] ) then return tooltips[gi][i], true end
 local groupName = not nulOrWhitespace( groupNames[gi] ) and groupNames[gi] .. ': ' or ''
 local prefix = unitsPrefix[gi] or unitsPrefix[1] or ''
 local suffix = unitsSuffix[gi] or unitsSuffix[1] or ''
 return mw.ustring.gsub(groupName .. prefix .. mw.getContentLanguage():formatNum( tonumber( val ) or 0 ) .. suffix, '_', ' '), false
 end
 function calcHeights( gi, i, val )
 local barHeight = math.floor( val / yscales[gi] * chartHeight + 0.5 ) -- add half to make it "round" instead of "trunc"
 local top, base = chartHeight - barHeight, 0
 if stack then
 local rawbase = 0
 for j = 1, gi - 1 do rawbase = rawbase + values[j][i] end -- sum the "i" value of all the groups below our group, gi.
 base = math.floor( chartHeight * rawbase / yscales[gi] ) -- normally, and especially if it's "stack", all the yscales must be equal.
 end
 return barHeight, top - base
 end
 function groupBounds( i )
 local setWidth = math.floor( chartWidth / numValues )
 local setOffset = ( i - 1 ) * setWidth
 return setOffset, setWidth
 end
 function calcx( gi, i )
 local setOffset, setWidth = groupBounds( i )
 if stack or numGroups == 1 then
 local barWidth = math.min( 38, math.floor( 0.8 * setWidth ) )
 return setOffset + (setWidth - barWidth) / 2, barWidth
 end
 setWidth = 0.85 * setWidth
 local barWidth = math.floor( 0.75 * setWidth / numGroups )
 local left = setOffset + math.floor( ( gi - 1 ) / numGroups * setWidth )
 return left, barWidth
 end
 function drawbar( gi, i, val, ttval )
 if val == '0' then return end -- do not show single line (borders....) if value is 0, or rather, '0'. see talkpage
 local color, tooltip, custom = colors[gi] or defcolor or 'blue', tooltip( gi, i, ttval or val )
 local left, barWidth = calcx( gi, i )
 local barHeight, top = calcHeights( gi, i, val )
 
 -- borders so it shows up when printing
 local style = string.format("position:absolute;left:%spx;top:%spx;height:%spx;min-width:%spx;max-width:%spx;background-color:%s;-webkit-print-color-adjust:exact;border:1px solid %s;border-bottom:none;overflow:hidden;",
 left, top, barHeight-1, barWidth-2, barWidth-2, color, color)
 local link = links[gi] and links[gi][i] or ''
 local img = not nulOrWhitespace( link ) and mw.ustring.format( '', link, custom and tooltip or ) or 
 table.insert( res, mw.text.tag( 'div', { style = style, title = tooltip, }, img ) )
 end
 function drawYScale()
 function drawSingle( gi, color, width, yticks, single )
 local yscale = yscales[gi]
 local _, top, ordermag = roundup( yscale * 0.999 )
 local numnotches = yticks >= 0 and yticks or
 		(top <= 1.5 and top * 4
 		or top < 4 and top * 2
 or top)
 local valStyleStr =
 single and 'position:absolute;height=20px;text-align:right;vertical-align:middle;width:%spx;top:%spx;padding:0 2px'
 or 'position:absolute;height=20px;text-align:right;vertical-align:middle;width:%spx;top:%spx;left:3px;background-color:%s;color:white;font-weight:bold;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000;padding:0 2px'
 local notchStyleStr = 'position:absolute;height=1px;min-width:5px;top:%spx;left:%spx;border:1px solid %s;'
 for i = 1, numnotches do
 local val = i / numnotches * yscale
 local y = chartHeight - calcHeights( gi, 1, val )
 local div = mw.text.tag( 'div', { style = string.format( valStyleStr, width - 10, y - 10, color ) }, mw.getContentLanguage():formatNum( tonumber( val ) or 0 ) )
 table.insert( res, div )
 div = mw.text.tag( 'div', { style = string.format( notchStyleStr, y, width - 4, color ) }, )
 table.insert( res, div )
 end
 end
 if scalePerGroup then
 local colWidth = 80
 local colStyle = "position:absolute;height:%spx;min-width:%spx;left:%spx;border-right:1px solid %s;color:%s"
 for gi = 1, numGroups do
 local left = ( gi - 1 ) * colWidth
 local color = colors[gi] or defcolor
 table.insert( res, mw.text.tag( 'div', { style = string.format( colStyle, chartHeight, colWidth, left, color, color ) } ) )
 drawSingle( gi, color, colWidth, yticks )
 table.insert( res, '</div>' )
 end
 else
 drawSingle( 1, 'black', scaleWidth, yticks, true )
 end
 end
 function drawXlegends()
 local setOffset, setWidth
 local legendDivStyleFormat = "position:absolute;left:%spx;top:10px;min-width:%spx;max-width:%spx;text-align:center;vertical-align:top;"
 local tickDivstyleFormat = "position:absolute;left:%spx;height:10px;width:1px;border-left:1px solid black;"
 for i = 1, numValues do
 if not nulOrWhitespace( xlegends[i] ) then
 setOffset, setWidth = groupBounds( i )
 -- setWidth = 0.85 * setWidth
 table.insert( res, mw.text.tag( 'div', { style = string.format( legendDivStyleFormat, setOffset + 5, setWidth - 10, setWidth - 10 ) }, xlegends[i] or ) )
 table.insert( res, mw.text.tag( 'div', { style = string.format( tickDivstyleFormat, setOffset + setWidth / 2 ) }, ) )
 end
 end
 end
 function drawChart()
 table.insert( res, mw.text.tag( 'div', { class = 'chart noresize', style = string.format( 'margin-top:1em;max-width:%spx;', width ) } ) )
 table.insert( res, mw.text.tag( 'div', { style = string.format("position:relative;min-height:%spx;min-width:%spx;max-width:%spx;", height, width, width ) } ) )
 table.insert( res, mw.text.tag( 'div', { style = string.format("float:right;position:relative;min-height:%spx;min-width:%spx;max-width:%spx;border-left:1px black solid;border-bottom:1px black solid;", chartHeight, chartWidth, chartWidth ) } ) )
 local acum = stack and accumulateTooltip and {}
 for gi, group in pairs( values ) do
 for i, val in ipairs( group ) do
 if acum then acum[i] = ( acum[i] or 0 ) + val end
 drawbar( gi, i, val, acum and acum[i] )
 end
 end
 table.insert( res, '</div>' )
 table.insert( res, mw.text.tag( 'div', { style = string.format("position:absolute;height:%spx;min-width:%spx;max-width:%spx;", chartHeight, scaleWidth, scaleWidth, scaleWidth ) } ) )
 drawYScale()
 table.insert( res, '</div>' )
 table.insert( res, mw.text.tag( 'div', { style = string.format( "position:absolute;top:%spx;left:%spx;width:%spx;", chartHeight, scaleWidth, chartWidth ) } ) )
 drawXlegends()
 table.insert( res, '</div>' )
 table.insert( res, '</div>' )
 createGroupList( res, groupNames, colors )
 table.insert( res, '</div>' )
 end
 extractParams()
 validate()
 calcHeightLimits()
 drawChart()
 return table.concat( res, "\n" )
end
return {
 ['bar-chart'] = barChart,
 [keywords.barChart] = barChart,
 [keywords.pieChart] = pieChart,
}