Module:Legislature diagram
Appearance
| This module depends on the following other modules: |
This module implements {{Legislature diagram}}. Please see the template page for documentation.
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