Jump to content

Module:Piechart/sandbox

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Johnuniq (talk | contribs) at 07:45, 27 February 2025 (remove global variables including a temp fix). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
local p = {}
-- 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.

	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)
]]

-- Forward declarations for globals (this is temporary pending moving functions so they are defined before usage).
local backColor
local defaultColor
local defaultColorNo
local entryJson
local formatNum
local isTrueishValue
local prepareLabel
local prepareSlice
local renderErrors
local renderOther
local rotation
local round
local sliceBase
local sliceWithClass
local sliceX
local sumValues
local trim

--[[
	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 = {}
	if (frame.args.meta) then
		options.meta = trim(frame.args.meta)
	end

	local html = p.renderPie(json_data, options)
	return trim(html)
end

-- Setup chart options.
function p.setupOptions(user_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 = "",
		-- width of the main container
		-- when direction is used defaults to max-width, otherwise it's not added
		width = "",
		-- caption above the labels
		caption = "",
		-- footer below the labels
		footer = "",
	}
	-- internals
	options.style = ""
	if user_options.meta then
		local rawOptions = mw.text.jsonDecode(user_options.meta, mw.text.JSON_TRY_FIXING)
		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 = 'flex-direction: ' .. sanitized .. ';'
				options.width = 'width: max-content;'
			end
			if (type(rawOptions.width) == "string") then
				-- note, this intentionaly overwrites what was set for direction
				local sanitized = rawOptions.width:gsub("[^a-z0-9%-]", "")
				options.width = 'width: ' .. sanitized .. ';'
			end
			if (type(rawOptions.caption) == "string") then
				options.caption = rawOptions.caption
			end
			if (type(rawOptions.footer) == "string") then
				options.footer = rawOptions.footer
			end
		end
		-- build style
		if options.width ~= "" then
			options.style = options.style .. options.width
		end
		if options.direction ~= "" then
			options.style = options.style .. options.direction
		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, user_options)
	local data = mw.text.jsonDecode(json_data, mw.text.JSON_TRY_FIXING)
	local options = p.setupOptions(user_options)

	-- prepare
	local ok, total = p.prepareEntries(data, options)

	-- init render
	local html = "<div class='smooth-pie-container' style='"..options.style.."'>"

	-- 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).
-- @param no = 1..total
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)
	-- background, but also color for MW syntax linter
	entry.bcolor = backColor(entry, no, total) .. ";color:#000"

	return true
end

-- render legend for pre-processed entries
function p.renderLegend(data, options)
	local function renderLegendItem(entry, options)
		-- invisible value (for a11y reasons this should not be used for important values!)
		if entry.visible ~= nil and entry.visible == false then
			return ""
		end

		local label = entry.label
		local bcolor = entry.bcolor
		local html = "\n<li>"
		html = html .. '<span class="l-color" style="'..forPrinting..bcolor..'"></span>'
		html = html .. '<span class="l-label">'..label..'</span>'
		return html .. "</li>"
	end
	local html = ""
	if options.caption ~= "" or options.footer ~= "" then
		html = "\n<div class='smooth-pie-legend-container'>"
	end
	if options.caption ~= "" then
		html = html .. "<div class='smooth-pie-caption'>" .. options.caption .. "</div>"
	end
	html = 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
	html = html .. "\n</ol>\n"
	if options.footer ~= "" then
		html = html .. "<div class='smooth-pie-footer'>" .. options.footer .. "</div>"
	end
	if options.caption ~= "" or options.footer ~= "" then
		html = html .. "</div>\n"
	end
	return html
end

-- Prepare data (slices etc)
function p.renderEntries(ok, total, data, options)
	-- final, but header...
	local 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..';'..forPrinting
		local html = [[
	<div class="smooth-pie"
		style="]]..style..[["
		title="]]..p.extract_text(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
	-- 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

-- 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="'..p.extract_text(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="'..p.extract_text(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 = ""
	if (value < 10) then
		v = string.format("%.2f", value)
	else
		v = string.format("%.1f", value)
	end
	if (lang:getCode() == 'pl') then
		v = v:gsub("%.", ",")
	end
	return v
end
p._formatNum = formatNum

--[[
	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
-- source: https://colorbrewer2.org/#type=diverging&scheme=PRGn&n=6
local colorGroupSize = 3 -- must be at least 3
local colorGroups = 4
local colorPalette = {
-- green (from dark)
'#1b7837',
'#7fbf7b',
'#d9f0d3',
-- violet
'#762a83',
'#af8dc3',
'#e7d4e8',
-- red
'#d73027',
'#fc8d59',
'#fee090',
-- blue
'#4575b4',
'#91bfdb',
'#e0f3f8',
}
local lastColor = '#fff'
-- background color from entry or the default colors
function backColor(entry, no, total)
	if (type(entry.color) == "string") then
		-- Remove unsafe characters from entry.color
		local sanitizedColor = entry.color:gsub("[^a-zA-Z0-9#%-]", "")
		return 'background:' .. sanitizedColor
	else
		local color = defaultColor(no, total)
		return 'background:' .. color
	end
end
-- color from the default colors
function defaultColor(no, total)
	local color = lastColor
	if no <= 0 then
		return color
	end
	local size = #colorPalette
	if not total or total == 0 then
		total = size + 1
	end
	local colorNo = defaultColorNo(no, total, size)
	if colorNo > 0 then
		color = colorPalette[colorNo]
	end
	return color
end
-- gets color number from default colors
-- trys to return a light color as the last one
-- 0 means white-ish color should be used
function defaultColorNo(no, total, size)
	local color = 0 -- special, lastColor
	if total == 1 then
		color = 1
	elseif total <= colorGroupSize * (colorGroups - 1) then
		if no < total then
			color = no
		else
			local groupIndex = ((no - 1) % colorGroupSize)
			if groupIndex == 0 then -- dark
				color = no+1
			elseif groupIndex == 1 then -- med
				color = no+1
			else
				color = no
			end
		end
	elseif no < total then
		color = ((no - 1) % size) + 1
	end
	return color
end
--[[
	Testing defaultColorNo:
	p.test_defaultColorNo(1, 12)
	p.test_defaultColorNo(2, 12)
	p.test_defaultColorNo(3, 12)
	p.test_defaultColorNo(4, 12)
	p.test_defaultColorNo(5, 12)
	p.test_defaultColorNo(6, 12)
]]
function p.test_defaultColorNo(total, size)
	for no=1,total do
		local color = defaultColorNo(no, total, size)
		mw.logObject({no=no, color=color})
	end
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

--[[
  Extract text from simple wikitext.

  For now only works with links.
]]
-- Tests:
-- mw.log(p.extract_text("[[candy|sweets]]: $v"))
-- mw.log(p.extract_text("[[sandwich]]es: $v"))
-- mw.log(p.extract_text("sandwich]]es: $v"))
-- mw.log(p.extract_text("sandwiches: $v"))
function p.extract_text(label)
	label = label
		-- replace links with pipe (e.g., [[candy|sweets]])
		:gsub("%[%[[^|%]]+|(.-)%]%]", "%1")
		-- replace simple links without pipe (e.g., [[sandwich]])
		:gsub("%[%[(.-)%]%]", "%1")
		-- remove templates?
		-- :gsub("{.-}", "")
		-- remove tags
		:gsub("<[^>]+>", "")
		-- escape special chars just in case
		:gsub("<", "&lt;"):gsub(">", "&gt;")
		:gsub("'", "&#39;"):gsub("\"", "&quot;")
	return label
end

--[[
  Parse classic template params into JSON.

From:
|label1=cookies: $v |value1=11 |color1=goldenrod
|label2=sweets: $v |value2=20 |color2=darkred

To:
{"value":11,"color":"goldenrod","label":"cookies: $v"},
{"value":20,"color":"darkred","label":"sweets: $v"},

]]
function p.parseEnumParams(frame)
	local args = frame:getParent().args
	local result = {}

	local i = 1
	local sum = 0.0
	local hasCustomColor = false -- has last custom color
	while args["value" .. i] do
		-- value is required in this mode; it's also assumed to be 0..100
		local entry = { value = tonumber(args["value" .. i]) or 0 }
		-- label and color is optional
		local label = args["label" .. i]
		if label and label ~= "" then
			entry.label = label
		end
		hasCustomColor = false
		local color = args["color" .. i]
		if color and color ~= "" then
			entry.color = color
			hasCustomColor = true
		end
		table.insert(result, entry)
		sum = sum + entry.value
		i = i + 1
	end
	-- re-loop to set values in labels
	for _, entry in ipairs(result) do
		local label = entry.label
		if label and not label:find("%$v") then
			-- autoscale will be forced, so use $v in labels
			if sum > 100 then
				entry.label = label .. " $v"
			else
				entry.label = label .. " (" .. entry.value .. "%)"
			end
		end
	end

	-- support other value mapping
	local lang = mw.language.getContentLanguage()
	local langOther = "Other"
	if (lang:getCode() == 'pl') then
		langOther = "Inne"
	end
	local colorOther = "#FEFDFD" -- white-ish for custom colors for best chance and contrast

	if args["other"] and args["other"] ~= "" then
		local value = 100 - sum
		if value < 0 then
			value = 0
		end
		local otherEntry = { label = (args["other-label"] or langOther) .. " ("..formatNum(value).."%)" }
		if args["other-color"] and args["other-color"] ~= "" then
			otherEntry.color = args["other-color"]
		else
			otherEntry.color = colorOther
		end
		table.insert(result, otherEntry)
	elseif sum < 100 then
		if hasCustomColor then
			table.insert(result, {visible = false, label = langOther .. " ($v)", color = colorOther})
		else
			table.insert(result, {visible = false, label = langOther .. " ($v)"})
		end
	end

	local jsonString = mw.text.jsonEncode(result)
	return jsonString
end

-- Function to check if a value is true-ish
local trueValues = { ["true"] = true, ["1"] = true, ["on"] = true, ["yes"] = true }
function isTrueishValue(value)
	-- should return nil for empty args (i.e. undefined i.e. default)
	if not value or value == "" then return nil end
	value = trim(value)
	if value == "" then return nil end
	-- other non-empty are false
	return trueValues[value:lower()] or false
end

--[[
  Parse classic template params into JSON with chart meta data.
]]
function p.parseMetaParams(frame)
	local args = frame:getParent().args
	local meta = {}

	-- default meta for value1..n parameters
	-- ...and for thumb right/left
	local thumb = args["thumb"]
	if args["value1"] or (thumb and (thumb == "right" or thumb == "left")) then
		meta.direction = "column-reverse"
		meta.width = "min-content"
		meta.size = 200
		meta.legend = true
	end

	-- explicit meta param
	if args["meta"] then
		meta = mw.text.jsonDecode(args["meta"], mw.text.JSON_TRY_FIXING)
	end


	if args["size"] then meta.size = tonumber(args["size"]) end
	if args["radius"] and tonumber(args["radius"]) then
		meta.size = 2 * tonumber(args["radius"])
	end
	if args["autoscale"] then meta.autoscale = isTrueishValue(args["autoscale"]) end
	if args["legend"] then meta.legend = isTrueishValue(args["legend"]) end
	if args["ariahidechart"] then meta.ariahidechart = isTrueishValue(args["ariahidechart"]) end
	if args["direction"] and args["direction"] ~= "" then
		meta.direction = args["direction"]:gsub("[^a-z0-9%-]", "")
	end
	if args["width"] and args["width"] ~= "" then
		meta.width = args["width"]:gsub("[^a-z0-9%-]", "")
	end
	if args["caption"] and args["caption"] ~= "" then
		meta.caption = args["caption"]
	end
	if args["footer"] and args["footer"] ~= "" then
		meta.footer = args["footer"]
	end

	return mw.text.jsonEncode(meta)
end

return p