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.

// request.cf · coarse context

A page that knows where it met you.

Only coarse request metadata is shown. This demo does not display or persist visitor IP addresses.

Country
US
Cloudflare location
CMH
Connection
HTTP/2
Language
Not provided

Ray ID: a2263f55e98807d6

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