Module:Chart
--&lt;source lang=lua&gt;
 --[[
     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 = require &quot;Module:Plotter/DefaultColors&quot;
 local hideGroupLegends
 
 local function nulOrWhitespace( s )
     return not s or mw.text.trim( s ) == ''
 end
 
 local function createGroupList( tab, legends, cols )
     if #legends &gt; 1 and not hideGroupLegends then
         table.insert( tab, mw.text.tag( 'div' ) )
         local list = {}
         local spanStyle = &quot;padding:0 1em;background-color:%s;border:1px solid %s;margin-right:1em;-webkit-print-color-adjust:exact;&quot;
         for gi = 1, #legends do
             local span = mw.text.tag( 'span', { style = string.format( spanStyle, cols[gi], cols[gi] ) }, '&amp;nbsp;' ) .. ' '..  legends[gi]
             table.insert( list, mw.text.tag( 'li', {}, span ) )
         end
         table.insert( tab,
             mw.text.tag( 'ul',
                 {style=&quot;width:100%;list-style:none;-webkit-column-width:12em;-moz-column-width:12em;column-width:12em;&quot;},
                 table.concat( list, '\n' )
             )
         )
         table.insert( tab, '&lt;/div&gt;' )
     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: &quot;%s&quot;, first item(&quot;%s&quot;) 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 '', &quot;%b()&quot; ) 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( '[[#noSuchAnchor|%s]]', legends[i] ) )
         end
     end
 
     function addRes( ... )
         for _, v in pairs( { ... } ) do
             table.insert( res, v )
         end
     end
 
     function createImageMap()
         addRes( '{{#tag:imagemap|', 'Image:Circle frame.svg{{!}}' .. ( radius * 2 ) .. 'px' )
         addRes( unpack( imslices ) )
         addRes( 'desc none', '}}' )
     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 &lt;= ( 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( &quot;no slices found - can't draw pie chart&quot; ) 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( '&lt;/div&gt;' ) -- close &quot;position:relative&quot; div that contains slices and imagemap.
     addRes( '&lt;/div&gt;' ) -- close &quot;position:relative&quot; div that contains slices and imagemap.
     createGroupList( res, legends, colors ) -- legends
     addRes( '&lt;/div&gt;' ) -- 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( &quot;must supply values for &quot; .. keywords[name] )
             end
             if #tab == 1 and toDuplicate then
                 for i = 2, numGroups do tab[i] = tab[1] end
             end
             if #tab &gt; 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 .. &quot; &quot; .. gi .. &quot; does not have same number of values as &quot; .. keywords.group .. &quot; 1&quot; ) 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 .. &quot;%s+(%d+)&quot; )
             if not i then return end
             i = tonumber( i ) or error(&quot;Expect numerical index for key &quot; .. keyword .. &quot; instead of '&quot; .. key .. &quot;'&quot;)
             if i &gt; 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 &lt; 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 &lt; 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 &gt;= 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 &quot;stack&quot; 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 &quot;round&quot; instead of &quot;trunc&quot;
         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 &quot;i&quot; value of all the groups below our group, gi.
             base = math.floor( chartHeight * rawbase / yscales[gi] ) -- normally, and especially if it's &quot;stack&quot;, 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(&quot;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;&quot;,
                         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( '[[File:Transparent.png|1000px|link=%s|%s]]', 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 &gt;= 0 and yticks or
             		(top &lt;= 1.5 and top * 4
             		or top &lt; 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 = &quot;position:absolute;height:%spx;min-width:%spx;left:%spx;border-right:1px solid %s;color:%s&quot;
             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, '&lt;/div&gt;' )
             end
         else
             drawSingle( 1, 'black', scaleWidth, yticks, true )
         end
     end
 
     function drawXlegends()
         local setOffset, setWidth
         local legendDivStyleFormat = &quot;position:absolute;left:%spx;top:10px;min-width:%spx;max-width:%spx;text-align:center;vertical-align:top;&quot;
         local tickDivstyleFormat = &quot;position:absolute;left:%spx;height:10px;width:1px;border-left:1px solid black;&quot;
         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(&quot;position:relative;min-height:%spx;min-width:%spx;max-width:%spx;&quot;, height, width, width ) } ) )
 
         table.insert( res, mw.text.tag( 'div', { style = string.format(&quot;float:right;position:relative;min-height:%spx;min-width:%spx;max-width:%spx;border-left:1px black solid;border-bottom:1px black solid;&quot;, 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, '&lt;/div&gt;' )
         table.insert( res, mw.text.tag( 'div', { style = string.format(&quot;position:absolute;height:%spx;min-width:%spx;max-width:%spx;&quot;, chartHeight, scaleWidth, scaleWidth, scaleWidth ) } ) )
         drawYScale()
         table.insert( res, '&lt;/div&gt;' )
         table.insert( res, mw.text.tag( 'div', { style = string.format( &quot;position:absolute;top:%spx;left:%spx;width:%spx;&quot;, chartHeight, scaleWidth, chartWidth ) } ) )
         drawXlegends()
         table.insert( res, '&lt;/div&gt;' )
         table.insert( res, '&lt;/div&gt;' )
         createGroupList( res, groupNames, colors )
         table.insert( res, '&lt;/div&gt;' )
     end
 
     extractParams()
     validate()
     calcHeightLimits()
     drawChart()
     return table.concat( res, &quot;\n&quot; )
 end
 
 return {
     ['bar-chart'] = barChart,
     [keywords.barChart] = barChart,
     [keywords.pieChart] = pieChart,
 }