Module:Legislature diagram
| 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
Content Disclaimer
Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.
- The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
- There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
- It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
- Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
- Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.