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, the whole chunk 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 here should be removed once mw.text becomes avaulable.

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 legends', 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 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 exatly ' .. 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.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 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 = 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 ) 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 = {} local spanStyle = "letter-spacing:4px;font-size:1.3em;color:white;background-color:%s;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000;padding:3px 1em" for gi = 1, #groupNames do               local span = mw.text.tag( 'span', { style = string.format( spanStyle, colors[gi]) }, groupNames[gi] ) table.insert( list, mw.text.tag( 'li', { style = "margin: 4px;" }, span ) ) end table.insert( res, mw.text.tag( 'ul', {}, 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( "position:absolute;top:%s;left:%s;width:%spx;", chartHeight + 6, scaleWidth, 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 }