Vai al contenuto

Modulo:Graph/sandbox

Da Wikipedia, l'enciclopedia libera.
local p = {}
local cfg = mw.loadData( 'Modulo:Chart/sandbox/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 to errors list
-- ===============================================================================
local function add_error(msg, ...)
    if arg then
        errors[#errors+1] = mw.ustring.format(msg, ...)
    else
        errors[#errors+1] = msg
    end
end

-- ===============================================================================
-- Consolidate errors message 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 value comprise between axes_min and axes_max
-- ===============================================================================
local function return_axis_thick_values(axes_min, axes_max, thick_interval)
    local thick_values = {}
    local thick = axes_min + thick_interval
    while thick < axes_min do
        thick_values[#thick_values+1] = thick
        thick = thick + thick_interval
    end   
end

-- ===============================================================================
-- Return an array of numbers splitting a string at ","
-- Check for the presence of an alternative separator and alternative
-- symbol for decimal separator
-- ===============================================================================
local function numericArray(csv)
    if not csv then return end
    local list = {}
    -- se csv contiene un ";" assume che i valori siano separati da ";" e il separatore decimale è "."
    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
    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)
-- ==============================================================================
local function generate_color_palette(palette, len)
    if not cfg.colors_palette[palette] then return nil end
    local palette_len, color_palette = cfg.colors_palette[palette][1],cfg.colors_palette[palette][2]
    local t = {}
    local pos = 1
    for i = 1, len do
        t[i] = color_palette[pos]
        pos = pos + 1
        if pos > palette_len  then pos = 1 end
    end
    return t
end

local function wrap_graph(align, graph, legend)
    local html = mw.html.create('div')
        :addClass(string.format('thumb t%s', align or 'right'))     
    html:tag('div')
        :addClass('thumbinner')
            :wikitext(graph)
        --:cssText(string.format('width:%s', tostring(width)))
        --:tag('div')
        --    :cssText("background-color:white;margin:auto;position:relative;width:200px;height:200px;overflow:hidden")
        --    :wikitext(frame:extensionTag( 'graph', mw.text.jsonEncode(graph) ))
        --    :done()
        :node(legend)
    return tostring(html)
end

-- ===============================================================================
-- Costruisce una struttura dati da codificare in json per realizzare un grafico
-- a torta visualizzabile utilizzando il tag graph
-- https://www.mediawiki.org/wiki/Extension:Graph
-- i nomi dei parametri sono localizzati in base a chart/Config
--
-- ===============================================================================
function build_pie_chart_json(data, args)
  local graph = {
        name = args[cfg.localization.name] or 'grafico a torta',
        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 = 0},
                        outerRadius = {value = tonumber(args[cfg.localization.radius]) or cfg.default.radius },
                        stroke = {value = "#fff"},                       
                    },
                    update = { fill = { field = "data.color"} },
                    hover = { fill = {value = "red"} }
                },
             }
        }
    }
    if args[cfg.localization.show_label] then
        graph.marks[#graph.marks+1] = {type = "text",
                from = { data = "table"},
                properties = {  
                    enter = {
                        x = { field = "data.x", group = "width", mult = 0.5 },
                        y = { field = "data.x", group = "height", mult = 0.5 },
                        radius = { value = tonumber(args[cfg.localization.radius]) or cfg.radius_default, offset = 8 },
                        theta = { field = "midAngle"},
                        fill = { value = "#000" },
                        align = { value = "center" },
                        baseline = { value = "middle" },
                        text = { field = "data.label"}
                    }
                }
            }
    end
    return graph
end


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

-- OLD da rivedere per integrazione nella pie_chart
--local function build_legend(data, args)
--   local legend = mw.html.create('div'):addClass('thumbcaption')
--    legend:wikitext(args[cfg.localization.caption] or '')
--    for _,datum in ipairs(data) do
--        legend:node(legend_item(datum.color, mw.ustring.format('%s (%s %%)', datum.label, mw.getContentLanguage():formatNum(datum.x))))
--    end
--    return legend
--end

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')
    if ncols and tonumber(ncols)>1 then
        legend_list
            :css('-moz-column-count', ncols)
            :css('-webkit-column-count', ncols)
            :css('column-count:', ncols)
    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

local function serie_min(serie)
    local minimum
    for index,value in ipairs(serie) do
        if index == 1 or value < minimum then
            minimum = value
        end
    end      
    return minimum
end

local function serie_max(serie)
    local maximum
    for index,value in ipairs(serie) do
        if index == 1 or value > maximum then
            maximum = value
        end
    end      
    return maximum
end

function p.pie_chart(frame)
    args = getArgs(frame, {parentOnly = true})
    local value_string = cfg.localization.value
    local label_string = cfg.localization.label
    local color_string = cfg.localization.color
    local index = 1
    local data = {}
    local colors = {}
    local labels = {}
    while true do
        local index_s = tostring(index)
        local value = tonumber(args[value_string .. index_s])
        if  value then
            data[index] = {
                x = value,
                label = args[label_string .. index_s] or '',
                color = args[color_string .. index_s] or
                        cfg.colors_palette.category10[index] or
                        cfg.colors_palette.category10[#cfg.colors_palette.category10]
            }
            index = index + 1
        else
            break
        end
    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 _,datum in ipairs(data) do
            total = total + datum.x
        end
        local other_value = math.max(0, 100 - total)
        data[index] = {
            x = other_value,
            label = args[cfg.localization.label_other] or 'altri',
            color = args[cfg.localization.color_other] or
                        cfg.colors_palette.category10[index] or
                        cfg.colors_palette.category10[#cfg.colors_palette.category10]
        }
    end
    if #data == 0 then
        return ''
    end
    local graph = build_pie_chart_json(data, args)
    local json
    -- se il parametro debug_json è stato valorizzato ritorna il codice json generato invece di generare il grafico
    if args[cfg.localization.debug_json] then
        return  frame:extensionTag('syntaxhighlight', mw.text.jsonEncode(graph, mw.text.JSON_PRETTY), {lang='json'})
    end
    local legend = build_legend(data, args)
    local html = mw.html.create('div')
        :addClass(string.format('thumb t%s', args[cfg.localization.thumb] or 'right'))     
    html:tag('div')
        :addClass('thumbinner')
        :cssText('width:202px')
        :tag('div')
            :cssText("background-color:white;margin:auto;position:relative;width:200px;height:200px;overflow:hidden")
            :wikitext(frame:extensionTag( 'graph', mw.text.jsonEncode(graph) ))
            :done()
        :node(legend)
    return html
end


local function build_ax(args, ax_name)

    local ax = {}
    local title = args[cfg.localization[ax_name .. 'AxisTitle']]
    local 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
    ax = {
        type = ax_name,
        scale = ax_name,
        title = title,
        format = format,
        grid = grid,
        layer = "back"
    }
    if args[cfg.localization[ax_name .. 'AxisPrimaryThick']] then
        ax.values = numericArray(args[cfg.localization[ax_name .. 'AxisPrimaryThick']])
    elseif args[cfg.localization[ax_name .. 'AxisPrimaryThickNumber']] then
        local n_thicks = tonumber(args[cfg.localization[ax_name .. 'AxisPrimaryThickNumber']])
        if n_thicks then
            args.thicks = n_thicks
        end
    end
    if args[cfg.localization[ax_name .. 'AxisSecondaryThick']] then
        local secondary_thick = tonumber(args[cfg.localization[ax_name .. 'AxisSecondaryThick']])
        if secondary_thick then
            ax.subdivide = secondary_thick
        end
    end
    return ax
end

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("tipo di grafico %s non riconosciuto", graph_type)
end

-- ===================================================================================
-- Imported and modified from en:Module:Chart revision 670068988 of 5 july 2015
-- ===================================================================================
function p._chart(args)
    local graphwidth = tonumber(args[cfg.localization.width]) or cfg.default.width
    local graphheight = tonumber(args[cfg.localization.height]) or cfg.default.height
    local graph_type, is_stacked = get_graph_type(args[cfg.localization.type])
    local interpolate = args[cfg.localization.interpolate]
    if interpolate and not cfg.interpolate[interpolate] then
        add_error('Valore per %s "%s" non valido', cfg.localization.interpolate, interpolate)
        interpolate = nil
    end
    -- 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
    local colors = args[cfg.localization.colors]
    if colors == nil then
        colors = "category10"
    elseif not cfg.colors_palette[colors] then
        colors = stringArray(colors)
    end
    -- get marks symbols, default symbol is used if the type of graph is line, otherwise the default
    -- is not to use symbol.
    local symbols = args[cfg.localization.symbols] or cfg.default.symbol
    if graph_type ~= "line" or symbols == "no" then
        symbols = nil
    else
        symbols = stringArray(symbols)
    end
    local symbol_size = tonumber(args[cfg.localization.symbolSize]) or cfg.default.symbol_size
    local stroke_thickness
    if graph_type =="line" then
        stroke_thickness = numericArray(args[cfg.localization.strokeThickness])
    end
     -- override x and y axis minimum and maximum
    local xMin = tonumber(args[cfg.localization.xAxisMin])
    local xMax = tonumber(args[cfg.localization.xAxisMax])
    local yMin = tonumber(args[cfg.localization.yAxisMin])
    local yMax = tonumber(args[cfg.localization.yAxisMax])
    -- show legend, optionally caption
    local internal_legend = args[cfg.localization.internal_legend]
    -- get x values
    local x = numericArray(args.x)
    local force_x_ordinal = false
    if #x == 0 then
        force_x_ordinal = true
    else
        for _,val in ipairs(x) do
            if val == 'x' then force_x_ordinal = true end
            break
        end
    end
    if force_x_ordinal then x = stringArray(args.x) end

    -- get y values (series)
    local y = {}
    local index = 1
    local seriesTitles = {}
    if args.y then
        y[1] = numericArray(args.y)
        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 args['y'..tostring(index)] then
            y[index] = numericArray(args['y'..tostring(index)])
            seriesTitles[#seriesTitles+1] = args[string.gsub(cfg.localization.yTitle, '#', tostring(index))] or ('y' .. tostring(index))
            index = index + 1
        else
            break
        end
    end
    -- ignore stacked charts if there is only one series
    if #y == 1 then is_stacked = false end
    -- assure that colors, stroke_thickness and symbols table are at least the same lenght that the number of
    -- y series
    if isTable(colors) then colors = extend_table(colors, #y) end
    if isTable(stroke_thickness) then stroke_thickness = extend_table(stroke_thickness, #y) end
    if isTable(symbols) then symbols = extend_table(symbols, #y) end
    -- build axes
    x_ax = build_ax(args, 'x')
    y_ax = build_ax(args, 'y')
    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(y) do
        for j = 1, math.min(#yserie, #x) do
            if yserie[j] ~= 'x' then data.values[#data.values + 1] = { series = seriesTitles[i], x = x[j], y = yserie[j] } end
        end
    end
 
    -- calculate statistics of data as stacking requires cumulative y values
    local stats
    if 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 xMin then xscale.domainMin = xMin end
    if xMax then xscale.domainMax = xMax end
    if xMin or xMax then xscale.clamp = true end
    if graph_type == "rect" or 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 = graph_type ~= "line",
        nice = true
    }
    if yMin then yscale.domainMin = yMin end
    if yMax then yscale.domainMax = yMax end
    if yMin or yMax then yscale.clamp = true end
    if 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 = colors
    }
    local alphaScale
    -- if there is at least one color in the format "#aarrggbb", create a transparency (alpha) scale
    if isTable(colors) then
        local alphas = {}
        local hasAlpha = false
        for i, color in ipairs(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)
                colors[i] = "#" .. rgb
            else
                alphas[i] = "1"
            end
        end
        for i = #colors + 1, #y do alphas[i] = "1" end
        if hasAlpha then alphaScale = { name = "transparency", graph_type = "ordinal", range = alphas } end
    end
    if not alphaScale and args[cfg.localization.alpha] then
        alphas = stringArray(args[cfg.localization.alpha])
        if alphas then
            for i,a in ipairs(alphas) do alphas[i] = tostring(tonumber(a, 16) / 255.0) end
            alphas = extend_table(alphas, #y)
            alphaScale =  { name = "transparency", graph_type = "ordinal", range = alphas }
        end
    end
    -- Stroke width scale
    local strokeScale
    if stroke_thickness then strokeScale = { name = "stroke", type = "ordinal", range = stroke_thickness } end
    -- Symbols scale
    local symbolsScale
    if symbols then symbolsScale = { name = "symbols", type = "ordinal", range = 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 graph_type == "rect" and not is_stacked and #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 graph_type == "line" then colorField = "stroke" else colorField = "fill" end
 
    -- create chart markings
    local marks =
    {
        type = 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 = { }
        }
    }
    if strokeScale then
        marks.properties.enter.strokeWidth = { scale = "stroke"}
    end
    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 graph_type == "rect" or graph_type == "area" then
        if 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 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 is_stacked and #y > 1 then
            marks.properties.enter.x.scale = "series"
            marks.properties.enter.x.field = "data.series"
            marks.properties.enter.width.scale = "series"
        end
    end
    -- stacked charts have their own (stacked) y values
    if is_stacked then marks.properties.enter.y.field = "y" end
    -- set interpolation mode
    if interpolate then marks.properties.enter.interpolate = { value = 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" },
                    size = { value = symbol_size }  
                },
            update = { stroke = { scale = "color"} }  
            }
        }
    end
    if #y == 1 then     
        marks.from = { data = "chart" }
        marks = { marks, symbolsMarks }
    else
        -- if there are multiple series, connect colors to series
        if graph_type == "rect" and return_yes_value(args[cfg.localization.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 strokeScale then marks.properties.enter.strokeWidth.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 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 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
    if internal_legend then
        legend =
        {
            {
                fill = "color",
                stroke = "color",
                title = internal_legend
            }
        }
    end

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

function p.chart(frame)
    local args = getArgs(frame, {parentOnly = true})
    local chart_json, external_legend, colors, labels = p._chart(args)
    local legend
    if external_legend then
        legend = build_legend(colors, labels, external_legend, args[cfg.localization.nCols])
    end
    if args[cfg.localization.debug_json] then
        return  frame:extensionTag('syntaxhighlight', chart_json)
    end
    local chart= frame:extensionTag('graph', chart_json)
    local align = args[cfg.localization.thumb]
    return wrap_graph(align, chart, legend) .. errors_output(args.NoTracking)
end

return p