Module:Graph: Difference between revisions

Content deleted Content added
updated graphs to vega 2.0
updated to latest version from w:de:Module:Graph
Line 1:
-- version 2016-01-06 _PLEASE UPDATE when modifying anything_
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", ""), "*,%s*")
local result = {}
local isInteger = true
for i = 1, #list do
result[i] = tonumber(list[i])
if not result[i] then return end
if isInteger then
local int, frac = math.modf(result[i])
isInteger = frac == 0.0
end
end
return result, isInteger
end
 
local function stringArray(csv)
if not csv then return end
 
return mw.text.split(mw.ustring.gsub(csv, "%s", ""), "*,%s*")
end
 
local function isTable(t) return type(t) == "table" end
 
function p.map(frame)
-- map path data for geographic objects
Line 41 ⟶ 48:
local legend = frame.args.legend
-- format JSON output
local formatJSONformatJson = 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 = {}
Line 57 ⟶ 64:
if isNumbers then defaultValue = 0 else defaultValue = "silver" end
end
 
-- create highlight scale
local scales
Line 74 ⟶ 81:
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
Line 81 ⟶ 88:
end
end
 
-- create legend
if legend then
Line 88 ⟶ 95:
{
fill = "color",
offset = 120,
properties =
{
Line 101 ⟶ 109:
}
end
 
-- get map url
local basemapUrl
Line 111 ⟶ 119:
basemapUrl = mw.title.new(basemap):fullUrl("action=raw")
end
 
local output =
{
Line 117 ⟶ 125:
width = 1, -- generic value as output size depends solely on map size and scaling factor
height = 1, -- ditto
data =
{
{
Line 157 ⟶ 165:
type = "path",
from = { data = "countries" },
properties =
{
enter = { path = { field = "layout_path" } },
Line 171 ⟶ 179:
output.marks[1].properties.update.fill.scale = "color"
end
 
local flags
if formatJSONformatJson then flags = mw.text.JSON_PRETTY end
return mw.text.jsonEncode(output, flags)
end
 
local function deserializeXData(serializedX, xType, xMin, xMax)
function p.chart(frame)
local x
-- chart width
 
local graphwidth = tonumber(frame.args.width)
if not xType or xType == "integer" or xType == "number" then
-- chart height
local isInteger
local graphheight = tonumber(frame.args.height)
x, isInteger = numericArray(serializedX)
-- chart type
if x then
local type = frame.args.type or "line"
xMin = tonumber(xMin)
-- 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
xMax = tonumber(xMax)
local interpolate = frame.args.interpolate
if not xType then
-- mark colors (if no colors are given, the default 10 color palette is used)
if isInteger then xType = "integer" else xType = "number" end
local colors = stringArray(frame.args.colors) or "category10"
end
-- for line charts, the thickness of the line (strokeWidth)
else
local linewidth = tonumber(frame.args.linewidth) or 2.5
if xType then error("Numbers expected for parameter 'x'") end
-- x and y axis caption
end
local xTitle = frame.args.xAxisTitle
end
local yTitle = frame.args.yAxisTitle
if not x then
-- override x and y axis minimum and maximum
x = stringArray(serializedX)
local xMin = tonumber(frame.args.xAxisMin)
if not xType then xType = "string" end
local xMax = tonumber(frame.args.xAxisMax)
end
local yMin = tonumber(frame.args.yAxisMin)
 
local yMax = tonumber(frame.args.yAxisMax)
return x, xType, xMin, xMax
-- override x and y axis label formatting
end
local xFormat = frame.args.xAxisFormat
 
local yFormat = frame.args.yAxisFormat
local function deserializeYData(serializedYs, yType, yMin, yMax)
-- 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 seriesTitlesareAllInteger = {}true
 
for name, value in pairs(frame.args) do
for yNum, value in pairs(serializedYs) do
local yNum
local yValues
if name == "y" then yNum = 1 else yNum = tonumber(string.match(name, "y(%d+)$")) end
if not yType or yType == "integer" or yType == "number" then
if yNum then
local isInteger
y[yNum] = numericArray(value)
yValues, isInteger = numericArray(value)
-- name the series: default is "y<number>". Can be overwritten using the "y<number>Title" parameters.
if yValues then
seriesTitles[yNum] = frame.args["y" .. yNum .. "Title"] or name
areAllInteger = areAllInteger and isInteger
else
if yType then
error("Numbers expected for parameter '" .. name .. "'")
else
return deserializeYData(serializedYs, "string", yMin, yMax)
end
end
end
if not yValues then yValues = stringArray(value) end
 
y[yNum] = yValues
end
if not yType then
if areAllInteger then yType = "integer" else yType = "number" end
-- create data tuples, consisting of series index, x value, y value
end
local data = { name = "chart", values = {} }
if yType == "integer" or yType == "number" then
yMin = tonumber(yMin)
yMax = tonumber(yMax)
end
 
return y, yType, yMin, yMax
end
 
local function convertXYToManySeries(x, y, xType, yType, seriesTitles)
local data =
{
name = "chart",
format =
{
type = "json",
parse = { x = xType, y = yType }
},
values = {}
}
for i = 1, #y do
for j = 1, #x do
if j <= #y[i] then datatable.values[#insert(data.values + 1] =, { series = seriesTitles[i], x = x[j], y = y[i][j] }) end
end
end
return data
end
-- use stacked charts
 
local stacked = false
local function convertXYToSingleSeries(x, y, xType, yType, yNames)
local stats
local data = { name = "chart", format = { type = "json", parse = { x = xType } }, values = {} }
if string.sub(type, 1, 7) == "stacked" then
 
type = string.sub(type, 8)
for j = 1, #y do data.format.parse[yNames[j]] = yType end
if #y > 1 then -- ignore stacked charts if there is only one series
 
stacked = true
for i = 1, #x do
-- calculate statistics of data as stacking requires cumulative y values
local item = { x = x[i] }
stats =
for j = 1, #y do item[yNames[j]] = y[j][i] end
{
 
name = "stats", source = "chart", transform = {
table.insert(data.values, item)
{
type = "aggregate",
groupby = { "x" },
summarize = { y = "sum" }
}
}
}
end
end
return data
end
-- create scales
 
local function getXScale(chartType, stacked, xMin, xMax, xType)
if chartType == "pie" then return end
 
local xscale =
{
Line 264 ⟶ 291:
if xMax then xscale.domainMax = xMax end
if xMin or xMax then xscale.clamp = true end
if typechartType == "rect" then
xscale.type = "ordinal" end
if not stacked then xscale.padding = 0.2 end -- pad each bar group
else
if xType == "date" then xscale.type = "time"
elseif xType == "string" then xscale.type = "ordinal" end
end
 
return xscale
end
 
local function getYScale(chartType, stacked, yMin, yMax, yType)
if chartType == "pie" then return end
 
local yscale =
{
Line 272 ⟶ 311:
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 = typechartType ~= "line",
nice = true
}
Line 278 ⟶ 317:
if yMax then yscale.domainMax = yMax end
if yMin or yMax then yscale.clamp = true end
if yType == "date" then yscale.type = "time"
elseif yType == "string" then yscale.type = "ordinal" end
if stacked then
yscale.___domain = { data = "stats", field = "sum_y" }
else
yscale.___domain = { data = "chart", field = "y" }
end
 
return yscale
end
 
local function getColorScale(colors, chartType, xCount, yCount)
if not colors then
if (chartType == "pie" and xCount > 10) or yCount > 10 then colors = "category20" else colors = "category10" end
end
 
local colorScale =
{
Line 290 ⟶ 340:
___domain = { data = "chart", field = "series" }
}
if chartType == "pie" then colorScale.___domain.field = "x" end
return colorScale
end
 
local function getAlphaColorScale(colors, y)
local alphaScale
-- if there is at least one color in the format "#aarrggbb", create a transparency (alpha) scale
Line 308 ⟶ 363:
if hasAlpha then alphaScale = { name = "transparency", type = "ordinal", range = alphas } end
end
return alphaScale
-- 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
end
local groupScale
 
if type == "rect" and not stacked and #y > 1 then
local function getValueScale(fieldName, min, max, type)
groupScale =
local valueScale =
{
name = fieldName,
type = type or "linear",
___domain = { data = "chart", field = fieldName },
range = { min, max }
}
return valueScale
end
 
local function addInteractionToChartVisualisation(plotMarks, colorField, dataField)
-- initial setup
if not plotMarks.properties.enter then plotMarks.properties.enter = {} end
plotMarks.properties.enter[colorField] = { scale = "color", field = dataField }
 
-- action when cursor is over plot mark: highlight
if not plotMarks.properties.hover then plotMarks.properties.hover = {} end
plotMarks.properties.hover[colorField] = { value = "red" }
 
-- action when cursor leaves plot mark: reset to initial setup
if not plotMarks.properties.update then plotMarks.properties.update = {} end
plotMarks.properties.update[colorField] = { scale = "color", field = dataField }
end
 
local function getPieChartVisualisation(yCount, innerRadius, outerRadius, linewidth, radiusScale)
local chartvis =
{
type = "arc",
from = { data = "chart", transform = { { field = "y", type = "pie" } } },
 
properties =
{
nameenter = "series",{
innerRadius = { value = innerRadius },
type = "ordinal",
range outerRadius = "width"{ },
___domain startAngle = { field = "serieslayout_start" },
endAngle = { field = "layout_end" },
stroke = { value = "white" },
strokeWidth = { value = linewidth or 1 }
}
}
}
xscale.padding = 0.2 -- pad each bar group
if radiusScale then
chartvis.properties.enter.outerRadius.scale = radiusScale.name
chartvis.properties.enter.outerRadius.field = radiusScale.___domain.field
else
chartvis.properties.enter.outerRadius.value = outerRadius
end
 
addInteractionToChartVisualisation(chartvis, "fill", "x")
-- decide if lines (strokes) or areas (fills) should be drawn
 
local colorField
return chartvis
if type == "line" then colorField = "stroke" else colorField = "fill" end
end
 
-- create chart markings
local function getChartVisualisation(chartType, stacked, colorField, yCount, innerRadius, outerRadius, linewidth, alphaScale, radiusScale, interpolate)
local marks =
if chartType == "pie" then return getPieChartVisualisation(yCount, innerRadius, outerRadius, linewidth, radiusScale) end
 
local chartvis =
{
type = typechartType,
properties =
{
Line 336 ⟶ 434:
x = { scale = "x", field = "x" },
y = { scale = "y", field = "y" }
},
-- chart update event handler
update = { },
-- chart hover event handler
hover = { }
}
}
addInteractionToChartVisualisation(chartvis, colorField, "series")
if colorField == "stroke" then
markschartvis.properties.enter[".strokeWidth"] = { value = linewidth or 2.5 }
end
 
marks.properties.enter[colorField] = { scale = "color", field = "series" }
marksif interpolate then chartvis.properties.update[colorField]enter.interpolate = { scalevalue = "color", field = "series"interpolate } end
 
marks.properties.hover[colorField] = { value = "red" }
if alphaScale then markschartvis.properties.update[colorField .. "Opacity"] = { scale = "transparency" } end
-- for bars and area charts set the lower bound of their areas
if typechartType == "rect" or typechartType == "area" then
if stacked then
-- for stacked charts this lower bound is cumulative/the end of the last stacking element
markschartvis.properties.enter.y2 = { scale = "y", field = "layout_end" }
else
--[[
Line 361 ⟶ 456:
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 ]]
markschartvis.properties.enter.y2 = { scale = "y", value = 0 }
end
end
-- for bar charts ...
if typechartType == "rect" then
-- set 1 pixel width between the bars
markschartvis.properties.enter.width = { scale = "x", band = true, offset = -1 }
-- for multiple series the bar marking needneeds to use the "inner" series scale, whereas the "outer" x scale is used by the grouping
if not stacked and #yyCount > 1 then
markschartvis.properties.enter.x.scale = "series"
markschartvis.properties.enter.x.field = "series"
markschartvis.properties.enter.width.scale = "series"
end
end
-- stacked charts have their own (stacked) y values
if stacked then markschartvis.properties.enter.y.field = "layout_start" end
 
-- if there are multiple series group these together
-- set interpolation mode
if yCount == 1 then
if interpolate then marks.properties.enter.interpolate = { value = interpolate } end
chartvis.from = { data = "chart" }
else
if #y == 1 then marks.from = { data = "chart" } else
-- if there are multiple series, connect colors to series
markschartvis.properties.update[colorField].field = "series"
if alphaScale then markschartvis.properties.update[colorField .. "Opacity"].field = "series" end
-- apply a grouping (facetting) transformation
markschartvis =
{
type = "group",
marks = { markschartvis },
from =
{
data = "chart",
transform =
{
Line 404 ⟶ 499:
-- for stacked charts apply a stacking transformation
if stacked then
table.insert( markschartvis.from.transform, 1, { type = "stack", groupby = { "x" }, sortby = { "series" }, field = "y" } )
else
-- for bar charts the series are side-by-side grouped by x
if typechartType == "rect" then
-- for bar charts with multiple series: each serie is grouped by the x value, therefore the series need their own scale within each x group
marks.from.transform[1].groupby = "x"
marks.scales = {local groupScale }=
{
marks.properties = { enter = { x = { field = "key", scale = "x" }, width = { scale = "x", band = true } } }
name = "series",
type = "ordinal",
range = "width",
___domain = { field = "series" }
}
 
chartvis.from.transform[1].groupby = "x"
chartvis.scales = { groupScale }
chartvis.properties = { enter = { x = { field = "key", scale = "x" }, width = { scale = "x", band = true } } }
end
end
end
 
return chartvis
-- create legend
end
if legend then
 
legend =
local function getTextMarks(chartType, outerRadius, radiusScale)
local textmarks
if chartType == "pie" then
textmarks =
{
type = "text",
from = { data = "chart", transform = { { field = "y", type = "pie" } } },
properties =
{
fillenter = "color",
{
stroke = "color",
x = { group = "width", mult = 0.5 },
title = legend
y = { group = "height", mult = 0.5 },
radius = { offset = -4 },
theta = { field = "layout_mid" },
fill = { value = "white" },
align = { value = "center" },
baseline = { value = "top" },
text = { field = "y" },
angle = { field = "layout_mid", mult = 180.0 / math.pi },
fontSize = { value = math.ceil(outerRadius / 10) }
}
}
}
if radiusScale then
textmarks.properties.enter.radius.scale = radiusScale.name
textmarks.properties.enter.radius.field = radiusScale.___domain.field
else
textmarks.properties.enter.radius.value = outerRadius
end
end
return textmarks
end
 
local function getAxes(xTitle, xFormat, xType, yTitle, yFormat, yType, chartType)
local xAxis, yAxis
if chartType ~= "pie" then
if xType == "integer" and not xFormat then xFormat = "d" end
xAxis =
{
type = "x",
scale = "x",
title = xTitle,
format = xFormat
}
 
if yType == "integer" and not yFormat then yFormat = "d" end
yAxis =
{
type = "y",
scale = "y",
title = yTitle,
format = yFormat
}
end
 
return xAxis, yAxis
end
 
local function getLegend(legendTitle, chartType, outerRadius)
local legend =
{
fill = "color",
stroke = "color",
title = legendTitle,
}
if chartType == "pie" then
-- move legend from center position to top
legend.properties = { legend = { y = { value = -outerRadius } } }
end
return legend
end
 
function p.chart(frame)
-- chart width and height
local graphwidth = tonumber(frame.args.width) or 200
local graphheight = tonumber(frame.args.height) or 200
-- chart type
local chartType = frame.args.type or "line"
-- interpolation mode for line and area charts: 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)
-- for line charts, the thickness of the line; for pie charts the gap between each slice
local linewidth = tonumber(frame.args.linewidth)
-- x and y axis caption
local xTitle = frame.args.xAxisTitle
local yTitle = frame.args.yAxisTitle
-- x and y value types
local xType = frame.args.xType
local yType = frame.args.yType
-- override x and y axis minimum and maximum
local xMin = frame.args.xAxisMin
local xMax = frame.args.xAxisMax
local yMin = frame.args.yAxisMin
local yMax = frame.args.yAxisMax
-- override x and y axis label formatting
local xFormat = frame.args.xAxisFormat
local yFormat = frame.args.yAxisFormat
-- show legend with given title
local legendTitle = frame.args.legend
-- show values as text
local showValues = frame.args.showValues
-- pie chart radiuses
local innerRadius = tonumber(frame.args.innerRadius) or 0
local outerRadius = math.min(graphwidth, graphheight)
-- format JSON output
local formatJson = frame.args.formatjson
 
-- get x values
local x
x, xType, xMin, xMax = deserializeXData(frame.args.x, xType, xMin, xMax)
 
-- get y values (series)
local yValues = {}
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
yValues[yNum] = 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
local y
y, yType, yMin, yMax = deserializeYData(yValues, yType, yMin, yMax)
 
-- create data tuples, consisting of series index, x value, y value
local data
if chartType == "pie" then
-- for pie charts the second second series is merged into the first series as radius values
data = convertXYToSingleSeries(x, y, xType, yType, { "y", "r" })
else
data = convertXYToManySeries(x, y, xType, yType, seriesTitles)
end
 
-- configure stacked charts
local stacked = false
local stats
if string.sub(chartType, 1, 7) == "stacked" then
chartType = string.sub(chartType, 8)
if #y > 1 then -- ignore stacked charts if there is only one series
stacked = true
-- aggregate data by cumulative y values
stats =
{
name = "stats", source = "chart", transform =
{
{
type = "aggregate",
groupby = { "x" },
summarize = { y = "sum" }
}
}
}
end
end
 
-- create scales
local scales = {}
 
local xscale = getXScale(chartType, stacked, xMin, xMax, xType)
table.insert(scales, xscale)
local yscale = getYScale(chartType, stacked, yMin, yMax, yType)
table.insert(scales, yscale)
 
 
local colorScale = getColorScale(colors, chartType, #x, #y)
table.insert(scales, colorScale)
 
local alphaScale = getAlphaColorScale(colors, y)
table.insert(scales, alphaScale)
 
local radiusScale
if chartType == "pie" and #y > 1 then
radiusScale = getValueScale("r", 0, outerRadius)
table.insert(scales, radiusScale)
end
 
-- decide if lines (strokes) or areas (fills) should be drawn
local colorField
if chartType == "line" then colorField = "stroke" else colorField = "fill" end
 
-- create chart markings
local chartvis = getChartVisualisation(chartType, stacked, colorField, #y, innerRadius, outerRadius, linewidth, alphaScale, radiusScale, interpolate)
 
-- text marks
local textmarks
if showValues then textmarks = getTextMarks(chartType, outerRadius, radiusScale) end
 
-- axes
local xAxis, yAxis = getAxes(xTitle, xFormat, xType, yTitle, yFormat, yType, chartType)
 
-- legend
local legend
if legendTitle then legend = getLegend(legendTitle, chartType, outerRadius) end
 
-- construct final output object
local output =
Line 434 ⟶ 727:
height = graphheight,
data = { data, stats },
scales = { xscale, yscale, colorScale, alphaScale }scales,
axes = { xAxis, yAxis },
marks = { chartvis, textmarks },
{
legends = { legend }
{
type = "x",
scale = "x",
title = xTitle,
format = xFormat
},
{
type = "y",
scale = "y",
title = yTitle,
format = yFormat
}
},
marks = { marks },
legends = legend
}
 
local flags
if formatJSONformatJson 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