Module:Graph: Difference between revisions

From Guild of Archivists
No edit summary
 
m (1 revision imported)
 

Latest revision as of 07:26, 30 October 2015

Documentation for this module may be created at Module:Graph/doc

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<number>". Can be overwritten using the "y<number>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