Module:Piechart/sandbox: Difference between revisions

Content deleted Content added
move to function; per talk
Report unknown $var • Wikiploy
 
(13 intermediate revisions by 2 users not shown)
Line 6:
-- require exact colors for printing
local forPrinting = "-webkit-print-color-adjust: exact; print-color-adjust: exact;"
--[===[
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.
 
Module info:
- Changelog and TODO: [[:en:User:Nux/pie_chart_-_todo]] (Piechart 1.0, 2.0 and beyond).
- Author: [[:en:User:Nux|Maciej Nux]].
 
Use with a helper template that adds required CSS.
Line 25 ⟶ 29:
{{{meta}}}:
{"size":200, "autoscale":false, "legend":true}
 
All meta options are optional (see `function p.setupOptions`).
]===]
-- Author: [[User:Nux|Maciej Nux]] (pl.wikipedia.org).
 
--[[
Line 79 ⟶ 83:
end
 
--[===[
Main pie chart function.
Piechart.
TODO (maybe):
[[:en:User:Nux/pie_chart_-_todo#Hopes_and_dreams]]
- [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 = priv.trim(frame.args[1])
Line 149 ⟶ 120:
-- footer below the labels
footer = "",
-- formatting template for labels
labelformat = "",
}
-- internals
options.style = ""
if user_options and user_options.meta then
local decodeSuccess, rawOptions = pcall(function()
return mw.text.jsonDecode(user_options.meta, mw.text.JSON_TRY_FIXING)
end)
if not decodeSuccess then
rawOptions = false
mw.log('invalid meta parameters')
end
if rawOptions then
if type(rawOptions.size) == "number" then
Line 182 ⟶ 161:
if (type(rawOptions.footer) == "string") then
options.footer = rawOptions.footer
end
if (type(rawOptions.labelformat) == "string") then
options.labelformat = rawOptions.labelformat
end
end
Line 197 ⟶ 179:
return options
end
 
-- internal for testing legend rendering
p.__priv.legendDebug = false
 
--[[
Line 204 ⟶ 189:
]]
function p.renderPie(json_data, user_options)
if type(json_data) ~= "string" then
local data = mw.text.jsonDecode(json_data, mw.text.JSON_TRY_FIXING)
error('invalid piechart data type: '..type(json_data))
end
if #json_data < 2 then
error('piechart data is empty')
end
local decodeSuccess, data = pcall(function()
return mw.text.jsonDecode(json_data, mw.text.JSON_TRY_FIXING)
end)
-- Handle decode error
if not decodeSuccess then
error('invalid piechart data: '..json_data)
end
local options = p.setupOptions(user_options)
 
Line 221 ⟶ 218:
if options.legend then
html = html .. p.renderLegend(data, options)
end
 
if p.__priv.legendDebug then
return html
end
 
Line 231 ⟶ 232:
 
return html
end
 
function priv.boundaryFormatting(diff)
local value = 0.0
if diff <= 1.0 then
value = math.ceil(diff / 0.2) * 0.2 -- 0.2 step
else
value = math.ceil(diff / 0.5) * 0.5 -- 0.5 step
end
return string.format("%.1f", value)
end
 
-- Check if sum will trigger autoscaling
function priv.willAutoscale(sum)
-- Compare with a number larger then 100% to avoid floating-point precision problems
--- ...and data precision problems https://en.wikipedia.org/wiki/Template_talk:Pie_chart#c-PrimeHunter-20250420202500-Allow_percentage_sum_slightly_above_100
local diff = sum - 100
local grace = 1
return diff > grace
end
-- Tracking errors in data (note: somewhat expensive, similar to a red link)
-- In short: ±0.3 is a reasonable deviation; ±1 when the errors accumulate
-- https://en.wikipedia.org/wiki/Template_talk:Pie_chart#c-Nux-20250429152000-Nux-20250422224600
function priv.sumErrorTracking(sum, items)
local diff = sum - 100
if diff >= 0.4 and diff <= 10 then
local firstItem = items[1]
local lastItem = items[#items]
mw.addWarning("pie chart: Σ (value) = "..sum.."% ("..firstItem.value.." + .. .+ "..lastItem.value..")")
if mw.title.getCurrentTitle().namespace == 0 then
local suffix = priv.boundaryFormatting(diff)
_ = mw.title.new("Module:Piechart/tracing/diff below "..suffix).id
end
end
end
 
Line 237 ⟶ 272:
local sum = priv.sumValues(data);
-- force autoscale when over 100
if priv.willAutoscale(sum > 100) then
options.autoscale = true
end
Line 299 ⟶ 334:
 
-- prepare final label
entry.label = priv.prepareLabel(entryoptions.labellabelformat, entry)
-- background, but also color for MW syntax linter
entry.bcolor = priv.backColor(entry, no, total) .. ";color:#000"
Line 340 ⟶ 375:
local bcolor = entry.bcolor
local html = "\n<li>"
if p.__priv.legendDebug then
forPrinting = ""
end
html = html .. '<span class="l-color" style="'..forPrinting..bcolor..'"></span>'
html = html .. '<span class="l-label">'..label..'</span>'
Line 352 ⟶ 390:
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
items = items .. priv.renderItem(previous, entry, options)
no = no + 1
if no == total then
header = priv.renderFinal(entry, options)
else
items = items .. priv.renderOther(previous, entry, options)
end
previous = previous + entry.value
end
end
local footer = '\n</div>'
local header = priv.renderHeader(options)
local footer = '\n<div class="smooth-pie-border"></div></div>'
 
return header, items, footer
end
-- header of pie-items (class="smooth-pie")
-- final, but header...
function priv.renderFinalrenderHeader(entry, options)
local labelbcolor = entry.label'background:#888;color:#000'
local bcolor = entry.bcolor
local size = options.size
 
Line 387 ⟶ 419:
<div class="smooth-pie"
style="]]..style..[["
title="]]..p.extract_text(label)..[["
]]..aria..[[
>]]
return html
end
-- Render pie-item
-- any other then final
-- (previous is a sum of previous values)
function priv.renderOther(previous, entry, options)
function priv.renderItem(previous, entry, options)
local value = entry.value
local label = entry.label
Line 401 ⟶ 433:
if (value < 0.03) then
mw.log('value too small', value, label)
mw.addWarning("pie chart: Value too small ↆ "..value.."% ("..label..")")
return ""
end
 
-- minimize transformation defects
if value < 10 then
if previous > 1 then
previous = previous - 0.01
end
value = value + 0.02
else
if previous > 1 then
previous = previous - 0.1
end
value = value + 0.2
end
-- force sum to be below 100% (needed due to value errors)
if previous + value > 100 then
if previous >= 100 then
mw.log('previous is already at 100', value, label)
return ""
end
value = 100 - previous
end
 
local html = ""
Line 470 ⟶ 524:
end
 
-- translate value to turn rotation (v=100 => 1.0turn)
function priv.rotation(value)
if (value > 0.001) then
returnlocal f = string.format("transform: rotate(%.3fturn)7f", value / 100)
f = f:gsub("(%d)0+$", "%1") -- remove trailing zeros
return "transform: rotate("..f.."turn)"
end
return ''
end
 
-- Language sensitive float, small numbers.
function priv.formatNum(value)
local lang = mw.language.getContentLanguage()
Line 497 ⟶ 553:
end
 
-- DisplayFormat commaslarge in legendvalues.
function priv.formatLargeNum(value)
local lang = mw.language.getContentLanguage()
if value < 1000 then
-- add thusands separators
return tostring(value)
local v = lang:formatNum(value)
else
return v
-- add commas to long numbers in the displayed legend
end
-- snip off decimal
-- Testing formatLargeNum.
local plusDecimal = tostring(value):match("%.(%d+)")
-- p.__priv.test_formatLargeNum()
plusDecimal = plusDecimal and ("." .. plusDecimal) or ""
function priv.test_formatLargeNum()
local withoutCommas = math.floor(value)
mw.log("must not add fractional part")
-- add commas
mw.log( p.__priv.formatLargeNum(12) )
local withCommas = (tostring(withoutCommas)
mw.log( p.__priv.formatLargeNum(123) )
:reverse() -- flip backwards
 
:gsub("%d%d%d", "%0,") -- comma every 3 digits
mw.log("should preserve fractional part for small numbers")
:reverse() -- flip rightwards
mw.log( p.__priv.formatLargeNum(1.1) )
:gsub("^,", "") -- don't start with a comma
mw.log( p.__priv.formatLargeNum(1.12) )
.. plusDecimal -- decimal or ""
mw.log( p.__priv.formatLargeNum(12.1) )
)
mw.log("can preserve long fractional part")
local label = ""
mw.log( p.__priv.formatLargeNum(1.1234) )
return withCommas
mw.log( p.__priv.formatLargeNum(1.12345) )
end
mw.log("should add separators above 1k")
mw.log( p.__priv.formatLargeNum(999) )
mw.log( p.__priv.formatLargeNum(1234) )
mw.log( p.__priv.formatLargeNum(12345) )
mw.log( p.__priv.formatLargeNum(123456) )
mw.log( p.__priv.formatLargeNum(1234567) )
 
mw.log("must handle large float, but might round values")
mw.log( p.__priv.formatLargeNum(1234.123) )
mw.log( p.__priv.formatLargeNum(12345.123) )
mw.log( p.__priv.formatLargeNum(123456.123) )
mw.log( p.__priv.formatLargeNum(1234567.123) )
end
 
Line 524 ⟶ 593:
 
Typical tpl:
"Abc$L: $v" (same as: "$label: $auto")
will result in:
"Abc: 23%" -- when values are percentages
Line 530 ⟶ 599:
Advanced tpl:
"Abc$L: $d ($p)" --e.g. only"Abc: works1234 with(23%)" autoscalefor {"label":"Abc", "value":1234}
"$L: $v" is the same as above when values are autoscaled
Long vs short variable names:
$label ($L)
$auto ($v)
$value ($d)
$percent ($p)
]]
function priv.prepareLabel(tpl, entry)
-- staticsetup default tpl
if not tpl andor not string.find(tpl, '$')== "" then
-- simple if no label
return tpl
if not entry.label or entry.label == "" then
tpl = "$v"
else
if entry.label:find("%$[a-z]") then
tpl = entry.label
else
tpl = "$L: $v"
end
end
end
 
local labelLabel = entry.label and entry.label or priv.getLangOther()
 
-- format % value without %
local ppRaw = priv.formatNum(entry.value)
local pp = pRaw .. "%%"
local label = tpl
-- aliases
label = label
:gsub("%$label", "$L")
:gsub("%$auto", "$v")
:gsub("%$value", "$d")
:gsub("%$percent", "$p")
-- replace variables
label = label:gsub("%$L", labelLabel)
local d = priv.formatLargeNum(entry.raw and entry.raw or entry.value)
local v = entry.raw and (d .. " (" .. pp .. ")") or pp
label = label
:gsub("%$p", pp)
:gsub("%$d", d)
:gsub("%$v", v)
 
-- Report unknown variables
-- default template
for var in label:gmatch("%$[a-zA-Z]+") do
if not tpl then
tpl-- =in "$v"preview
mw.addWarning("pie chart: Unknown variable (wrong format of your label): " .. var)
end
-- tracing links
_ = mw.title.new("Module:Piechart/tracing/unknown-variable").id
if entry.raw then
local d = priv.formatLargeNum(entry.raw)
label = tpl:gsub("%$p", p .. "%%"):gsub("%$d", d):gsub("%$v", d .. " (" .. p .. "%%)")
else
label = tpl:gsub("%$v", p .. "%%")
end
 
return label
end
Line 582 ⟶ 683:
if (type(entry.color) == "string") then
-- Remove unsafe characters from entry.color
local sanitizedColor = entry.color:gsub("[^a-zA-Z0-9#%-]", "")
:gsub('&#35;', '#') -- workaround Module:Political_party issue reported on talk
:gsub("[^a-zA-Z0-9#%-]", "")
return 'background:' .. sanitizedColor
else
Line 697 ⟶ 800:
function p.parseEnumParams(frame)
local args = frame:getParent().args
return priv.parseEnumParams(args)
end
function priv.parseEnumParams(args)
local result = {}
Line 721 ⟶ 827:
end
-- re-loop to set values in labels
local willAutoscale = priv.willAutoscale(sum)
for _, entry in ipairs(result) do
local label = entry.label
if label and not label:find("%$v[a-z]") then
-- autoscale will be forced, so use $v in labels
if sum > 100willAutoscale then
entry.label = label .. " $v"
else
entry.label = label .. " (" .. entry.value .. "%$p)"
end
end
end
-- tracking data errors
priv.sumErrorTracking(sum, result)
 
-- support other value mapping
local langlangOther = mwpriv.language.getContentLanguagegetLangOther()
local langOther = "Other"
if (lang:getCode() == 'pl') then
langOther = "Inne"
end
local colorOther = "#FEFDFD" -- white-ish for custom colors for best chance and contrast
local otherValue = 100 - sum
if args["other"] and args["other"] ~= "" then
if otherValue < 0.001 then
local value = 100 - sum
otherValue = 0
if value < 0 then
value = 0
end
local otherEntry = { label = (args["other-label"] or langOther) .. " ("..priv.formatNum(value).."%$p)" }
if args["other-color"] and args["other-color"] ~= "" then
otherEntry.color = args["other-color"]
Line 753 ⟶ 858:
end
table.insert(result, otherEntry)
elseif sumotherValue <> 1000.01 then
if hasCustomColor then
table.insert(result, {visible = false, label = langOther .. " ($v)", color = colorOther})
Line 763 ⟶ 868:
local jsonString = mw.text.jsonEncode(result)
return jsonString
end
 
function priv.getLangOther()
-- support other value mapping
local lang = mw.language.getContentLanguage()
if (lang:getCode() == 'pl') then
return "Inne"
end
return "Other"
end
 
Line 793 ⟶ 907:
-- explicit meta param
if args["meta"] then
local decodeSuccess, tempMeta = pcall(function()
meta = mw.text.jsonDecode(args["meta"], mw.text.JSON_TRY_FIXING)
return mw.text.jsonDecode(args["meta"], mw.text.JSON_TRY_FIXING)
end)
if not decodeSuccess then
mw.log('invalid meta parameter')
else
meta = tempMeta
end
end
 
Line 815 ⟶ 936:
if args["footer"] and args["footer"] ~= "" then
meta.footer = args["footer"]
end
if args["labelformat"] and args["labelformat"] ~= "" then
meta.labelformat = args["labelformat"]
end