Skip to content
AFM Studio
  • Pages

    AFM Studio
  • Work
  • Lab
  • About
  • CV
  • Contact
  • Projects

    AksaraA searchable library for Otorita IKN's knowledge, where the server checks every reader's access across three levels
  • Daily TaqwaTamper-resistant prayer attendance system for ~2,000 OIKN government employees at Masjid Negara IKN
  • Lantara v2A permit engine for Otorita IKN where administrators add new permit types from a screen, without a developer
  • eKiosk IKNTwo control rooms for Nusantara: kiosk content management and live bus tracking with arrival times that grade themselves
  • E-Monev IKNA monitoring platform for Nusantara's 23-year build, tracking every unit's quarterly progress against 24 national KPIs
  • JDIH OIKNOIKN's public legal document portal, with a controlled publishing workflow and AI summaries that never leave the server
  • Jejak PenanamanA check-in app for IKN tree-planting events that proves each participant was on site with a geofenced selfie
  • Dashboard SAKTIA nightly pipeline and dashboard that replaces OIKN's manual budget downloads for all six of its budget units
  • DoseRxA bedside dose calculator for 127 drugs that rounds each dose to tablets and syrups Indonesian pharmacies sell
  • CubiqA speedcubing trainer with competition-grade timing and a solver I wrote for each of the 8 major puzzles
  • ScimotionAn interactive science library of 86 articles, each explained next to animations the reader can control
  • WA Daily Scrum BotA WhatsApp bot that chases missing standup reports for a 14-person team and flags tasks stuck for days

↑ ↓ to move · Enter to open · Esc to close

Suara ke Kursi

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

Screenshots
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.ts holds 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-scale and d3-shape do 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.json is a synthetic placeholder marked "provenance": "synthetic". npm run verify exits 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

Ruang Sidang
Angka Ringkas
Ambang Batas
Aturan
Suara Sah Nasional
Dapil

Hiring, or building something similar?

I'm open to full-time roles and select freelance work.