Module:Graph
From Guild of Archivists
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