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="list-style:none;column-width:12em;"},

table.concat( list, '\n' )

)

)

table.insert( tab, '

' )

end

end

local 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()

local function getArg( s, def, subst, with )

local result = args[keywords[s]] or def or ''

if subst and with then result = string.gsub( result, subst, with ) end

return result

end

local function analyzeParams()

local 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 '', slice ) )

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 = 0

for slice in string.gmatch( slicesStr or '', "%b()" ) do

i = i + 1

addSlice( i, string.match( slice, '^%(%s*(.-)%s*%)$' ) )

end

for k, v in pairs(args) do

local ind = string.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] = string.format( '%s: %s%s%s%s', names[i], prefix, lang:formatNum( value ), suffix, addprec )

links[i] = mw.text.trim( links[i] or string.format( '%s', legends[i] ) )

end

end

local function addRes( ... )

for _, v in pairs( { ... } ) do

table.insert( res, v )

end

end

local function createImageMap()

addRes( '{{#tag:imagemap|', 'File:Circle frame.svg{{!}}' .. ( radius * 2 ) .. 'px' )

addRes( unpack( imslices ) )

addRes( 'desc none', '}}' )

end

local 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, border

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

local function createSlices()

local 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( '

' ) -- close "position:relative" div that contains slices and imagemap.

addRes( '' ) -- close "position:relative" div that contains slices and imagemap.

createGroupList( res, legends, colors ) -- legends

addRes( '' ) -- close containing div

return frame:preprocess( table.concat( res, '\n' ) )

end

local 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

local function validate()

local 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.', keywords.stack, keywords.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

local function extractParams()

local function testone( keyword, key, val, tab )

local 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

local 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

local 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

local 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 = mw.text.killMarkers(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 string.gsub(groupName .. prefix .. mw.getContentLanguage():formatNum( tonumber( val ) or 0 ) .. suffix, '_', ' '), false

end

local function calcHeights( gi, i, val )

local barHeight = math.max( 2, math.floor( val / yscales[gi] * chartHeight + 0.5 ) ) -- add half to make it "round" instead of "trunc", min height to 2 to avoid negative bar sizes

local top, base = chartHeight - barHeight, 0

if stack then

for j = 1, gi - 1 do

if tonumber(values[j][i]) > 0 then

base = base + math.max( 2, math.floor( values[j][i] / yscales[gi] * chartHeight + 0.5 ) ) -- sum the "i" value of all the groups below our group, gi, and keep the same calculation for each bar

end

end

end

return barHeight, top - base

end

local function groupBounds( i )

local setWidth = math.floor( chartWidth / numValues )

local setOffset = ( i - 1 ) * setWidth

return setOffset, setWidth

end

local 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

local 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 string.format( 'File:Transparent.png', link, custom and tooltip or ) or

table.insert( res, mw.text.tag( 'div', { style = style, title = tooltip, }, img ) )

end

local function drawYScale()

local 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, '' )

end

else

drawSingle( 1, 'black', scaleWidth, yticks, true )

end

end

local 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 + 1, setWidth - 2, setWidth - 2 ) }, xlegends[i] or '' ) )

table.insert( res, mw.text.tag( 'div', { style = string.format( tickDivstyleFormat, setOffset + setWidth / 2 ) }, '' ) )

end

end

end

local function drawChart()

table.insert( res, mw.text.tag( 'div', { class = 'chart noresize', style = string.format( 'padding-top:10px;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, '' )

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, '' )

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, '' )

table.insert( res, '' )

createGroupList( res, groupNames, colors )

table.insert( res, '' )

end

extractParams()

validate()

calcHeightLimits()

drawChart()

return table.concat( res, "\n" )

end

return {

['bar-chart'] = barChart,

[keywords.barChart] = barChart,

[keywords.pieChart] = pieChart,

}