Suara ke Kursi
An interactive model of Indonesia's 580-seat parliament where you change the 2024 election rules and watch seats move

- valid votes that won no seat in 2024
- 17,304,303
- seats across 84 districts, recalculated live
- 580
- rule controls, each saved in a shareable link
- 4
- accessibility violations at three screen widths
- 0
On this page
The problem
Journalists, students and legislative staff reading the new election bill hit the same wall. Indonesia turns votes into seats with two rules few people can work through by hand. A party below the 4% national threshold gets no seats anywhere, even where it came first. Each of the 84 districts then shares out its seats by the Sainte-Laguë method, dividing party votes by 1, 3, 5, 7 and onward.
In 2024, 18 parties took part and 8 cleared the threshold. 17,304,303 valid votes produced no seat. PPP, a party already in parliament, missed the threshold by about 0.13 percentage points. The Constitutional Court held the 4% figure valid for 2024 only and asked lawmakers to set a new one before 2029 that keeps wasted votes low. That is a calculation, and no public tool performed it.
What I built
The seat maths lives in one small, checkable function
I kept the allocation engine apart from everything else. It takes party votes, district data and a set of rules, and returns the full result. It has no dependencies, no randomness and no clock. The same code runs in a plain script outside the browser, so anyone can check the numbers without trusting the interface.
The explanation shows the actual arithmetic
Every seat the engine awards records the full table of quotients at the moment it was won. The "show your work" view draws that record directly and never recalculates. For any district, you can step through the seats one at a time and read each division. The picture can't drift away from the maths, because it is the maths.
Seats move when the rules change
The main view is a 580-seat chamber. Drag the threshold from 4.0% toward zero and individual seats travel from one party's block to another. The engine recalculates all 84 districts in 0.23 milliseconds, so it can rerun on every animation frame. I drew all 580 seats in a single animation loop, which keeps the motion smooth on a phone. When the slider passes 3.87%, PPP enters the chamber.
It has to match 2024 before it can show a what-if
I set a rule that the app must reproduce the certified 2024 result before it offers any hypothetical, and the check blocks the build. The per-district vote table exists only in scanned election commission PDFs, and I haven't finished extracting it. So the header says plainly that the official result is not yet reproduced. National totals use certified figures, and the wasted-vote count matches Perludem's independent tally to the vote.
Every rule points to the law
Each rule cites its article in Law 7/2017, and each headline figure cites its election commission document. The citation opens in a panel you can reach by keyboard and read at leisure. The copy states outcomes in neutral terms, such as "ten parties won no seats".
Result
Suara ke Kursi is live, public and in Indonesian. It has four rule controls: threshold, threshold scope, divisor method and district map. Every setting is saved in the link, so an argument about a 2% threshold can be sent to someone. It models 580 seats, 84 districts and 18 parties across five views. Each view has a keyboard-reachable table with the same numbers. An automated audit found zero accessibility violations at three screen widths, in light and dark. I built it in about 28 hours over 2 and 3 September 2026.
Under the hoodTechnical detail for engineers
- The engine is
src/engine/, 621 lines with zero imports.allocate(parties, dapil, rules)is its whole public surface: synchronous and deterministic. It covers the threshold, four divisor rules (Sainte-Laguë, D'Hondt, modified Sainte-Laguë, Hare quota with largest remainder), tie resolution, the trace object and five disproportionality metrics (including Gallagher, Loosemore–Hanby and effective number of parties). - Legal basis: UU 7/2017 Pasal 414 ayat (1) for the 4% threshold, Pasal 415 ayat (2) for Sainte-Laguë. The court ruling is Putusan MK 116/PUU-XXI/2023. Seat magnitudes come from PKPU 6/2023.
- Hot loop uses typed arrays indexed by party ordinal, converted to objects only at the boundary. The trace is skipped for slider recomputes. 0.23 ms median for all 84 districts against a 16 ms budget; 60 fps median dragging 4.0% to 0 on the production build, p95 frame 17.6 ms. No debounce, no
requestIdleCallback, no web worker. src/ui/motion.tsholds a hand-written cubic-Bézier solver and one rAF loop that interpolates all 580 seats in a single pass.- All five views are hand-written SVG rendered by React.
d3-array,d3-scaleandd3-shapedo layout maths only and never touch the DOM. Five runtime dependencies in total. - Rule state serialises to the query string, e.g.
?t=2.0&scope=national&d=sainte-lague&geo=dapil. public/data/dapil-2024.jsonis a synthetic placeholder marked"provenance": "synthetic".npm run verifyexits non-zero and CI reports the gate as not testable. Swapping in a file marked"certified"flips everything green.- The party palette is solved by
scripts/extract/solve-colors.ts. A mid-grey ground made the 3:1 contrast target unsatisfiable for saturated hues, so I moved instruments to a near-black ground. Every colour now clears 3.5:1 and seven parties, including the four largest, keep their exact logo colour. - The spec asked for a "~1%" Laakso–Taagepera tick. At an average district magnitude of 6.90 the formula gives 9.5%, so the tick ships with the computed value.
- Payload about 440 KB (228 KB JS, 31 KB CSS, 141 KB self-hosted fonts, 58 KB data) against a 1 MB budget enforced in CI. Zero axe-core violations against WCAG 2.1 A and AA at 380, 720 and 1400 px.
- 40 Vitest tests, each expected value hand-computed on paper first. About 8,000 lines, 42 commits.
Screenshots
Hiring, or building something similar?
I'm open to full-time roles and select freelance work.