Modulo:Graph

Versione del 21 ago 2015 alle 12:08 di Moroboshi (discussione | contributi) (fix altri errori)

Modulo che implementa i template {{Grafico}} e {{Grafico a torta}}.

Ha una sottopagina di configurazione: Modulo:Graph/Configurazione.


local p = {}
local cfg = mw.loadData( 'Modulo:Chart/Configurazione' );
local getArgs = require('Module:Arguments').getArgs
local errors = { }

local function dump(t, ...)
    local args = {...}
    for _, s in ipairs(args) do
        table.insert(t, s)
    end
end

-- ===============================================================================
-- Add error message to errors list, mgs_key must be a error key listed in
-- cfg.errors_key, args is an optional array of string
-- ===============================================================================
local function add_error(msg_key, args)
    local msg = cfg.errors_key[msg_key]
    if not msg then msg = cfg.errors_key.unknown_error end
    if args then
        errors[#errors+1] = mw.ustring.format(msg, unpack(args))
    else
        errors[#errors+1] = msg
    end
end

-- ===============================================================================
-- Consolidate errors messages and add error category
-- ===============================================================================
local function errors_output(nocat)
    if #errors > 0 then
        local out = string.format('<strong class="error">%s</strong>', table.concat(errors, "; "))
        if nocat or not cfg.uncategorized_namespaces[mw.title.getCurrentTitle().ns] then
            out = out .. '[[Category:' .. cfg.errors_category .. ']]'
        end
        return out
    end
    return ''
end

-- ===============================================================================
-- Return true if val is not nil and is a string in the array cfg.yes_values
-- ===============================================================================
local function return_yes_value(val)
    return val and  cfg.yes_values[mw.ustring.lower(val)]
end

-- ===============================================================================
-- Return true if val is not nil and is a string in the array cfg.no_value
-- ===============================================================================
local function return_no_value(val)
    return val and  cfg.no_values[mw.ustring.lower(val)]
end

-- ===============================================================================
-- Return an array of numbers splitting a string at ","
-- For localization purpose check for the presence of an alternative separator
-- and alternative symbol for decimal separator
-- ===============================================================================
local function numericArray(csv, default_empty)
    if not csv then return end
    if default_empty == nil then default_empty = 'x' end
    local list = {}
    -- check for local separator character instead of ","
    if mw.ustring.find(csv, cfg.separator.list) then
        list = mw.text.split(mw.ustring.gsub(csv, "%s", ""), cfg.separator.list)
        for index,v in ipairs(list) do
            list[index] = mw.ustring.gsub(v, cfg.separator.decimal, ".")
        end
    else
        list = mw.text.split(mw.ustring.gsub(csv, "%s", ""), ",")
    end
    -- build output array replacing empty value with a "x"
    local result = {}
    for i, val in ipairs(list) do
        if val == '' then
            result[i] = 'x'
        else
            result[i] = tonumber(val)
        end
    end
    return result
end

-- ===============================================================================
-- Return an array of string splitting at ","
-- ===============================================================================
local function stringArray(csv)
    if not csv then return end
    local t = {}
    for s in mw.text.gsplit(csv, ",") do
        t[#t+1] = mw.text.trim(s)
    end
    return t
end


-- ==============================================================================
-- Return true if t is a table
-- ===============================================================================
local function isTable(t)
    return type(t) == "table"
end

-- ==============================================================================
-- Extend table replicating content
-- ==============================================================================
local function extend_table(t, new_len)
    if #t >= new_len then return t end
    local pos = 1
    local old_len = #t
    for i = #t+1, new_len do
        t[i] = t[pos]
        pos = pos + 1
        if pos > old_len then pos = 1 end
    end
    return t
end

-- ==============================================================================
-- Generate a color palette from the name (must be in cfg.colors_palette)
-- with minimal length len
-- ==============================================================================
local function generate_color_palette(palette, len)
    if not cfg.colors_palette[palette] then
        colors_palette = "category10"
    end
    local palette_len, color_palette = cfg.colors_palette[palette][1],cfg.colors_palette[palette][2]
    local t = {}
    local pos = 1
    for i = 1, palette_len do
        t[i] = color_palette[pos]
        pos = pos + 1
        if pos > palette_len then pos = 1 end
    end
    --t = extend_table(palette, len or 1)
    return t
end

-- ===================================================================================
-- Wrap the graph inside a div structure and add an optional legend extenal to the
-- graph tag
-- ===================================================================================
local function wrap_graph(graph, legend, align, width)
    local html = mw.html.create('div'):addClass('thumb')
    if align then
        html:addClass('t' .. align)
    else
        html:css('display', 'inline-block')
    end

    html:tag('div')
        :addClass('thumbinner')
        :tag('div')
            :wikitext(graph)
            :done()
        :tag('div')
            :node(legend)
            :css('width', tostring(width) .. 'px')
    return tostring(html)
end

-- ===================================================================================
-- Build a legend item joining a color box with text
-- ===================================================================================
local function legend_item(color, text)
    local item = mw.html.create('p'):cssText('margin:0px;font-size:100%;text-align:left')
    item:tag('span'):cssText(string.format('border:none;background-color:%s;color:%s;', color, color)):wikitext("██")
    item:wikitext(string.format("&nbsp;%s", text))
    return item
end

-- ===================================================================================
-- Build a legend
-- ===================================================================================
local function build_legend(colors, labels, title, ncols)
    local legend = mw.html.create('div'):addClass('thumbcaption'):css('text-align', 'center')
    legend:wikitext(title or '')
    local legend_list= mw.html.create('div')
    local cols = tonumber(ncols or "1")
    if cols>1 then
        local col_string = tostring(cols)
        legend_list
            :css('-moz-column-count', col_string)
            :css('-webkit-column-count', col_string)
            :css('column-count:', col_string)
    end
    if not isTable(colors) then
        colors = generate_color_palette(colors, #labels)
    end
    for i,label in ipairs(labels) do
        legend_list:node(legend_item(colors[i], label))
    end
    legend:node(legend_list)
    return legend
end

-- ===================================================================================
-- Return the json code to build a pie chart
-- ===================================================================================
function p.pie_chart_json(args)
    local data = {}
    for pos,x in ipairs(args.values) do
        data[pos] = { x = x, color = args.colors[pos] }
    end
    local graph = {
        name = args.name,
        width = math.floor(args.graphwidth / 3),
        height = math.floor(args.graphwidth / 3),
        data = {
            {
                name = "table",
                values = data,
                transform = { { type = "pie", value = "data.x" } }
            }
        },
        marks = {
            {
                type = "arc",
                from = { data = "table"},
                properties = {
                    enter = {
                        x = { field = "data.x", group = "width", mult = 0.5 },
                        y = { field = "data.x", group = "height", mult = 0.5 },
                        startAngle = { field = "startAngle"},
                        endAngle = {field = "endAngle"},
                        innerRadius = {value = args.inner_radius},
                        outerRadius = {value = args.outer_radius },
                        stroke = {value = "#fff"},
                    },
                    update = { fill = { field = "data.color"} },
                    hover = { fill = {value = "pink"} }
                },
             }
        }
    }
    local flags = return_yes_value(args[cfg.localization.debug_json]) and mw.text.JSON_PRETTY
    return mw.text.jsonEncode(graph, flags)
end

-- ===================================================================================
-- Interface function for template:pie_chart
-- ===================================================================================
function p.pie_chart(frame)
    local args = getArgs(frame, {parentOnly = true})
    local arguments = { }
    arguments.name = args[cfg.localization.name] or 'grafico a torta'
    arguments.values = numericArray(args[cfg.localization.values], 0)
    -- get marks colors, use default category10 palette as default,
    -- if colors is not the name of a palette then read it as an array of colors
    arguments.colors = args[cfg.localization.colors] or "category10"
    if not cfg.colors_palette[arguments.colors] then
        arguments.colors = stringArray(arguments.colors)
    else
        arguments.colors = generate_color_palette(arguments.colors)
    end
    arguments.labels = {}
    local index = 1
    label_string = cfg.localization.label
    if args[label_string] then
        arguments.labels[1] = args[label_string]
        index = 2
    end
    while true do
        if not args[label_string .. tostring(index)] then break end
        arguments.labels[index] = args[label_string .. tostring(index)]
        index = index + 1
    end
    -- Se è definito 'other' assumo che sia calcolato su base %, calcolo il suo valore e l'aggiungo alla tabella dati
    if args[cfg.localization.other] then
        local total = 0
        for _,val in ipairs(arguments.values) do total = total + val end
        if total > 0 and total < 100 then
            arguments.values[#arguments.values+1]= math.max(0, 100 - total)
            arguments.labels[#arguments.values] = "Altri"
        end
    end
    arguments.colors = extend_table(arguments.colors, #arguments.values)
    arguments.graphwidth = tonumber(args[cfg.localization.width]) or cfg.default.width_piechart
    arguments.outer_radius = arguments.graphwidth / 2 - 5
    if return_yes_value(args[cfg.localization.ring]) then
        arguments.inner_radius = arguments.outer_radius / 3
    else
        arguments.inner_radius = 0
    end
    arguments.legend = args[cfg.localization.internal_legend] or args[cfg.localization.external_legend] or 'Legenda'
    for pos,txt in ipairs(arguments.labels) do
        arguments.labels[pos] = txt .. ' (' .. mw.language.getContentLanguage():formatNum(arguments.values[pos] or 0) .. '%)'
    end
    local json_code = p.pie_chart_json(arguments)
    if args[cfg.localization.debug_json] then return frame:extensionTag('syntaxhighlight', json_code) end
    local external_legend
    if not return_no_value(arguments.legend) then
        external_legend = build_legend(arguments.colors, arguments.labels, arguments.legend, args[cfg.localization.nCols])
    end
    local chart = frame:extensionTag('graph', json_code)
    local align = args[cfg.localization.thumb]
    return wrap_graph(chart, external_legend, align, arguments.graphwidth ) .. errors_output(args.NoTracking)
end

-- ===================================================================================
-- Generate data structure for x and y axes
-- ===================================================================================
local function build_ax(args, ax_name)

    local ax = {
        type = ax_name,
        scale = ax_name,
        title = args[ax_name .. 'title'],
        format = args[ax_name .. 'format'],
        grid = args[ax_name .. 'grid'],
        layer = "back"
    }
    if isTable(args[ax_name .. 'AxisPrimaryTicks']) then
        ax.values = args[ax_name .. 'AxisPrimaryTicks']
    elseif args[ax_name .. 'nTicks'] then
        ax.ticks = args[ax_name .. 'nTicks']
    end
    if args[ax_name .. 'SecondaryTicks'] then ax.subdivide = args[ax_name .. 'SecondaryTicks'] end
    return ax
end

-- ===================================================================================
-- Return a json structure to generate a a line/area/bar chart
-- Imported and modified from en:Module:Chart revision 670068988 of 5 july 2015
-- ===================================================================================
function p.chart_json(args)
    -- build axes
    local x_ax = build_ax(args, 'x')
    local y_ax = build_ax(args, 'y')
    local axes = { x_ax, y_ax }

    -- create data tuples, consisting of series index, x value, y value
    local data = { name = "chart", values = {} }
    for i, yserie in ipairs(args.y) do
        for j = 1, math.min(#yserie, #args.x) do
            if yserie[j] ~= 'x' then data.values[#data.values + 1] = { series = args.seriesTitles[i], x = args.x[j], y = yserie[j] } end
        end
    end
    -- calculate statistics of data as stacking requires cumulative y values
    local stats
    if args.is_stacked then
        stats =
        {
            name = "stats", source = "chart", transform =
            {
                { type = "facet", keys = { "data.x" } },
                { type = "stats", value = "data.y" }
            }
        }
    end
    -- create scales
    local xscale =
    {
        name = "x",
        type = "linear",
        range = "width",
        zero = false, -- do not include zero value
        nice = true,  -- force round numbers for y scale
        ___domain = { data = "chart", field = "data.x" }
    }
    if args.xMin then xscale.domainMin = args.xMin end
    if args.xMax then xscale.domainMax = args.xMax end
    if args.xMin or args.xMax then xscale.clamp = true end
    if args.graph_type == "rect" or args.force_x_ordinal  then xscale.type = "ordinal" end

    local yscale =
    {
        name = "y",
        type = "linear",
        range = "height",
        -- area charts have the lower boundary of their filling at y=0 (see marks.properties.enter.y2), therefore these need to start at zero
        zero = args.graph_type ~= "line",
        nice = true
    }
    if args.yMin then yscale.domainMin = args.yMin end
    if args.yMax then yscale.domainMax = args.yMax end
    if args.yMin or args.yMax then yscale.clamp = true end
    if args.is_stacked then
        yscale.___domain = { data = "stats", field = "sum"  }
    else
        yscale.___domain = { data = "chart", field = "data.y" }
    end
    -- Color scale
    local colorScale = { name = "color", type = "ordinal", range = args.colors }
    local alphaScale
    if args.alphas then alphaScale = { name = "transparency", graph_type = "ordinal", range = args.alphas } end
    -- Symbols scale
    local symbolsScale
    if args.symbols then symbolsScale = { name = "symbols", type = "ordinal", range = args.symbols } end
     -- for bar charts with multiple series: each series is grouped by the x value, therefore the series need their own scale within each x group
    local groupScale
    if args.graph_type == "rect" and not is_stacked and #args.y > 1 then
        groupScale = { name = "series", type = "ordinal", range = "width", ___domain = { field = "data.series" } }
        xscale.padding = 0.2 -- pad each bar group
    end

    -- decide if lines (strokes) or areas (fills) should be drawn
    local colorField
    if args.graph_type == "line" then colorField = "stroke" else colorField = "fill" end

    -- create chart markings
    local marks =
    {
        type = args.graph_type,
        properties =
        {
            -- chart creation event handler
            enter =
            {
                x = { scale = "x", field = "data.x" },
                y = { scale = "y", field = "data.y" },

            },
            -- chart update event handler
            update = { },
            -- chart hover event handler
            hover = { }
        }
    }
    marks.properties.update[colorField] = { scale = "color" }
    marks.properties.hover[colorField] = { value = "red" }
    if alphaScale then marks.properties.update[colorField .. "Opacity"] = { scale = "transparency" } end
    -- for bars and area charts set the lower bound of their areas
    if args.graph_type == "rect" or args.graph_type == "area" then
        if args.is_stacked then
            -- for stacked charts this lower bound is cumulative/stacking
            marks.properties.enter.y2 = { scale = "y", field = "y2" }
        else
            --[[
            for non-stacking charts the lower bound is y=0
            TODO: "yscale.zero" is currently set to "true" for this case, but "false" for all other cases.
            For the similar behavior "y2" should actually be set to where y axis crosses the x axis,
            if there are only positive or negative values in the data ]]
            marks.properties.enter.y2 = { scale = "y", value = 0 }
        end
    end
    -- for bar charts ...
    if args.graph_type == "rect" then
        -- set 1 pixel width between the bars
        marks.properties.enter.width = { scale = "x", band = true, offset = -1 }
        -- for multiple series the bar marking need to use the "inner" series scale, whereas the "outer" x scale is used by the grouping
        if not args.is_stacked and #args.y > 1 then
            marks.properties.enter.x.scale = "series"
            marks.properties.enter.x.field = "data.series"
            marks.properties.enter.width.scale = "series"
        end
    end
    if args.graph_type == "line" then marks.properties.enter.strokeWidth = { value = args.stroke_thickness }  end
    -- stacked charts have their own (stacked) y values
    if args.is_stacked then marks.properties.enter.y.field = "y" end
    -- set interpolation mode
    if args.interpolate then marks.properties.enter.interpolate = { value = args.interpolate } end
    local symbolsMarks
    if symbolsScale then
        symbolsMarks = {
            type = "symbol",
            from = { data = "chart" },
            properties = {
                enter =
                    {
                        x = { scale = "x", field = "data.x" },
                        y = { scale = "y", field = "data.y" },
                        shape = { scale = "symbols" },
                    },
                update = { stroke = { scale = "color"} }
            }
        }
        if args.symbol_size then symbolsMarks.properties.enter.size = { value = args.symbol_size } end
    end
    if #args.y == 1 then
        marks.from = { data = "chart" }
        marks = { marks, symbolsMarks }
    else
        -- if there are multiple series, connect colors to series
        if args.graph_type == "rect" and args.colorsByGroup then
            marks.properties.update[colorField].field = "data.x"
        else
            marks.properties.update[colorField].field = "data.series"
        end
        if symbolsScale then
            symbolsMarks.properties.enter.shape.field = "data.series"
            symbolsMarks.properties.update.stroke.field = "data.series"
        end
        if alphaScale then marks.properties.update[colorField .. "Opacity"].field = "data.series" end

        -- apply a grouping (facetting) transformation
        marks =
        {
            type = "group",
            marks = { marks, symbolsMarks },
            from =
            {
                data = "chart",
                transform =
                {
                    {
                        type = "facet",
                        keys = { "data.series" }
                    }
                }
            }
        }
        -- for stacked charts apply a stacking transformation
        if args.is_stacked then
            marks.from.transform[2] = { type = "stack", point = "data.x", height = "data.y" }
        else
            -- for bar charts the series are side-by-side grouped by x
            if args.graph_type == "rect" then
                marks.from.transform[1].keys = "data.x"
                marks.scales = { groupScale }
                marks.properties = { enter = { x = { field = "key", scale = "x" }, width = { scale = "x", band = true } } }
            end
        end
        marks = { marks }
    end

    -- create legend
    local legend
    if args.internal_legend then
        legend = { { fill = "color", stroke = "color", title = args.internal_legend } }
    end

    -- build final output object
    local scales =  { xscale, yscale, colorScale}
    if alphaScale then scales[#scales+1] = alphaScale end
    if symbolsScale then scales[#scales+1] = symbolsScale end
    local output =
    {
        width = args.graphwidth,
        height = args.graphheight,
        data = { data, stats },
        scales = scales,
        axes =  axes,
        marks =  marks ,
        legends = legend
    }
    local flags = return_yes_value(args[cfg.localization.debug_json]) and mw.text.JSON_PRETTY
    return mw.text.jsonEncode(output, flags)
end

-- ===================================================================================
-- Interface function for template:Grafico a linee
-- ===================================================================================
function p.chart(frame)

    -- Read ax arguments
    local function read_ax_arguments(args, ax_name, arguments)
        arguments[ax_name .. 'title'] = args[cfg.localization[ax_name .. 'AxisTitle']]
        arguments[ax_name .. 'format'] = args[cfg.localization[ax_name .. 'AxisFormat']]
        local grid = cfg.default[ax_name .. 'Grid']
        if grid then
           grid = not return_no_value(args[cfg.localization[ax_name .. 'Grid']])
        else
            grid = return_yes_value(args[cfg.localization[ax_name .. 'Grid']])
        end
        arguments[ax_name .. 'grid'] = grid
        arguments[ax_name .. 'AxisPrimaryTicks'] = numericArray(args[cfg.localization[ax_name .. 'AxisPrimaryTicks']])
        arguments[ax_name.. 'nTicks'] =  tonumber(args[cfg.localization[ax_name .. 'AxisPrimaryTicksNumber']])
        arguments[ax_name .. 'SecondaryTicks'] = tonumber(args[cfg.localization[ax_name .. 'AxisSecondaryTicks']])
        arguments[ax_name ..'Min'] = tonumber(args[cfg.localization[ax_name .. 'AxisMin']])
        arguments[ax_name ..'Max'] = tonumber(args[cfg.localization[ax_name .. 'AxisMax']])
    end

    -- get graph type
    local function get_graph_type(graph_string)
        if graph_string == nil then return "line", false end
        local graph_type = cfg.graph_type[mw.ustring.lower(graph_string)]
        if graph_type then return graph_type[1], graph_type[2] end
        add_error('type_unknown', {graph_string})
    end

    local args = getArgs(frame, {parentOnly = true})
    -- analyze and build data to build the chart
    local arguments = { }
    arguments.graphwidth = tonumber(args[cfg.localization.width]) or cfg.default.width
    arguments.graphheight = tonumber(args[cfg.localization.height]) or cfg.default.height
    arguments.graph_type, arguments.is_stacked = get_graph_type(args[cfg.localization.type])
    arguments.interpolate = args[cfg.localization.interpolate]
    if arguments.interpolate and not cfg.interpolate[arguments.interpolate] then
        add_error('value_not_valid', {cfg.localization.interpolate, arguments.interpolate})
        interpolate = nil
    end
    -- get marks symbols, default symbol is used if the type of graph is line, otherwise the default
    -- is not to use symbol.
    if arguments.graph_type == "line" and not return_no_value(args[cfg.localization.symbols]) then
        arguments.symbols = stringArray(args[cfg.localization.symbols]) or cfg.default.symbol
        arguments.symbol_size = tonumber(args[cfg.localization.symbolSize]) or cfg.default.symbol_size
    end
    if arguments.graph_type =="line" then
        arguments.stroke_thickness =  tonumber(args[cfg.localization.strokeThickness]) or cfg.default.stroke_thickness
    end
    -- show legend, optionally caption
    arguments.internal_legend = args[cfg.localization.internal_legend]
    -- get x values
    arguments.x = numericArray(args.x)
    arguments.force_x_ordinal = false
    if #arguments.x == 0 then
        arguments.force_x_ordinal = true
    else
        for _,val in ipairs(arguments.x) do
            if val == 'x' then
                arguments.force_x_ordinal = true
                break
            end
        end
    end
    if arguments.force_x_ordinal then arguments.x = stringArray(args.x) end
    -- get y values (series)
    arguments.y = {}
    local index = 1
    arguments.seriesTitles = {}
    if args.y then
        arguments.y[1] = numericArray(args.y)
        arguments.seriesTitles[1] = args[string.gsub(cfg.localization.yTitle, '#', '')] or args[string.gsub(cfg.localization.yTitle, '#', '1')] or "y"
        index = 2
    end
    while true do
        if not args['y'..tostring(index)] then break end
        arguments.y[index] = numericArray(args['y'..tostring(index)])
        arguments.seriesTitles[index] = args[string.gsub(cfg.localization.yTitle, '#', tostring(index))] or ('y' .. tostring(index))
        index = index + 1
    end
    -- ignore stacked charts if there is only one series
    if #arguments.y == 1 then arguments.is_stacked = false end
    -- read axes arguments
    read_ax_arguments(args, 'x', arguments)
    read_ax_arguments(args, 'y', arguments)
    -- get marks colors,  default palette is category10,
    -- if colors is not the name of a predefined palette then read it as an array of colors
    arguments.colors = args[cfg.localization.colors] or "category10"
    if not cfg.colors_palette[arguments.colors] then
        arguments.colors = stringArray(arguments.colors)
    elseif arguments.colors ~="category10" and arguments.colors ~="category20" then
        arguments.colors = generate_color_palette(arguments.colors)
    end
    -- assure that colors, stroke_thickness and symbols table are at least the same lenght that the number of
    -- y series
    if isTable(arguments.colors) then arguments.colors = extend_table(arguments.colors, #arguments.y) end
    if isTable(arguments.stroke_thickness) then arguments.stroke_thickness = extend_table(arguments.stroke_thickness, #arguments.y) end
    if isTable(arguments.symbols) then arguments.symbols = extend_table(arguments.symbols, #arguments.y) end
    -- if there is at least one color in the format "#aarrggbb", create a transparency (alpha) scale
    if isTable(arguments.colors) then
        alphas = {}
        local hasAlpha = false
        for i, color in ipairs(arguments.colors) do
            local a, rgb = string.match(color, "#(%x%x)(%x%x%x%x%x%x)")
            if a then
                hasAlpha = true
                alphas[i] = tostring(tonumber(a, 16) / 255.0)
                arguments.colors[i] = "#" .. rgb
            else
                alphas[i] = "1"
            end
        end
        for i = #arguments.colors + 1, #arguments.y do alphas[i] = "1" end
        if hasAlpha then arguments.alphas = alphas end
    elseif args[cfg.localization.alpha] then
        arguments.alphas = stringArray(args[cfg.localization.alpha])
        if arguments.alphas then
            for i,a in ipairs(arguments.alphas) do arguments.alphas[i] = tostring(tonumber(a, 16) / 255.0) end
            arguments.alphas = extend_table(arguments.alphas, #arguments.y)
        end
    end
    arguments.colorsByGroup = return_yes_value(args[cfg.localization.colorsByGroup])
    local chart_json = p.chart_json(arguments)
    if args[cfg.localization.debug_json] then return frame:extensionTag('syntaxhighlight', chart_json) end
    local external_legend
    if args[cfg.localization.external_legend] then
        external_legend = build_legend(arguments.colors, arguments.seriesTitles, args[cfg.localization.external_legend],
                                       args[cfg.localization.nCols])
    end
    local chart = frame:extensionTag('graph', chart_json)
    local align = args[cfg.localization.thumb]
    return wrap_graph(chart, external_legend, align, arguments.graphwidth) .. errors_output(args.NoTracking)
end

return p