Edge Rewrite
// HTMLRewriter · presentation

This page was redesigned at the edge.

Cloudflare fetched the original article and streamed it through HTMLRewriter to apply an entirely new visual system without rebuilding the source page.

Jump to content

Module:Legislature diagram

Permanently protected module
From Wikipedia, the free encyclopedia

local p = {}

local getArgs = require('Module:Arguments').getArgs

local partyFetch = require('Module:Political party')._fetch
local groupingsModule = require('Module:Political groupings')
local yesno = require('Module:Yesno')

local buildTable = groupingsModule._buildTable

-- helper functions for a port from python
local function sum(of)
    local total = 0
    for i, v in pairs(of) do
        total = total + v
    end
    return total
end

local function max(a, b)
    if a > b then return a end
    return b
end

local function round(x)
    if x >= 0 then
        return math.floor(x + 0.5)
    else
        return math.ceil(x - 0.5)
    end
end

local function int(x)
    return x >= 0 and math.floor(x) or math.ceil(x)
end

local function len(of)
    return #of
end

-- seat positioning code is a lua port of geometry.py in
-- https://github.com/Gouvernathor/parliamentarch/tree/main
-- which has now been dual licenced with CC-BY-SA-4.0

-- default angle, in degrees, coming from the rightmost seats
-- through the center to the leftmost seats
local DEFAULT_SPAN_ANGLE = 180

local function get_row_thickness(nrows)
	-- Returns the thickness of a row in the same unit as the coordinates.
	return 1 / (4*nrows - 2)
end

local function get_rows_from_nrows(nrows, span_angle)
	-- This indicates the maximal number of seats for each row for a given number of rows.
	-- Returns a list of number of seats per row, from inner to outer.
	-- The length of the list is nrows.
	-- span_angle, if provided, is the angle in degrees that the hemicycle, as an annulus arc, covers.
	span_angle = span_angle or DEFAULT_SPAN_ANGLE

	local rv = {}

	-- thickness of a row (as an annulus) compared to the outer diameter of the hemicycle
	-- this is equal to the diameter of a single seat
	local rad = get_row_thickness(nrows)
	-- if you divide the half-disk of the hemicycle
	-- into one half-disk of half the radius
	-- and one half-annulus outside it,
	-- the innermost row lies on the border between the two,
	-- and the outermost row lies entirely inside the half-annulus.
	-- So, looking at the line cutting the circle and the annulus in half
	-- (which is the bottom border of the diagram),
	-- all rows minus one half of the innermost are on the left, same on the right,
	-- and the radius of the void at the center is equal to that value again.
	-- So, total = 4 * (nrows-.5) = 4*nrows - 2

	local radian_span_angle = math.pi*span_angle/180

	for r = 0, nrows-1 do
		-- row radius : the radius of the circle crossing the center of each seat in the row
		local row_arc_radius = .5 + 2*r*rad

		table.insert(rv, int(radian_span_angle*row_arc_radius/(2*rad)))

	end

	return rv
    
end

local function get_nrows_from_nseats(nseats, span_angle)
    -- Returns the minimal number of rows necessary to contain nseats seats.
	span_angle = span_angle or DEFAULT_SPAN_ANGLE

	local i = 1
	while sum(get_rows_from_nrows(i, span_angle)) < nseats do
		i = i + 1
	end

	return i

end

local DEFAULT = 0
-- The seats are distributed among all the rows,
-- proportionally to the maximum number of seats each row may contain.

local EMPTY_INNER = 1
-- Selects as few outermost rows as necessary, then distributes the seats among them,
-- proportionally to the maximum number of seats each row may contain.

local OUTER_PRIORITY = 2
-- Fills up the rows as much as possible, starting with the outermost ones.

local function get_seats_centers(nseats, args)

	-- Returns a list of nseats seat centers as (angle, x, y) tuples.
	-- The canvas is assumed to be of 2 in width and 1 in height, with the y axis pointing up.
	-- The angle is calculated from the (1., 0.) center of the hemicycle, in radians,
	-- with 0° for the leftmost seats, 90° for the center and 180° for the rightmost.

	-- The minimum number of rows required to contain the given number of seats
	-- will be computed automatically.
	-- If min_nrows is higher, that will be the number of rows, otherwise the parameter is ignored.
	-- Passing a higher number of rows will make the diagram sparser.

	-- seat_radius_factor should be between 0 and 1,
	-- with seats touching their neighbors in packed rows at seat_radius_factor=1.
	-- It is only taken into account when placing the seats at the extreme left and right of the hemicycle
	-- (which are the seats at the bottom of the diagram),
	-- although the placement of these seats impacts in turn the placement of the other seats.

	-- span_angle is the angle in degrees from the rightmost seats,
	-- through the center, to the leftmost seats.
	-- It defaults to 180° to make a true hemicycle.
	-- Values above 180° are not supported.

	args = args or {}

	local min_nrows = args.min_nrows or 0
	local filling_strategy = args.filling_strategy or DEFAULT
	local span_angle = args.span_angle or DEFAULT_SPAN_ANGLE

	local nrows = max(min_nrows, get_nrows_from_nseats(nseats, span_angle))
	-- thickness of a row in the same unit as the coordinates
	local row_thicc = get_row_thickness(nrows)
	local span_angle_margin = (1 - span_angle/180)*math.pi /2

	local maxed_rows = get_rows_from_nrows(nrows, span_angle)

	local starting_row, filling_ratio, seats_on_starting_row

	if filling_strategy == DEFAULT then
		starting_row = 0
		filling_ratio = nseats/sum(maxed_rows)
	elseif filling_strategy == EMPTY_INNER then
--         case FillingStrategy.EMPTY_INNER:
--             rows = list(maxed_rows)
--             while sum(rows[1:]) >= nseats:
--                 rows.pop(0)
--             # here, rows represents the rows which are enough to contain nseats,
--             # and their number of seats

--             # this row will be the first one to be filled
--             # the innermore ones are empty
--             starting_row = nrows-len(rows)
--             filling_ratio = nseats/sum(rows)
--             del rows            
	elseif filling_strategy == OUTER_PRIORITY then
--         case FillingStrategy.OUTER_PRIORITY:
--             rows = list(maxed_rows)
--             while sum(rows) > nseats:
--                 rows.pop(0)
--             # here, rows represents the rows which will be fully filled,
--             # and their number of seats

--             # this row will be the only one to be partially filled
--             # the innermore ones are empty, the outermore ones are fully filled
--             starting_row = nrows-len(rows)-1
--             seats_on_starting_row = nseats-sum(rows)
--             del rows

--         case _:
--             raise ValueError(f"Unrecognized strategy : {filling_strategy}")
	end

	local positions = {}
	for r = starting_row, nrows - 1 do
		local nseats_this_row

		if r == nrows - 1 then -- if it's the last, outermost row
		   -- fit all the remaining seats
			nseats_this_row = nseats-len(positions)
		elseif filling_strategy == OUTER_PRIORITY then
			if r == starting_row then
				nseats_this_row = seats_on_starting_row -- TODO: seats_on_starting_row is never set
			else
				nseats_this_row = maxed_rows[r + 1] 
				-- MORWEN NOTE: FUCKING LUA ONE BASED ARRAYS I HATE IT SO MUCH
			end
		else
			-- fullness of the diagram times the maximum number of seats in the row
			nseats_this_row = round(filling_ratio * maxed_rows[r + 1])
			-- actually more precise rounding : avoid rounding errors to accumulate too much
			-- nseats_this_row = round((nseats-len(positions)) * maxed_rows[r]/sum(subrange(maxed_rows, r))
		end

		-- row radius : the radius of the circle crossing the center of each seat in the row
		local row_arc_radius = .5 + 2*r*row_thicc

		local angle_margin, angle_increment, angle
		
		if nseats_this_row == 1 then
			table.insert(positions, {x=1, y=row_arc_radius, theta=math.pi/2})
		else
			-- the angle necessary in this row to put the first (and last) seats fully in the canvas
			angle_margin = math.asin(row_thicc/row_arc_radius)
			-- add the margin to make up the side angle
			angle_margin = angle_margin + span_angle_margin
			-- alternatively, allow the centers of the seats by the side to reach the angle's boundary
			-- angle_margin = max(angle_margin, span_angle_margin)

			-- the angle separating two seats of that row
			angle_increment = (math.pi-2*angle_margin) / (nseats_this_row-1)
			--  a fraction of the remaining space,
			-- keeping in mind that the same elevation on start and end limits that remaining place to less than 2pi

			for s = 0, nseats_this_row - 1 do
				angle = angle_margin + s*angle_increment
				-- an oriented angle, so it goes trig positive (counterclockwise)
				table.insert(positions, {
						x=row_arc_radius*math.cos(angle)+1,
						y=row_arc_radius*math.sin(angle),
						theta=angle})
			end
		end
	end

	return positions

end

local function getWidth(args, seats)
	if args.width then
		return args.width
	end

	if seats <= 29 then
		return 280
	elseif seats <= 100 then
		return 300
	else
		return 350
	end
end


-- -- Linearizes an 8-bit sRGB channel per the W3C WCAG 2.x standard
-- -- Source: https://www.w3.org/TR/WCAG20/#relativeluminancedef
-- local function linearize(c)
--     c = c / 255.0
--     if c <= 0.04045 then
--         return c / 12.92
--     else
--         return ((c + 0.055) / 1.055) ^ 2.4
--     end
-- end


-- -- Calculates relative luminance (L) from a standard hex string
-- local function getLuminance(hex)
--     -- Sanitize input by stripping '#'
--     hex = hex:gsub("^#", "")
    
--     -- -- Expand 3-digit hex to 6-digit (e.g., "F00" -> "FF0000")
--     -- if #hex == 3 then
--     --     hex = hex:gsub("(.)", "%1%1")
--     -- end
    
--     -- Parse hexadecimal to decimal
--     local r = tonumber(hex:sub(1, 2), 16)
--     local g = tonumber(hex:sub(3, 4), 16)
--     local b = tonumber(hex:sub(5, 6), 16)

--     -- Apply human optical sensitivity weights to the linearized channels
--     return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
-- end


local function getLuminance(color)
	local luminance = require('Module:Color contrast')._lum(color)
	if type(luminance) ~= "number" then
		return 1 -- white
	end

	return luminance
end


-- Native Lua entry point
local function getBorderColor(circleColor)
    local L_circle = getLuminance(circleColor)
    
    -- Algorithm #1: Optimal contrasting color against the text itself
    -- If L > 0.1791, the color is light, so black contrasts best.
    local optimalTextContrast = (L_circle > 0.1791) and "black" or "white"
    
    -- Contrast ratio against pure white (L = 1.0) and pure black (L = 0.0)
    -- Formula: (Lighter + 0.05) / (Darker + 0.05)
    local crAgainstWhite = 1.05 / (L_circle + 0.05)
    local crAgainstBlack = (L_circle + 0.05) / 0.05
    
    -- The WCAG AA standard for normal text is a ratio of 4.5:1. 
    -- If the text passes against the background, no border is strictly needed.
    -- If it fails, the border must be the inverse of the background to be visible.
	-- written in this manner in case we want to make it a standalone module or something (see commented-out function below)
    local d = {
        luminance = L_circle,
        idealBorderColor = optimalTextContrast,
        
        whiteBg = {
            needsBorder = crAgainstWhite < 4.5,
            borderColor = "#000000"
        },
        
        blackBg = {
            needsBorder = crAgainstBlack < 4.5,
            borderColor = "#F8F9FA"
        }
    }
    
	if d.blackBg.needsBorder then
		return d.blackBg.borderColor
	elseif d.whiteBg.needsBorder then
		return d.whiteBg.borderColor
	end
	return nil
end


-- -- if we wanted to make this a standalone module or something
-- function p.getBorder(frame)
--     local hex = frame.args[1] or frame.args.color or "FFFFFF"
--     local bgType = frame.args.bg or "white" -- Expects "white" or "black"
    
--     local logic = _getBorderLogic(hex)
    
--     if bgType:lower() == "black" then
--         if logic.blackBg.needsBorder then
--             return logic.blackBg.borderColor
--         else
--             return "none"
--         end
--     else
--         if logic.whiteBg.needsBorder then
--             return logic.whiteBg.borderColor
--         else
--             return "none"
--         end
--     end
-- end


local function makeLegislature(args, parsed)
	local working = {}

	for _, party in ipairs(parsed.parties) do
		for n = 1, parsed.seatCounts[party] do
			table.insert(working, party)
		end
	end
	for n = 1, parsed.vacancies do
		table.insert(working, "Vacancy")
	end

	local svg = mw.svg.new()

	local circles = {}

    local seats = get_seats_centers(#working)

	table.sort(seats, function(a, b)
		return a.theta > b.theta
	end)

	local size = get_row_thickness(get_nrows_from_nseats(#working))
	
	-- 1. Check if the user passed the optional parameter
	local use_toolforge = true
	if args.toolforge_style ~= nil then
		use_toolforge = yesno(args.toolforge_style)
	end
	local radius_multiplier = use_toolforge and 0.8 or 0.9 -- Decrease radius from 0.9 to 0.8 to match the Toolforge spacing
	
	for i, v in ipairs(working) do
		
		local circle = mw.html.create('circle')
			:attr('cx', seats[i].x)
			:attr('cy', -seats[i].y)
			:attr('r', size * radius_multiplier)
			:attr('fill', parsed.partyColors[v] or "white")

		local border = parsed.borderColors[v]

		if border then
			circle = circle:css('stroke', border)
				-- :css('stroke-width', 'max(2px, 0.3162%)') -- .005 but scaled for our hardcoded viewbox below
				:css('stroke-width', .005)
		else
			-- local stroke_width = 'max(2px, 0.3162%)' -- .005
			local stroke_width = .005
			if #seats >= 250 and #seats < 600 then
				-- stroke_width = 'max(2px, 0.2213%)' -- .0035
				stroke_width = .0035
			elseif #seats >= 600 then
				-- stroke_width = 'max(2px, 0.1581%)' -- .0025
				stroke_width = .0025
			end
			
			-- 2. Only draw a black stroke if the seat is a vacancy or
			-- toolforge_style is false
			local black_border_only_for_vacancies =
				args.black_border_only_for_vacancies == "y" or
				args.black_border_only_for_vacancies == "yes" or
				args.black_border_only_for_vacancies == "true" or
				args.black_border_only_for_vacancies == "1" or
				args.black_border_only_for_vacancies == 1
			if black_border_only_for_vacancies then
				if (v == "Vacancy" or v == "Vacant Party (US)") then
					circle = circle:css('stroke', 'black')
						:css('stroke-width', .005) -- in this mode, hardcode .005 bc it's supposed to mimic the legacy toolforge style (probably should rename the argument to reflect that intent)
				end
			else
				circle = circle:css('stroke', getBorderColor(parsed.partyColors[v]))
					:css('stroke-width', stroke_width)
			end
		end
		
		table.insert(circles, tostring(circle:done()))
	end
	
	-- 3. Inject the center text node with the total number of seats but only if toolforge_style is true
	if use_toolforge then
		local center_text = mw.html.create('text')
			:attr('x', 1)
			:attr('y', -(5/175)) -- Exactly 170/175 down the canvas
			:attr('text-anchor', 'middle')
			:css('font-size', tostring(36/175) .. 'px') -- Exactly the Python font factor
			:css('font-family', 'sans-serif')
			:css('font-weight', 'bold')
			:css('fill', 'black')
    		:css('stroke', '#F8F9FA')
    		:css('stroke-width', '0.015')
    		:css('paint-order', 'stroke')
			:wikitext(#working)
		table.insert(circles, tostring(center_text:done()))
	end

	local box = svg:setAttribute( 'viewBox', '0 -1 2 1' )
		:setContent( table.concat(circles) )
		:setImgAttribute( 'alt', 'Graph of the party split among ' .. #seats .. " seats." )

	local width = getWidth(args, #seats)
	
	if width then
		box = box:setImgAttribute('width', tostring(width))
	end
	
	return box
end
	
local function drawLegislature(args, parsed)

	local svg = makeLegislature(args, parsed)
	
	return svg:toImage() -- Gives the image
end

local function generateCaption(root, _args, data)
	
	local list = root:tag('ul')
	list:css('column-count', '2')
	list:css('margin-left', '0')

	for i, v in ipairs(data.parties) do
		list:tag('li') --li
			:css('list-style-type', 'none')
			:css('list-style-image', 'none')
			:css('margin', '0')
				:tag('span') --span
				:css('border', 'none')
				:css('width', '1.2em')
				:css('height', '1.2em')
				:css('background-color', data.partyColors[v] or "white")
				:css('margin-right', '0.3em')
				:css('vertical-align', 'middle')
				:css('display', 'inline-block')
				:done()
			:wikitext(v .. ': ' .. data.seats[v])
			:done()
	end
	
	return list
end

local function legislatureFloat(args, data)
	local root = mw.html.create('div')

	local width = getWidth(args, data.total)
	local mainDiv = root:tag('div')
	
	mainDiv:addClass('thumb')
	mainDiv:addClass('t'..args['float'])
	
	local divThumbinner = mainDiv:tag('div')
	divThumbinner:addClass('thumbinner')
	divThumbinner:css('width', tostring(width*1.125)..'px')
	divThumbinner:css('padding-right', '6.5px !important')
	
	if args['title'] then
		local divTitle = divThumbinner:tag('div')
		divTitle:addClass('thumbdescription')
		divTitle:css('font-weight', 'bold')
		divTitle:css('text-align', 'center')
		divTitle:css('font-size', '100%')
		divTitle:wikitext(args['title'])
	end
	
	local divThumbimage = divThumbinner:tag('div')
	divThumbimage:addClass('thumbimage')
	divThumbimage:addClass('diagram')
	divThumbimage:addClass('noprint')
	divThumbimage:css('width', tostring(width)..'px')
	divThumbimage:css('height', tostring(width/2)..'px')
	divThumbimage:css('padding', tostring(width/15)..'px')
	divThumbimage:css('overflow', 'hidden')
	divThumbimage:css('background', args.background)

	local svg = makeLegislature(args, data)

	divThumbimage:wikitext(tostring(svg:toImage()))
	
	local divThumbcaption = divThumbinner:tag('div')
	divThumbcaption:addClass('thumbcaption')
	divThumbcaption:wikitext('Total '..tostring(data.total)..' seats')
	
	generateCaption(divThumbcaption, args, data)
	
	return root
end

p._drawLegislature = drawLegislature

function p.drawLegislature(frame)
    local args = getArgs(frame, {
        wrappers = {'Template:Legislature diagram', 'Template:Parliament diagram', 'Template:Seats diagram'}
    })

	local parsed = buildTable(args)

	if args.float then
		return legislatureFloat(args, parsed)
	end

    return p._drawLegislature(args, parsed)
end

return p