Solving it in the browser
The pipeline in placement, carving and matching runs in R and produces a finished JSON file. That is the right shape for publishing one map, and the wrong shape for asking what if — what if the states were bigger, what if the padding were wider, what if the seats came from the 2030 projection instead of 2022.
So the numerical half is also ported to JavaScript, in web/solver.js, and
driven by a page that re-solves live:
the studio.
Where this sits in the build
census boundaries → project + simplify → scale to seats → place in slots → push apart → carve into cells → match cells to districts → JSON
mapshaper owns anything topological — projection, simplification, dissolve, polygon intersection. Base R owns everything numerical, with no packages beyond what ships with R.
This page is that same numerical half, ported to run client-side.
What moved, and what did not
Only the numerical stages are ported. The topological ones stay where they are:
| Stage | Where it runs in the browser build |
|---|---|
| Project, simplify, dissolve | Not ported — mapshaper does it once, and the result ships as the page’s data |
| Scale to seats | JS |
| Place in slots, push apart | JS (relaxDiscs, boundaryDiscs) |
| Sample, cluster, balance | JS (samplePoints, kmeans, balance) |
| Build cells | JS (powerCells, clipBisector) |
| Clip cells to the state | Not ported — the browser does it at paint time |
| Match cells to districts | JS (hungarian) |
That clipping row is the reason this needs no polygon-clipping library at all.
The R build asks mapshaper to intersect each convex cell with the state outline.
A browser can instead draw all of a state’s cells inside an SVG <clipPath> that
is the state outline, and let the renderer do it:
<g transform="translate(tx,ty) scale(k)">
<path d="STATE"/>
<g clip-path="url(#clip-NJ)"> ...raw convex cells... </g>
</g>
Because the clip path sits inside the state’s own transformed group, its coordinates are the state’s unscaled ones — the same path data as the outline, reused.
The trade-off is worth stating plainly: the cells the browser holds are the raw convex polygons, running past the border. They only look clipped. Exact areas and exported geometry still come from the R build.
Two things that make it feel live
Cells do not depend on placement. Carving happens in each state’s own
unscaled frame; placement only translates and scales the finished group. So
changing the padding or the state size never invalidates the cells. The worker
caches them on (seats, cell mode, sample size, seed) alone, which is why the
padding slider re-solves in tens of milliseconds while a change of seat counts
takes about a second.
A spatial grid for the relaxation. Disc spacing is half the padding, so halving the padding doubles the discs and quadruples a naive pair scan. Each state’s discs are indexed into a uniform grid, in the state’s own frame so it survives the state moving; the cell size is the padding, which means the nearest disc within range is always in one of the nine cells around the query point. Measured pair by pair against a brute-force scan: identical answers, and the 50-state relaxation drops from ~690 ms to ~17 ms.
The same idea fixed sampling. Rejection sampling makes hundreds of thousands of point-in-polygon calls against outlines totalling 42,000 vertices; bucketing the edges by y so a query only tests its own row took that stage from 376 ms to 25 ms, with zero disagreements over 150,000 test points.
It will not reproduce the R build exactly
It cannot, and this is worth understanding rather than treating as a bug.
The relaxation is a Gauss–Seidel iteration: each pair’s correction is applied immediately, so later pairs in the same sweep see it. That makes it converge fast and makes it chaotic. The grid computes the same contacts as a brute force scan, but sums them in a different floating-point order. After one sweep the two differ by about 10⁻¹³ pixels. After ten, 10⁻⁶. After a hundred, a few pixels.
Bisecting it iteration by iteration:
after 1 iter: max diff 5.70e-13 px
after 2 iter: max diff 5.49e-12 px
after 3 iter: max diff 8.01e-11 px
after 5 iter: max diff 8.14e-10 px
after 10 iter: max diff 4.19e-06 px
Sampling adds a second, larger source: R’s RNG cannot be reproduced in JavaScript, so the point clouds differ and so do the cells.
What survives all of that is the invariant, which is the thing actually worth guaranteeing: no two states end up closer than the padding, and the assignment is optimal. Both are checked, not assumed — see below.
How the port is checked
sh/12_test_solver.js runs against the R build’s own output on every build. It
does not compare geometry, because it cannot; it compares properties.
geometry primitives, against the R build's own numbers
ok state areas match R within 0.081% (1dp coordinates)
ok state bounding boxes match R within 0.000 px
ok 432/435 district centroids land inside their state
Hungarian assignment
ok matches brute force on 40 random cases -- the same check R runs
ok New Jersey: JS cost 1807 <= R's 1807 px2 (same optimum)
ok recovers R's exact pairing when given R's own cells
carving cells
ok NJ (12 seats): largest cell / smallest = 1.03
ok FL (28 seats): largest cell / smallest = 1.09
...
ok Florida unbalanced 2.87 vs balanced 1.14 -- balancing earns its keep
disc relaxation
ok accepts R's finished layout unchanged -- both solvers agree it is legal
ok perturbed layout starts 1.9 px short of 2 px padding
ok relaxation closes it to 0.029 px in 26 iterations
ok worst state ends 16.2 px from where R put it -- the spring holds
ok New England moves as one body
Two of those are worth calling out. Recovers R’s exact pairing when given R’s own cells means the ported Hungarian, handed the R build’s cells and districts, returns the identity assignment at the identical cost — the strongest statement available without matching floating point. And accepts R’s finished layout unchanged means the JS relaxation, started from the layout R shipped, finds nothing to move: the two implementations agree on what “legal” means.
What the controls actually change
| Control | Re-runs | Notes |
|---|---|---|
| Seats per state | everything | 2022 districts, or the 2000/2010/2020 apportionments, or the 2030 projection |
| State size | placement only | The area divisor. The build uses 2.90 |
| Padding | placement only | Zero skips relaxation entirely and leaves states in their raw slots |
| Hand nudges | placement only | The New England, WV and LA corrections |
| New England as one body | placement only | Off lets the six states shear apart |
| Cells | carving | Balanced, plain k-means, or none |
| Sample points per seat | carving | The build uses 600; the page defaults to 200 for speed |
| Seed | carving | The sampling is the only randomness |
Two settings are worth trying deliberately.
Cells → plain k-means. This is the comparison that motivates the whole balancing step. On the 2000 seat counts it leaves Florida’s largest cell 10.6× its smallest; balanced, the worst state in the country is 1.31×.
State size → 1.8 with padding 7. The relaxation cannot converge, and the page says why rather than quietly returning a broken layout: states are pinned against the cap on how far they may drift from their hand-drawn slots, so there is nowhere left to push.
That cap is one deliberate difference from R. The R build hard-codes 40 px
because it only ever runs at one state size. Here the size is a control, so the
cap scales with it — every state’s linear scale goes as 1/√divisor, so the cap
does too. Otherwise turning the states up would make the map infeasible for a
reason about the cap rather than about the geometry.
Cost
Measured in-browser on the full 50 states, 435 seats:
| Stage | Time |
|---|---|
| Placement (relaxation) | ~75 ms |
| Carving, 200 points/seat | ~1.1 s |
| Matching (435 districts) | ~20 ms |
The solving runs in a Web Worker, so none of it blocks the page. That is also why
there is no single-file standalone build the way the other viewers have one: a
worker cannot be loaded from a file:// document, so a double-clickable HTML
file would silently do nothing. Serve out/ over HTTP instead —
npx serve out, then open /studio.html.
Where the code lives
| File | Role |
|---|---|
web/solver.js |
The whole port: geometry, sampling, k-means, balancing, cells, relaxation, Hungarian |
sh/12_test_solver.js |
Checks it against the R build; runs on every build |
sh/13_build_studio_payload.js |
Outlines, seat counts and Figma slots, extracted from the build |
sh/14_build_studio.js |
Assembles the page |
viewer/studio.worker.js |
Drives the solvers off the main thread, caches cells |
viewer/studio.js |
Controls and SVG rendering |
Back to the documentation index, or the stages themselves: placement, carving cells, matching.