Overlap removal with PRISM

2026 · 05 · 900 words

The pile on this site is nine cards with hand-tuned offsets from the viewport center. On a wide monitor they spread out. On a narrow one, density scaling clamps and cards start sitting on top of each other. The about card covers the writing card. The music card overlaps contact. The layout that felt effortless at 1440 pixels was falling apart at 768.

Overlapping rectangles is a solved problem in the sense that you can always push them apart. Iterate over every pair, find the axis with the least penetration, split the difference, repeat until nothing collides. It runs fast and terminates. But each push can shove a card into a new collision with something else, and the order you process pairs in determines the final arrangement. Two cards that were designed to sit side by side end up on opposite edges of the viewport. The positions are valid (nothing overlaps) but the layout has lost its shape.

Minimum displacement

PRISM (Proximity Stress Model) comes from a 2010 paper by Gansner and Hu on node overlap removal in graph visualization. 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 "given where everything started, where should everything end up?"

Pairwise pushing looks at two rectangles, moves them, and hopes the rest works out. PRISM looks at all rectangles simultaneously and finds positions that eliminate every overlap while minimizing total displacement from the original layout. Cards that were near each other stay near each other. Cards that were far apart stay far apart.

In a card pile, position is the design. The offsets encode visual hierarchy: photo sits top-right, writing sits center, contact sits bottom. An overlap remover that scrambles that arrangement while producing zero-overlap output has solved the wrong problem.

aboutphotowritingmusicnowcontact
PRISM overlap removal. Drag blocks to create overlaps, then resolve. Dashed outlines show where blocks started.

How the solver works

The algorithm iterates. Each iteration builds a model of the current state and takes one optimization step.

First, it computes overlap factors for every pair of rectangles. The overlap factor is how much the distance between two centers would need to grow for the pair to clear each other, including a configurable padding. A factor below 1.0 means the pair is already separated. Above 1.0 means they overlap.

Those factors feed a stress model. Each pair becomes an edge in a weighted graph with an ideal distance (current distance scaled by the overlap factor). Overlapping pairs carry high weights because they need to move. 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.

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 ideal distances, and solves the resulting linear system with Gaussian elimination. This produces new coordinates for every rectangle at once, nudging the whole layout toward lower stress in one step.

A damping term (λ=0.005\lambda = 0.005) anchors each rectangle near its current position between iterations, preventing oscillation. The loop runs until no pairs overlap.

One wrinkle: stress majorisation optimizes euclidean distance between centers, not per-axis separation. 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 handles these stubborn remainders.

Three hundred lines of Rust

The implementation is a Rust crate compiled to WASM via wasm-bindgen. Overlap factors, stress step, Gaussian solver, nudge fallback: about 300 lines including tests. The binary is around 20 KB after opt-level = "s" and LTO.

Rust isn't strictly necessary for nine cards. 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 easier to get right in Rust than to debug in JavaScript.

On this site, layout.ts calls removeOverlaps once per pile rebuild: on viewport resize, not per frame. It passes card centers and dimensions, gets back adjusted positions, then clamps to viewport bounds. If a narrow screen pushes clamped cards into the same horizontal band and reintroduces overlaps, a simpler JavaScript fallback handles the remainder with sorted downward pushes.

The whole pile rebuilds in under a millisecond. Overkill for nine cards, maybe. But the layout holds its shape across viewport sizes now, and that was the point.