Module:Piechart
This module is rated as ready for general use. It has reached a mature form and is thought to be relatively bug-free and ready for use wherever appropriate. It is ready to mention on help pages and other Wikipedia resources as an option for new users to learn. To reduce server load and bad output, it should be improved by sandbox testing rather than repeated trial-and-error editing. |
Smooth piechart module.
Usage
[edit]Draws charts in HTML with an accessible legend (optional). A list of all features is in the "TODO" section of the main `p.pie` function.
Most of the time you should use with a helper template that adds required CSS: {{Piechart}}.
Examples
[edit]Minimalistic
[edit]Note that you don't need to provide the second value as it's calculated (assuming they sum up to 100).
{{Piechart| [ {"value":33.3}, {} ] }}
Labels and Legend
[edit]Here we add some custom labels. Also note that we add a meta option to add legend on the side.
{{Piechart| [
{"label": "women: $v", "value": 33.3},
{"label": "men: $v"}
]
| meta = {"legend":true}
}}
- women: 33.3%
- men: 66.7%
Automatic Scaling
[edit]In cases where you don't have calculated percentages, you can use automatic scaling. Just provide both values in this case.
{{Piechart| [
{"label": "women: $v", "value": 750},
{"label": "men: $v", "value": 250}
]
| meta = {"legend":true}
}}
- women: 750 (75.0%)
- men: 250 (25.0%)
Multiple Values
[edit]The module allows displaying multiple values, not just 2.
{{Piechart| [
{"label": "sweets: $v", "value": 5, "color":"darkred"},
{"label": "sandwiches: $v", "value": 3, "color":"wheat"},
{"label": "cookies: $v", "value": 2, "color":"goldenrod"},
{"label": "drinks: $v", "value": 1, "color":"#ccf"}
]
|meta={"autoscale":true, "legend":true}
}}
- sweets: 5 (45.5%)
- sandwiches: 3 (27.3%)
- cookies: 2 (18.2%)
- drinks: 1 (9.1%)
Note that in this case, it was necessary to provide the additional option "autoscale":true
. This is necessary when the sum is less than 100.
Legend and Its Position
[edit]The legend is added using the meta property legend as shown. However, you can also change the order using direction. Possible values include:
- row (default) – order is list, chart;
- row-reverse – reverse order, i.e., chart, list;
- column – column layout (vertical).
- column-reverse – column layout, reversed (chart on top).
{{Piechart| [
{"label": "cookies: $v", "value": 2, "color":"goldenrod"},
{"label": "drinks: $v", "value": 1, "color":"#ccf"},
{"label": "sweets: $v", "value": 5, "color":"darkred"},
{"label": "sandwiches: $v", "value": 3, "color":"wheat"}
]
|meta={"autoscale":true, "legend":true, "direction":"row-reverse"}
}}
row (default direction)
- cookies: 2 (18.2%)
- drinks: 1 (9.1%)
- sweets: 5 (45.5%)
- sandwiches: 3 (27.3%)
row-reverse
- cookies: 2 (18.2%)
- drinks: 1 (9.1%)
- sweets: 5 (45.5%)
- sandwiches: 3 (27.3%)
column
- cookies: 2 (18.2%)
- drinks: 1 (9.1%)
- sweets: 5 (45.5%)
- sandwiches: 3 (27.3%)
column-reverse
- cookies: 2 (18.2%)
- drinks: 1 (9.1%)
- sweets: 5 (45.5%)
- sandwiches: 3 (27.3%)
Green frames added for clarity in examples. They are not normally added.
Direct functions
[edit]In case you want to use without the {{Piechart}} template, you can use this main functions:
{{#invoke:Piechart|pie|json_data|meta=json_options}}
{{#invoke:Piechart|color|number}}
Note that direct calls to the pie function require adding CSS:
<templatestyles src="Piechart/style.css"/>
{{#invoke:Piechart|pie| [ {"value":33.3}, {} ] }}
Example of json_data:
[
{ "label": "pie: $v", "color": "wheat", "value": 40 },
{ "label": "cheese pizza $v", "color": "#fc0", "value": 20 },
{ "label": "mixed pizza: $v", "color": "#f60", "value": 20 },
{ "label": "raw pizza $v", "color": "#f30" }
]
- Note that the last value is missing. The last value is optional as long as the values are intended to sum up to 100 (as in 100%).
- Notice
$v
label, this is a formatted number (see `function prepareLabel
`). - Colors are hex or names. Default palette is in shades of green.
Example of meta=json_options:
|meta = {"size":200, "autoscale":false, "legend":true}
All meta options are optional (see `function p.setupOptions
`).
Feature requests
[edit]For feature requests and bugs write to me, the author of the piecharte module: Maciej Nux.
See also
[edit]- {{Pie chart}}, also pie, but with classic template parameters (enumerated); that chart is shown on the right (like a thumbnail image).
local p = {}
--[[
Smooth piechart module.
Draws charts in HTML with an accessible legend (optional).
A list of all features is in the "TODO" section of the main `p.pie` function.
Use with a helper template that adds required CSS.
{{{1}}}:
[
{ "label": "pie: $v", "color": "wheat", "value": 40 },
{ "label": "cheese pizza $v", "color": "#fc0", "value": 20 },
{ "label": "mixed pizza: $v", "color": "#f60", "value": 20 },
{ "label": "raw pizza $v", "color": "#f30" }
]
Where $v is a formatted number (see `function prepareLabel`).
{{{meta}}}:
{"size":200, "autoscale":false, "legend":true}
All meta options are optional (see `function p.setupOptions`).
]]
-- Author: [[User:Nux|Maciej Nux]] (pl.wikipedia.org).
--[[
Debug:
-- labels and auto-value
local json_data = '[{"label": "k: $v", "value": 33.1}, {"label": "m: $v", "value": -1}]'
local html = p.renderPie(json_data)
mw.logObject(html)
-- autoscale values
local json_data = '[{"value": 700}, {"value": 300}]'
local html = p.renderPie(json_data, options)
mw.logObject(html)
-- size option
local json_data = '[{"label": "k: $v", "value": 33.1}, {"label": "m: $v", "value": -1}]'
local options = '{"size":200}'
local html = p.renderPie(json_data, options)
mw.logObject(html)
-- custom colors
local json_data = '[{"label": "k: $v", "value": 33.1, "color":"black"}, {"label": "m: $v", "value": -1, "color":"green"}]'
local html = p.renderPie(json_data)
mw.logObject(html)
-- 4-cuts
local entries = {
'{"label": "ciastka: $v", "value": 2, "color":"goldenrod"}',
'{"label": "słodycze: $v", "value": 4, "color":"darkred"}',
'{"label": "napoje: $v", "value": 1, "color":"lightblue"}',
'{"label": "kanapki: $v", "value": 3, "color":"wheat"}'
}
local json_data = '['..table.concat(entries, ',')..']'
local html = p.renderPie(json_data, '{"autoscale":true}')
mw.logObject(html)
-- colors
local fr = { args = { " 123 " } }
local ret = p.color(fr)
]]
--[[
Color for a slice (defaults).
{{{1}}}: slice number
]]
function p.color(frame)
local index = tonumber(trim(frame.args[1]))
return ' ' .. defaultColor(index)
end
--[[
Piechart.
TODO:
- [x] basic 2-element pie chart
- read json
- calculate value with -1
- generate html
- new css + tests
- provide dumb labels (just v%)
- [x] colors in json
- [x] 1st value >= 50%
- [x] custom labels support
- [x] pie size from 'meta' param (options json)
- [x] pl formatting for numbers?
- [x] support undefined value (instead of -1)
- [x] undefined in any order
- [x] scale values to 100% (autoscale)
- [x] order values clockwise (not left/right)
- [x] multi-cut pie
- [x] sanitize user values
- [x] auto colors
- [x] function to get color by number (for custom legend)
- [x] remember and show autoscaled data
- [x] generate a legend
- [x] simple legend positioning by (flex-)direction
- legend2: customization
- (?) itemTpl support
- replace default item with tpl
- can I / should I sanitize it?
- support for $v, $d, $p
- (?) custom head
- (?) validation of input
- check if required values are present
- message showing whole entry, when entry is invalid
- pre-sanitize values?
- sane info when JSON fails? Maybe dump JSON and show example with quotes-n-all...
- (?) option to sort entries by value
]]
function p.pie(frame)
local json_data = trim(frame.args[1])
local options = nil
if (frame.args.meta) then
options = trim(frame.args.meta)
end
local html = p.renderPie(json_data, options)
return trim(html)
end
-- Setup chart options.
function p.setupOptions(json_options)
local options = {
-- circle size in [px]
size = 100,
-- autoscale values (otherwise assume they sum up to 100)
autoscale = false,
-- hide chart for screen readers (when you have a table, forced for legend)
ariahidechart = false,
-- show legend (defaults to the left side)
legend = false,
-- direction of legend-chart flexbox (flex-direction)
direction = "",
}
if json_options then
local rawOptions = mw.text.jsonDecode(json_options)
if rawOptions then
if type(rawOptions.size) == "number" then
options.size = math.floor(rawOptions.size)
end
options.autoscale = rawOptions.autoscale or false
if rawOptions.legend then
options.legend = true
end
if rawOptions.ariahidechart then
options.ariahidechart = true
end
if (type(rawOptions.direction) == "string") then
-- Remove unsafe/invalid characters
local sanitized = rawOptions.direction:gsub("[^a-z0-9%-]", "")
-- also adjust width so that row-reverse won't push things to the right
options.direction = 'width: max-content; flex-direction: ' .. sanitized
end
end
end
if (options.legend) then
options.ariahidechart = true
end
return options
end
--[[
Render piechart.
@param json_data JSON string with pie data.
]]
function p.renderPie(json_data, json_options)
local data = mw.text.jsonDecode(json_data)
local options = p.setupOptions(json_options)
-- prepare
local ok, total = p.prepareEntries(data, options)
-- init render
local html = "<div class='smooth-pie-container' style='"..options.direction.."'>"
-- error info
if not ok then
html = html .. renderErrors(data)
end
-- render legend
if options.legend then
html = html .. p.renderLegend(data, options)
end
-- render items
local header, items, footer = p.renderEntries(ok, total, data, options)
html = html .. header .. items .. footer
-- end .smooth-pie-container
html = html .. "\n</div>"
return html
end
-- Prepare data (slices etc)
function p.prepareEntries(data, options)
local sum = sumValues(data);
-- force autoscale when over 100
if (sum > 100) then
options.autoscale = true
end
-- pre-format entries
local ok = true
local no = 0
local total = #data
for index, entry in ipairs(data) do
no = no + 1
if not prepareSlice(entry, no, sum, total, options) then
no = no - 1
ok = false
end
end
total = no -- total valid
return ok, total
end
function sumValues(data)
local sum = 0;
for _, entry in ipairs(data) do
local value = entry.value
if not (type(value) ~= "number" or value < 0) then
sum = sum + value
end
end
return sum
end
-- render error info
function renderErrors(data)
local html = "\n<ol class='chart-errors' style='display:none'>"
for _, entry in ipairs(data) do
if entry.error then
entryJson = mw.text.jsonEncode(entry)
html = html .. "\n<li>".. entryJson .."</li>"
end
end
return html .. "\n</ol>\n"
end
-- Prepare single slice data (modifies entry).
function prepareSlice(entry, no, sum, total, options)
local autoscale = options.autoscale
local value = entry.value
if (type(value) ~= "number" or value < 0) then
if autoscale then
entry.error = "cannot autoscale unknown value"
return false
end
value = 100 - sum
end
-- entry.raw only when scaled
if autoscale then
entry.raw = value
value = (value / sum) * 100
end
entry.value = value
-- prepare final label
entry.label = prepareLabel(entry.label, entry)
-- prepare final slice bg color
local index = no
if no == total then
index = -1
end
entry.bcolor = backColor(entry, index)
return true
end
-- render legend for pre-processed entries
function p.renderLegend(data, options)
local html = "\n<ol class='smooth-pie-legend'>"
for _, entry in ipairs(data) do
if not entry.error then
html = html .. renderLegendItem(entry, options)
end
end
return html .. "\n</ol>\n"
end
-- render legend item
function renderLegendItem(entry, options)
local label = entry.label
local bcolor = entry.bcolor
local html = "\n<li>"
html = html .. '<span class="l-color" style="'..bcolor..'"></span>'
html = html .. '<span class="l-label">'..label..'</span>'
return html .. "</li>"
end
-- Prepare data (slices etc)
function p.renderEntries(ok, total, data, options)
-- cache for some items (small slices)
p.cuts = mw.loadJsonData('Module:Piechart/cuts.json')
local first = true
local previous = 0
local no = 0
local items = ""
local header = ""
for index, entry in ipairs(data) do
if not entry.error then
no = no + 1
if no == total then
header = renderFinal(entry, options)
else
items = items .. renderOther(previous, entry, options)
end
previous = previous + entry.value
end
end
local footer = '\n</div>'
return header, items, footer
end
-- final, but header...
function renderFinal(entry, options)
local label = entry.label
local bcolor = entry.bcolor
local size = options.size
-- hide chart for readers, especially when legend is there
local aria = ""
if (options.ariahidechart) then
aria = 'aria-hidden="true"'
end
-- slices container and last slice
local style = 'width:'..size..'px; height:'..size..'px;'..bcolor
local html = [[
<div class="smooth-pie"
style="]]..style..[["
title="]]..label..[["
]]..aria..[[
>]]
return html
end
-- any other then final
function renderOther(previous, entry, options)
local value = entry.value
local label = entry.label
local bcolor = entry.bcolor
-- value too small to see
if (value < 0.03) then
mw.log('value too small', value, label)
return ""
end
local html = ""
local size = ''
-- mw.logObject({'v,p,l', value, previous, label})
if (value >= 50) then
html = sliceWithClass('pie50', 50, value, previous, bcolor, label)
elseif (value >= 25) then
html = sliceWithClass('pie25', 25, value, previous, bcolor, label)
elseif (value >= 12.5) then
html = sliceWithClass('pie12-5', 12.5, value, previous, bcolor, label)
elseif (value >= 7) then
html = sliceWithClass('pie7', 7, value, previous, bcolor, label)
elseif (value >= 5) then
html = sliceWithClass('pie5', 5, value, previous, bcolor, label)
else
-- 0-5%
local cutIndex = round(value*10)
if cutIndex < 1 then
cutIndex = 1
end
local cut = p.cuts[cutIndex]
local transform = rotation(previous)
html = sliceX(cut, transform, bcolor, label)
end
-- mw.log(html)
return html
end
-- round to int
function round(number)
return math.floor(number + 0.5)
end
-- render full slice with specific class
function sliceWithClass(sizeClass, sizeStep, value, previous, bcolor, label)
local transform = rotation(previous)
local html = ""
html = html .. sliceBase(sizeClass, transform, bcolor, label)
-- mw.logObject({'sliceWithClass:', sizeClass, sizeStep, value, previous, bcolor, label})
if (value > sizeStep) then
local extra = value - sizeStep
transform = rotation(previous + extra)
-- mw.logObject({'sliceWithClass; extra, transform', extra, transform})
html = html .. sliceBase(sizeClass, transform, bcolor, label)
end
return html
end
-- render single slice
function sliceBase(sizeClass, transform, bcolor, label)
local style = bcolor
if transform ~= "" then
style = style .. '; ' .. transform
end
return '\n\t<div class="'..sizeClass..'" style="'..style..'" title="'..label..'"></div>'
end
-- small slice cut to fluid size.
-- range in theory: 0 to 24.(9)% reaching 24.(9)% for cut = +inf
-- range in practice: 0 to 5%
function sliceX(cut, transform, bcolor, label)
local path = 'clip-path: polygon(0% 0%, '..cut..'% 0%, 0 100%)'
return '\n\t<div style="'..transform..'; '..bcolor..'; '..path..'" title="'..label..'"></div>'
end
-- translate value to turn rotation
function rotation(value)
if (value > 0) then
return string.format("transform: rotate(%.3fturn)", value/100)
end
return ''
end
-- Language sensitive float.
function formatNum(value)
local lang = mw.language.getContentLanguage()
-- doesn't do precision :(
-- local v = lang:formatNum(value)
local v = string.format("%.1f", value)
if (lang:getCode() == 'pl') then
v = v:gsub("%.", ",")
end
return v
end
--[[
Prepare final label.
Typical tpl:
"Abc: $v"
will result in:
"Abc: 23%" -- when values are percentages
"Abc: 1234 (23%)" -- when values are autoscaled
Advanced tpl:
"Abc: $d ($p)" -- only works with autoscale
]]
function prepareLabel(tpl, entry)
-- static tpl
if tpl and not string.find(tpl, '$') then
return tpl
end
-- format % value without %
local p = formatNum(entry.value)
-- default template
if not tpl then
tpl = "$v"
end
local label = ""
if entry.raw then
label = tpl:gsub("%$p", p .. "%%"):gsub("%$d", entry.raw):gsub("%$v", entry.raw .. " (" .. p .. "%%)")
else
label = tpl:gsub("%$v", p .. "%%")
end
return label
end
-- default colors
local colorPalette = {
'#005744',
'#006c52',
'#00814e',
'#009649',
'#00ab45',
'#00c140',
'#00d93b',
'#00f038',
}
local lastColor = '#cdf099'
-- background color from entry or the default colors
function backColor(entry, no)
if (type(entry.color) == "string") then
-- Remove unsafe characters from entry.color
local sanitizedColor = entry.color:gsub("[^a-zA-Z0-9#%-]", "")
return 'background-color: ' .. sanitizedColor
else
local color = defaultColor(no)
return 'background-color: ' .. color
end
end
-- color from the default colors
-- last entry color for 0 or -1
function defaultColor(no)
local color = lastColor
if (no > 0) then
local cIndex = (no - 1) % #colorPalette + 1
color = colorPalette[cIndex]
end
return color
end
--[[
trim string
note:
`(s:gsub(...))` returns only a string
`s:gsub(...)` returns a string and a number
]]
function trim(s)
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
return p