# Module:Chart

--<source lang=lua>  
---
keywords are used for languages: they are the names of the actual  
parameters of the template  

local keywords = {  
barChart = 'bar chart',  
pieChart = 'pie chart',,,  
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 "Module:Plotter/DefaultColors"  
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 span  
for gi = 1, #legends do  
local span = mw.text.tag( 'span', { style = string.format( spanStyle, cols[gi], cols[gi] ) }, '&nbsp;' ) .. ' '.. legends[gi]  
table.insert( list, mw.text.tag( 'li', {}, span ) )  
end  
table.insert( tab,  
mw.text.tag( 'ul',  
{},  
table.concat( list, '\n' )  
table.insert( tab, '</div>' )  
end  
end  

function pieChart  
local res, imslices, args = {}, {}, .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,  
local style  
if q == 1 then  
= ''  
w1, w2, w3, w4 = 0, 0, wsin, wcos  
s1, s2 = 'bottom', ''  
elseif q == 2 then  
= 'bottom'  
w1, w2, w3, w4 = 0, wcos, wsin, 0  
s1, s2 = 'bottom', ''  
elseif q == 3 then  
= ''  
w1, w2, w3, w4 = wsin, wcos, 0, 0  
s1, s2 = 'top', ''  
else  
= 'top'  
w1, w2, w3, w4 = wsin, 0, 0, wcos  
s1, s2 = 'top', ''  
end  

local style = string.format( ':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;:0;background-color:%s', style, color )  
else  
style = string.format( '%s;-width:%spx %spx %spx %spx;-%s-color:%s', style, w1, w2, w3, w4, , 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', {, 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 :preprocess( table.concat( res, '\n' ) )  
end  

function barChart  
local res = {}  
local args = .args -- can be changed to :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 = setOffset + math.floor( ( gi - 1 ) / numGroups * setWidth )  
return , 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 , barWidth = calcx( gi, i )  
local barHeight, top = calcHeights( gi, i, val )  

-- borders so it shows up when printing  
local style = string.format("position:absolute;:%spx;top:%spx;height:%spx;min-width:%spx;max-width:%spx;background-color:%s;-webkit-print-color-adjust:exact;: solid %s;-bottom:;overflow:hidden;",  
, 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=;text-align:;vertical-align:middle;width:%spx;top:%spx;padding:0 '  
or 'position:absolute;height=;text-align:;vertical-align:middle;width:%spx;top:%spx;:;background-color:%s;color:white;font-weight:bold;text-shadow:- - 0 #000, - 0 #000,- 0 #000, 0 #000;padding:0 '  
local notchStyleStr = 'position:absolute;height=;min-width:;top:%spx;:%spx;: 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 col  
for gi = 1, numGroups do  
local = ( gi - 1 ) * colWidth  
local color = colors[gi] or defcolor  
table.insert( res, mw.text.tag( 'div', { style = string.format( colStyle, chartHeight, colWidth, , 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;:%spx;top:;min-width:%spx;max-width:%spx;text-align:;vertical-align:top;"  
local tickDivstyleFormat = "position:absolute;:%spx;height:;width:;-: 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', {, 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:;position:relative;min-height:%spx;min-width:%spx;max-width:%spx;-: black solid;-bottom: 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;:%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,  
}  
--</source>