Modulo:Graph

Versione del 17 ago 2020 alle 15:39 di VulpesVulpes825 (discussione | contributi) (Upgrade Graph syntax to Vega 2.0)

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

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


local p = {}
local cfg = mw.loadData( 'Module:Graph/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

-- ===============================================================================
-- Fix CSS per mostrare le immagini su mobile anche in modalità lazy load (T216431)
-- ===============================================================================
local function loadCSS()
	local styles = 'Modulo:Graph/styles.css'
	return mw.getCurrentFrame():extensionTag{
			name = 'templatestyles',
			args = {src = styles}
		}
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(loadCSS() .. 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 = {
		version = 2,
		name = args.name,
		width = math.floor(args.graphwidth / 3),
		height = math.floor(args.graphwidth / 3),
		data = {
			{
				name = "table",
				values = data,
				transform = { { type = "pie", value = "x" } }
			}
		},
		marks = {
			{
				type = "arc",
				from = { data = "table"},
				properties = {
					enter = {
						x = { field = "x", group = "width", mult = 0.5 },
						y = { field = "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 = "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 = "aggregate", source = "chart", transform =
			{
				{ type = "facet", groupby = { "x" } },
				{ type = "aggregate", value = "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 = "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 = "aggregate", field = "sum"  }
	else
		yscale.___domain = { data = "chart", field = "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 args.is_stacked and #args.y > 1 then
		groupScale = { name = "series", type = "ordinal", range = "width", ___domain = { field = "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 = "x" },
				y = { scale = "y", field = "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 = "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 = "x" },
						y = { scale = "y", field = "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 = "x"
		else
			marks.properties.update[colorField].field = "series"
		end
		if symbolsScale then
			symbolsMarks.properties.enter.shape.field = "series"
			symbolsMarks.properties.update.stroke.field = "series"
		end
		if alphaScale then marks.properties.update[colorField .. "Opacity"].field = "series" end

		-- apply a grouping (facetting) transformation
		marks =
		{
			type = "group",
			marks = { marks, symbolsMarks },
			from =
			{
				data = "chart",
				transform =
				{
					{
						type = "facet",
						groupby = { "series" }
					}
				}
			}
		}
		-- for stacked charts apply a stacking transformation
		if args.is_stacked then
			marks.from.transform[2] = { type = "stack", point = "x", height = "y" }
		else
			-- for bar charts the series are side-by-side grouped by x
			if args.graph_type == "rect" then
				marks.from.transform[1].groupby = "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 =
	{
		version = 2,
		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