Module:Graph

local p = {}

local baseMapDirectory = "Module:Graph/"

local function numericArray(csv) if not csv then return end local list = mw.text.split(mw.ustring.gsub(csv, "%s", ""), ",") local result = {} for i = 1, #list do		result[i] = tonumber(list[i]) end return result end

local function stringArray(csv) if not csv then return end return mw.text.split(mw.ustring.gsub(csv, "%s", ""), ",") end

local function isTable(t) return type(t) == "table" end

function p.map(frame) -- map path data for geographic objects local basemap = frame.args.basemap or "WorldMap-iso2.json" -- scaling factor local scale = tonumber(frame.args.scale) or 100 -- map projection, see https://github.com/mbostock/d3/wiki/Geo-Projections local projection = frame.args.projection or "equirectangular" -- defaultValue for geographic objects without data local defaultValue = frame.args.defaultValue local scaleType = frame.args.scaleType or "linear" -- minimaler Wertebereich (nur für numerische Daten) local domainMin = tonumber(frame.args.domainMin) -- maximaler Wertebereich (nur für numerische Daten) local domainMax = tonumber(frame.args.domainMax) -- Farbwerte der Farbskala (nur für numerische Daten) local colorScale = frame.args.colorScale or "category10" -- show legend local legend = frame.args.legend -- format JSON output local formatJSON = frame.args.formatjson -- map data are key-value pairs: keys are non-lowercase strings (ideally ISO codes) which need to match the "id" values of the map path data local values = {} local isNumbers = nil for name, value in pairs(frame.args) do		if mw.ustring.find(name, "^[^%l]+$") then if isNumbers == nil then isNumbers = tonumber(value) end local data = { id = name, v = value } if isNumbers then data.v = tonumber(data.v) end table.insert(values, data) end end if not defaultValue then if isNumbers then defaultValue = 0 else defaultValue = "silver" end end

-- create highlight scale local scales if isNumbers then if colorScale == "category10" or colorScale == "category20" then else colorScale = stringArray(colorScale) end scales = {			{				name = "color", type = scaleType, domain = { data = "highlights", field = "data.v" }, range = colorScale, nice = true }		}		if domainMin then scales[1].domainMin = domainMin end if domainMax then scales[1].domainMax = domainMax end

local exponent = string.match(scaleType, "pow%s+(%d+%.?%d+)") -- check for exponent if exponent then scales[1].type = "pow" scales[1].exponent = exponent end end

-- create legend if legend then legend = {			{				fill = "color", properties = {					title = { fontSize = { value = 14 } }, labels = { fontSize = { value = 12 } }, legend = {						stroke = { value = "silver" }, strokeWidth = { value = 1.5 } }				}			}		}	end

-- get map url local basemapUrl if (string.sub(basemap, 1, 7) == "http://") or (string.sub(basemap, 1, 8) == "https://") or (string.sub(basemap, 1, 2) == "//") then basemapUrl = basemap else -- if not a (supported) url look for a colon as namespace separator. If none prepend default map directory name. if not string.find(basemap, ":") then basemap = baseMapDirectory .. basemap end basemapUrl = mw.title.new(basemap):fullUrl("action=raw") end

local output = {		width = 1, -- generic value as output size depends solely on map size and scaling factor height = 1, -- ditto data = {			{				-- data source for the highlights name = "highlights", values = values },			{				-- data source for map paths data name = "countries", url = basemapUrl, format = { type = "topojson", feature = "countries" }, transform = {					{						-- geographic transformation ("geopath") of map paths data type = "geopath", value = "data",			-- data source scale = scale, translate = { 0, 0 }, projection = projection },					{						-- join ("zip") of mutiple data source: here map paths data and highlights type = "zip", key = "data.id",    -- key for map paths data with = "highlights", -- name of highlight data source withKey = "data.id", -- key for highlight data source as = "zipped",      -- name of resulting table default = { data = { v = defaultValue } } -- default value for geographic objects that could not be joined }				}			}		},		marks = {			-- output markings (map paths and highlights) {				type = "path", from = { data = "countries" }, properties = {					enter = { path = { field = "path" } }, update = { fill = { field = "zipped.data.v" } }, hover = { fill = { value = "darkgrey" } } }			}		},		legends = legend }	if (scales) then output.scales = scales output.marks[1].properties.update.fill.scale = "color" end

local flags if formatJSON then flags = mw.text.JSON_PRETTY end return mw.text.jsonEncode(output, flags) end

function p.chart(frame) -- chart width local graphwidth = tonumber(frame.args.width) -- chart height local graphheight = tonumber(frame.args.height) -- chart type local type = frame.args.type or "line" -- interpolation mode: linear, step-before, step-after, basis, basis-open, basis-closed (type=line only), bundle (type=line only), cardinal, cardinal-open, cardinal-closed (type=line only), monotone local interpolate = frame.args.interpolate -- mark colors (if no colors are given, the default 10 color palette is used) local colors = stringArray(frame.args.colors) or "category10" -- x and y axis caption local xTitle = frame.args.xAxisTitle local yTitle = frame.args.yAxisTitle -- override x and y axis minimum and maximum local xMin = tonumber(frame.args.xAxisMin) local xMax = tonumber(frame.args.xAxisMax) local yMin = tonumber(frame.args.yAxisMin) local yMax = tonumber(frame.args.yAxisMax) -- override x and y axis label formatting local xFormat = frame.args.xAxisFormat local yFormat = frame.args.yAxisFormat -- show legend, optionally caption local legend = frame.args.legend -- format JSON output local formatJSON = frame.args.formatjson

-- get x values local x = numericArray(frame.args.x)

-- get y values (series) local y = {} local seriesTitles = {} for name, value in pairs(frame.args) do		local yNum if name == "y" then yNum = 1 else yNum = tonumber(string.match(name, "y(%d+)$")) end if yNum then y[yNum] = numericArray(value) -- name the series: default is "y ". Can be overwritten using the "y Title" parameters. seriesTitles[yNum] = frame.args["y" .. yNum .. "Title"] or name end end

-- create data tuples, consisting of series index, x value, y value local data = { name = "chart", values = {} } for i = 1, #y do		for j = 1, #x do			if j <= #y[i] then data.values[#data.values + 1] = { series = seriesTitles[i], x = x[j], y = y[i][j] } end end end -- use stacked charts local stacked = false local stats if string.sub(type, 1, 7) == "stacked" then type = string.sub(type, 8) if #y > 1 then -- ignore stacked charts if there is only one series stacked = true -- calculate statistics of data as stacking requires cumulative y values stats = {				name = "stats", source = "chart", transform = {					{ type = "facet", keys = { "data.x" } }, { type = "stats", value = "data.y" } }		}		end 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 type == "rect" 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 = 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 stacked then yscale.domain = { data = "stats", field = "sum" } else yscale.domain = { data = "chart", field = "data.y" } end 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 = 1, #colors do			local a, rgb = string.match(colors[i], "#(%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", type = "ordinal", range = alphas } end 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 type == "rect" and not 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 type == "line" then colorField = "stroke" else colorField = "fill" end -- create chart markings local marks = {		type = 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 type == "rect" or type == "area" then if 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 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 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 stacked then marks.properties.enter.y.field = "y" end -- set interpolation mode if interpolate then marks.properties.enter.interpolate = { value = interpolate } end if #y == 1 then marks.from = { data = "chart" } else -- if there are multiple series, connect colors to series marks.properties.update[colorField].field = "data.series" if alphaScale then marks.properties.update[colorField .. "Opacity"].field = "data.series" end -- apply a grouping (facetting) transformation marks = {			type = "group", marks = { marks }, from = {				data = "chart", transform = {					{						type = "facet", keys = { "data.series" } }				}			}		}		-- for stacked charts apply a stacking transformation if 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 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 end

-- create legend if legend then legend = {			{				fill = "color", stroke = "color", title = legend }		}	end

-- construct final output object local output = {		width = graphwidth, height = graphheight, data = { data, stats }, scales = { xscale, yscale, colorScale, alphaScale }, axes = {			{				type = "x", scale = "x", title = xTitle, format = xFormat },			{				type = "y", scale = "y", title = yTitle, format = yFormat }		},		marks = { marks }, legends = legend }

local flags if formatJSON then flags = mw.text.JSON_PRETTY end return mw.text.jsonEncode(output, flags) end

function p.mapWrapper(frame) return p.map(frame:getParent) end

function p.chartWrapper(frame) return p.chart(frame:getParent) end

return p