Module:Chart

-- next 100 lines or so are copied from mw.text package, which is not yet available. once this library is added to wmf distributions, all functions and variables that look lke "mw.text.XXX should be removed. u = require( "libraryUtil" ) mw = mw or {} mw.text = mw.text or {} local htmlencode_map = {    ['>'] = '&gt;',    ['<'] = '&lt;',    ['&'] = '&amp;',    ['"'] = '&quot;', ["'"] = '&#039;',   ['\194\160'] = '&#nbsp;', } local htmldecode_map = {} for k, v in pairs( htmlencode_map ) do   htmldecode_map[v] = k end local decode_named_entities = nil

function mw.text.encode( s, charset ) charset = charset or '<>&"\'\194\160'   s = mw.ustring.gsub( s, '[' .. charset .. ']', function ( m )        if not htmlencode_map[m] then            local e = string.format( '&#%d;', mw.ustring.codepoint( m ) )            htmlencode_map[m] = e            htmldecode_map[e] = m        end        return htmlencode_map[m]    end )    return s end function mw.text.split( text, pattern, plain )    local ret = {}    for m in gsplit( text, pattern, plain ) do        ret[#ret+1] = m    end    return ret end

function mw.text.gsplit( text, pattern, plain ) local s, l = 1, mw.ustring.len( text ) return function if s then local e, n = mw.ustring.find( text, pattern, s, plain ) local ret if not e then ret = mw.ustring.sub( text, s ) s = nil elseif n < e then -- Empty separator! ret = mw.ustring.sub( text, s, e ) if e < l then s = e + 1 else s = nil end else ret = e > s and mw.ustring.sub( text, s, e - 1 ) or '' s = n + 1 end return ret end end, nil, nil end

function mw.text.tag( name, attrs, content ) local named = false if type( name ) == 'table' then named = true name, attrs, content = name.name, name.attrs, name.content u.checkTypeForNamedArg( 'tag', 'name', name, 'string' ) u.checkTypeForNamedArg( 'tag', 'attrs', attrs, 'table', true ) else u.checkType( 'tag', 1, name, 'string' ) u.checkType( 'tag', 2, attrs, 'table', true ) end

local ret = { '<' .. name } for k, v in pairs( attrs or {} ) do       if type( k ) ~= 'string' then error( "bad named argument attrs to 'tag' (keys must be strings, found " .. type( k ) .. ")", 2 )       end if string.match( k, '[\t\r\n\f /<>"\'=]' ) then           error( "bad named argument attrs to 'tag' (invalid key '" .. k .. "')", 2 )        end        local tp = type( v )        if tp == 'boolean' then            if v then                ret[#ret+1] = ' ' .. k            end        elseif tp == 'string' or tp == 'number' then            ret[#ret+1] = string.format( ' %s="%s"', k, mw.text.encode( tostring( v ) ) )        else            error( "bad named argument attrs to 'tag' (value for key '" .. k .. "' may not be " .. tp .. ")", 2 )        end    end

local tp = type( content ) if content == nil then ret[#ret+1] = '>' elseif content == false then ret[#ret+1] = ' />' elseif tp == 'string' or tp == 'number' then ret[#ret+1] = '>' ret[#ret+1] = content ret[#ret+1] = ''   else if named then u.checkTypeForNamedArg( 'tag', 'content', content, 'string, number, nil, or false' ) else u.checkType( 'tag', 3, content, 'string, number, nil, or false' ) end end

return table.concat( ret ) end

function mw.text.trim( s ) return mw.ustring.match(s, "^%s*(.-)%s*$") end -- everything up to this point should be removed once mw.text becomes available. 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 = {}, {}, {} local width, height, stack, delimiter = 500, 350, false, ':' local chartWidth, chartHeight, defcolor, scalePerGroup

local keywords = { width = 'width', height = 'height', stack = 'stack', colors = 'colors', group = 'group', xlegend = 'x legend', yscale = 'y scale', tooltip = 'tooltip', defcolor = 'default color', scalePerGroup = 'scale per group', unitsPrefix = 'units prefix', unitsSuffix = 'units suffix', groupNames = 'group names', } -- here is where you want to translate

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] .. ' should contain the same number of items as the number of groups (' .. numGroups .. ')')           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' scaleWidth = scalePerGroup and 40 * numGroups or 60 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 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.scalePerGroup then scalePerGroup = true elseif k == keywords.defcolor then defcolor = v           else for keyword, tab in pairs( {                   group = values,                    xlegend = xlegends,                    colors = colors,                    tooltip = tooltips,                    unitsPrefix = unitPrefix,                    unitsSuffix = unitsSuffix,                    groupNames = groupNames,                    } ) 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 ithem, otherwise calculate. for "stack" there's only one limet. if #yscales > 0 then return end 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 = roundup( 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 ) end if not scalePerGroup then for i = 1, #values do yscales[i] = math.max( unpack( yscales ) ) end end end

function tooltip( gi, i, val ) function nulOrWhitespace( s ) return not s or mw.text.trim( s ) == '' end

if tooltips and tooltips[gi] and not nulOrWhitespace( tooltips[gi][i] ) then return tooltips[gi][i] 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 groupName .. prefix .. val .. suffix end

function calcHeights( gi, i, val ) local barHeight = math.floor( val / yscales[gi] * chartHeight ) 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 ) setWidth = 0.85 * setWidth if stack then local barWidth = math.min( 38, math.floor( 0.8 * setWidth ) ) return setOffset + (setWidth - barWidth) / 2, barWidth end 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 ) local color, tooltip = colors[gi] or defcolor or 'blue', tooltip( gi, i, val ) local left, barWidth = calcx( gi, i ) local barHeight, top = calcHeights( gi, i, val ) local style = string.format("position:absolute;left:%spx;top:%spx;height:%spx;min-width:%spx;max-width:%spx;background-color:%s;box-shadow:4px -3px 3px 1px grey;",                       left, top, barHeight, barWidth, barWidth, color) table.insert( res, mw.text.tag( 'div', { style = style, title = tooltip, }, "" ) ) end

function drawYScale function drawSingle( gi, color, width, single ) local yscale = yscales[gi] local _, top, ordermag = roundup( yscale * 0.999 ) local numnotches = 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 ) }, val ) 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 -- not ready yet/ local colWidth = 40 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 ) * 40 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 ) table.insert( res, ' ' ) end else drawSingle( 1, 'black', scaleWidth, 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;veritical-align:top;" local tickDivstyleFormat = "position:absolute;left:%spx;height:10px;width:1px;border-left:1px solid black;" for i = 1, numValues do           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 - 10 ) }, '' ) ) end end

function printGroupList if #groupNames > 0 then local list = {} for gi = 1, #groupNames do               local square = mw.text.tag( 'span', { style = string.format( 'background-color:%s;padding:0 0.5em;margin:0 0.5em;', colors[gi] or defcolor ) }, ' ' ) table.insert( list, '*' .. square .. ' ' .. groupNames[gi] ) end table.insert( res, table.concat( list, '\n' ) ) end end

function drawChart table.insert( res, mw.text.tag( 'div', { style = string.format( 'max-width:%spx;', width ) } ) ) table.insert( res, mw.text.tag( 'div', { style = string.format("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 ) } ) )

for gi, group in pairs( values ) do            for i, val in ipairs( group ) do                drawbar( gi, i, val ) end end

table.insert( res, ' ' ) table.insert( res, mw.text.tag( 'div', { style = string.format("position:absolute;height:%s;min-width:%s;max-width:%spx;", chartHeight, scaleWidth, scaleWidth, scaleWidth ) } ) ) drawYScale table.insert( res, ' ' ) table.insert( res, mw.text.tag( 'div', { style = string.format("float:right;position:relative;width:%spx;", chartWidth ) } ) ) drawXlegends table.insert( res, ' ' ) table.insert( res, ' ' ) printGroupList table.insert( res, ' ' ) end

extractParams validate calcHeightLimits drawChart return table.concat( res, "\n" ) end

return { ['bar-chart'] = barChart }