Overlap removal with PRISM
The pile on this site is nine cards, each one sitting at a hand-tuned offset from the center of the viewport. On a wide monitor they spread out nicely. On a narrow one, the density scaling clamps and the cards start sitting on top of each other: the about card covers the writing card, the music card overlaps contact. A layout that felt effortless at 1440 pixels was quietly falling apart at 768. Overlap removal is the job of pulling those collisions apart again without throwing away the arrangement you started from.
You can always just push overlapping rectangles apart. Iterate over every pair, find the axis with the least penetration, split the difference, and repeat until nothing collides. It runs fast and it terminates. But each push can shove a card straight into a fresh collision with something else, and the order you happen to process the pairs in decides the final arrangement. Two cards that were designed to sit side by side end up on opposite edges of the viewport. The positions are all valid, since nothing overlaps, and yet the layout has completely lost its shape.
Minimum displacement
PRISM (the Proximity Stress Model) comes from a 2010 paper by Gansner and Hu on node overlap removal in graph visualization, and it also ships inside Graphviz as the default overlap strategy for neato layouts. Instead of asking "which direction should I push these two apart?", it asks a better question: "given where everything started, where should everything end up?"
Pairwise pushing looks at two rectangles, moves them, and hopes the rest works itself out. PRISM looks at all of them at once and finds positions that eliminate every overlap while minimizing the total displacement from the original layout. Cards that started near each other stay near each other, and cards that started far apart stay far apart.
In a card pile, the position is the design. Those offsets encode a visual hierarchy: the photo sits top-right, the writing sits center, contact sits at the bottom. An overlap remover that scrambles all of that while producing zero-overlap output has solved the wrong problem.
How the solver works
The algorithm iterates. Each pass builds a model of the current state and then takes a single optimization step.
First it computes overlap factors for every pair of rectangles. The overlap factor is how much the distance between two centers would have to grow for the pair to clear each other, including a configurable padding. Below 1.0, the pair is already separated; above 1.0, they overlap.
Those factors then feed a stress model. Each pair becomes an edge in a weighted graph with an ideal distance (the current distance scaled by the overlap factor). Overlapping pairs carry high weights, because they need to move, while separated pairs carry low weights, because they should stay put. The weight drops with the square of the ideal distance, so nearby non-overlapping pairs pull harder than distant ones do.
Then comes the expensive step: stress majorisation. The solver builds a weighted Laplacian matrix for the full graph, constructs the right-hand side from the current positions and the ideal distances, and solves the resulting linear system with Gaussian elimination. Out come new coordinates for every rectangle at once, the whole layout nudged toward lower stress in a single step.
A damping term () anchors each rectangle near its current position between iterations, which prevents oscillation. The loop runs until no pairs overlap.
There's one wrinkle. Stress majorisation optimizes euclidean distance between centers, not per-axis separation, so two rectangles can be far enough apart in the euclidean sense while their bounding boxes still touch along one axis. A final pass of direct axis-aligned nudging cleans up these stubborn remainders.
Three hundred lines of Rust
The implementation is a Rust crate compiled to WASM via wasm-bindgen. Overlap factors, the stress step, the Gaussian solver, the nudge fallback: about 300 lines in total, including the tests. The binary lands at around 20 KB after opt-level = "s" and LTO.
Rust isn't strictly necessary for nine cards, and JavaScript would handle this in under a millisecond too. But the algorithm is all numerics: matrix construction, pivot selection, back substitution. That kind of code is far easier to get right in Rust than it is to debug in JavaScript.
On this site, layout.ts calls removeOverlaps once per pile rebuild, on viewport resize rather than per frame. It passes in the card centers and dimensions, gets back the adjusted positions, then clamps them to the viewport bounds. If a narrow screen pushes those clamped cards into the same horizontal band and reintroduces overlaps, a simpler JavaScript fallback mops up the remainder with sorted downward pushes.
The whole pile rebuilds in under a millisecond. Overkill for nine cards, probably. But the layout holds its shape across viewport sizes now, and that was the entire point.