Three regions, fixed populations: A = 100, B = 51, C = 17 (168 people in all). We use the largest-remainder method: give each region its exact quota's whole part, then hand out the leftover seats to whoever has the biggest fractional remainder. It's the most intuitive rule there is.
Drag the slider to change the total number of seats and watch each region's allocation.
<p class="hint">Three regions with fixed populations share a parliament. The
<b>largest-remainder</b> rule gives each region the whole part of its exact quota,
then hands leftover seats to the biggest fractional remainders. Move the slider and
watch what happens to region <b>C</b> when the house grows from 14 to 15.</p>
<div class="seatrow">
<label for="seats">Total seats: <b id="seatval">14</b></label>
<input id="seats" type="range" min="3" max="24" value="14" />
</div>
<div class="btns">
<button id="show14" type="button">Show 14 seats</button>
<button id="show15" type="button">Add a seat → 15</button>
</div>
<table class="tbl">
<thead><tr><th>Region</th><th>Population</th><th>Exact quota</th><th>Seats</th></tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="status" id="status">Slide to 14, then to 15, and watch region C.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.seatrow { display: flex; align-items: center; gap: .8rem; margin: .3rem 0 .6rem; }
.seatrow label { font-size: .95rem; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.tbl { width: 100%; border-collapse: collapse; font-size: .92rem; }
.tbl th, .tbl td { text-align: center; padding: .45rem .3rem; border-bottom: 1px solid #e2e8ef; }
.tbl th { color: #1d3557; font-size: .82rem; text-transform: uppercase; letter-spacing: .03em; }
.tbl td.seats { font-weight: 700; font-size: 1.15rem; color: #1d3557; }
.tbl tr.drop td.seats { color: #c92f3c; }
.tbl tr.gain td.seats { color: #0a7d33; }
.status { font-size: 1rem; font-weight: 600; margin: .7rem 0 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Apportionment by the largest-remainder (Hamilton) method.
// Each region gets floor(quota); leftover seats go to the largest fractional
// remainders. This stays "within quota" but suffers the ALABAMA PARADOX: growing the house can take a seat away from a region. (Balinski-Young, 1980.)
const REGIONS = [
{ name: 'A', pop: 100 },
{ name: 'B', pop: 51 },
{ name: 'C', pop: 17 },
];
const TOTAL = REGIONS.reduce((s, r) => s + r.pop, 0); // 168
function apportion(seats) {
const quotas = REGIONS.map(r => (r.pop * seats) / TOTAL);
const floors = quotas.map(Math.floor);
let used = floors.reduce((a, b) => a + b, 0);
let leftover = seats - used;
const order = quotas
.map((q, i) => ({ i, frac: q - Math.floor(q) }))
.sort((a, b) => b.frac - a.frac);
const seatsArr = floors.slice();
for (let k = 0; k < leftover; k++) seatsArr[order[k].i]++;
return { quotas, seatsArr };
}
let prev = null; // previous seat allocation to detect a drop/gain
function render(seats) {
document.getElementById('seatval').textContent = seats;
const { quotas, seatsArr } = apportion(seats);
const tbody = document.getElementById('rows');
tbody.innerHTML = '';
REGIONS.forEach((r, i) => {
const tr = document.createElement('tr');
let cls = '';
if (prev) {
if (seatsArr[i] < prev[i]) cls = 'drop';
else if (seatsArr[i] > prev[i]) cls = 'gain';
}
tr.className = cls;
tr.innerHTML =
'<td>' + r.name + '</td>' +
'<td>' + r.pop + '</td>' +
'<td>' + quotas[i].toFixed(2) + '</td>' +
'<td class="seats">' + seatsArr[i] + '</td>';
tbody.appendChild(tr);
});
return seatsArr;
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + cls;
}
function update(seats, compareTo) {
prev = compareTo;
const now = render(seats);
if (compareTo) {
const dropped = REGIONS.findIndex((r, i) => now[i] < compareTo[i]);
if (dropped >= 0)
setStatus(fmt('Paradox! The house grew to %seats% seats, yet region %name% LOST a seat.', { seats: seats, name: REGIONS[dropped].name }), 'bad');
else setStatus(fmt('House size %seats%: no region lost a seat here.', { seats: seats }), 'ok');
} else {
setStatus(fmt('House size %seats%. Now add a seat and watch region C.', { seats: seats }), '');
}
return now;
}
const slider = document.getElementById('seats');
let lastSeats = 14;
let lastAlloc = update(14, null);
slider.addEventListener('input', () => {
const seats = +slider.value;
const compareTo = seats === lastSeats + 1 ? lastAlloc : null;
const now = update(seats, compareTo);
lastSeats = seats; lastAlloc = now;
});
document.getElementById('show14').addEventListener('click', () => {
slider.value = 14; lastSeats = 14; lastAlloc = update(14, null);
});
document.getElementById('show15').addEventListener('click', () => {
// ensure baseline is 14, then step to 15 to expose the paradox
const base = apportion(14).seatsArr;
slider.value = 15; lastSeats = 15; lastAlloc = update(15, base);
});
<p class="hint">Three regions with fixed populations share a parliament. The
<b>largest-remainder</b> rule gives each region the whole part of its exact quota,
then hands leftover seats to the biggest fractional remainders. Move the slider and
watch what happens to region <b>C</b> when the house grows from 14 to 15.</p>
<div class="seatrow">
<label for="seats">Total seats: <b id="seatval">14</b></label>
<input id="seats" type="range" min="3" max="24" value="14" />
</div>
<div class="btns">
<button id="show14" type="button">Show 14 seats</button>
<button id="show15" type="button">Add a seat → 15</button>
</div>
<table class="tbl">
<thead><tr><th>Region</th><th>Population</th><th>Exact quota</th><th>Seats</th></tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="status" id="status">Slide to 14, then to 15, and watch region C.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.seatrow { display: flex; align-items: center; gap: .8rem; margin: .3rem 0 .6rem; }
.seatrow label { font-size: .95rem; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.tbl { width: 100%; border-collapse: collapse; font-size: .92rem; }
.tbl th, .tbl td { text-align: center; padding: .45rem .3rem; border-bottom: 1px solid #e2e8ef; }
.tbl th { color: #1d3557; font-size: .82rem; text-transform: uppercase; letter-spacing: .03em; }
.tbl td.seats { font-weight: 700; font-size: 1.15rem; color: #1d3557; }
.tbl tr.drop td.seats { color: #c92f3c; }
.tbl tr.gain td.seats { color: #0a7d33; }
.status { font-size: 1rem; font-weight: 600; margin: .7rem 0 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Apportionment by the largest-remainder (Hamilton) method.
// Each region gets floor(quota); leftover seats go to the largest fractional
// remainders. This stays "within quota" but suffers the ALABAMA PARADOX: growing the house can take a seat away from a region. (Balinski-Young, 1980.)
const REGIONS = [
{ name: 'A', pop: 100 },
{ name: 'B', pop: 51 },
{ name: 'C', pop: 17 },
];
const TOTAL = REGIONS.reduce((s, r) => s + r.pop, 0); // 168
function apportion(seats) {
const quotas = REGIONS.map(r => (r.pop * seats) / TOTAL);
const floors = quotas.map(Math.floor);
let used = floors.reduce((a, b) => a + b, 0);
let leftover = seats - used;
const order = quotas
.map((q, i) => ({ i, frac: q - Math.floor(q) }))
.sort((a, b) => b.frac - a.frac);
const seatsArr = floors.slice();
for (let k = 0; k < leftover; k++) seatsArr[order[k].i]++;
return { quotas, seatsArr };
}
let prev = null; // previous seat allocation to detect a drop/gain
function render(seats) {
document.getElementById('seatval').textContent = seats;
const { quotas, seatsArr } = apportion(seats);
const tbody = document.getElementById('rows');
tbody.innerHTML = '';
REGIONS.forEach((r, i) => {
const tr = document.createElement('tr');
let cls = '';
if (prev) {
if (seatsArr[i] < prev[i]) cls = 'drop';
else if (seatsArr[i] > prev[i]) cls = 'gain';
}
tr.className = cls;
tr.innerHTML =
'<td>' + r.name + '</td>' +
'<td>' + r.pop + '</td>' +
'<td>' + quotas[i].toFixed(2) + '</td>' +
'<td class="seats">' + seatsArr[i] + '</td>';
tbody.appendChild(tr);
});
return seatsArr;
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + cls;
}
function update(seats, compareTo) {
prev = compareTo;
const now = render(seats);
if (compareTo) {
const dropped = REGIONS.findIndex((r, i) => now[i] < compareTo[i]);
if (dropped >= 0)
setStatus(fmt('Paradox! The house grew to %seats% seats, yet region %name% LOST a seat.', { seats: seats, name: REGIONS[dropped].name }), 'bad');
else setStatus(fmt('House size %seats%: no region lost a seat here.', { seats: seats }), 'ok');
} else {
setStatus(fmt('House size %seats%. Now add a seat and watch region C.', { seats: seats }), '');
}
return now;
}
const slider = document.getElementById('seats');
let lastSeats = 14;
let lastAlloc = update(14, null);
slider.addEventListener('input', () => {
const seats = +slider.value;
const compareTo = seats === lastSeats + 1 ? lastAlloc : null;
const now = update(seats, compareTo);
lastSeats = seats; lastAlloc = now;
});
document.getElementById('show14').addEventListener('click', () => {
slider.value = 14; lastSeats = 14; lastAlloc = update(14, null);
});
document.getElementById('show15').addEventListener('click', () => {
// ensure baseline is 14, then step to 15 to expose the paradox
const base = apportion(14).seatsArr;
slider.value = 15; lastSeats = 15; lastAlloc = update(15, base);
});
Now do the cruel experiment. Set the house to 14 seats — region C gets 2. Then add a single seat to make 15. Region C drops to 1, even though the parliament just got bigger. This is the Alabama paradox, named for the 1880 US census where Alabama would have lost a seat as the House grew from 299 to 300. No region misbehaved; the arithmetic did.
Comments
Loading comments...