geometry
The label that lands in the ocean: a bounding-box centre is not inside your polygon
A concave country's label rendered offshore because the anchor was the bounding-box centre — a point the polygon does not contain. The fix is a guaranteed-interior point, but the harder call was shipping it from a session with no GPU to re-check the pixels.
By the X-GIS team 6 min read
Take a “U”-shaped polygon with these eight corners:
[0,0] [10,0] [10,10] [7,10] [7,3] [3,3] [3,10] [0,10]It is solid below y=3 across the full width, and above y=3 only the two
arms x∈[0,3] and x∈[7,10] are filled — the middle is a notch cut out of
the top. Its bounding box is x∈[0,10], y∈[0,10], so the bounding-box centre
is (5,5). That point is in the notch. It is not inside the polygon.
That is where the label went. featureAnchor() — the function that picks the
[lon,lat] a symbol label anchors to — returned the bbox centre of the first
outer ring for every Polygon. For a convex country that centre is inside and
nobody notices. For a concave one (a “C”, a horseshoe bay, anything with a
big re-entrant coastline) the anchor sits in open water and the country’s name
renders offshore. The MultiPolygon path had a second, independent version of
the same bug: it used coordinates[0][0] — the first-listed part — so a
country whose source geometry lists a tiny outlying island before its mainland
got labelled on the island.
The wrong first move
The obvious fix is “compute a representative interior point instead of the bbox centre.” It is obvious and it has two traps, one mathematical and one about where the code was running.
The mathematical trap: the centroid is not the answer. The area-weighted
centroid of a concave shape can fall outside it just as the bbox centre does —
the centroid of a crescent sits in the hollow of the crescent. Swapping one
not-guaranteed-inside point for another is not a fix, it is a different bug
with better PR. A point that is always inside needs an actual construction —
PostGIS calls it ST_PointOnSurface, Mapbox ships it as polylabel (the
pole of inaccessibility). There is no closed-form “centre” that is inside by
definition.
The second trap mattered more here. This change was made in a headless CI
session with no GPU. The engine’s own contract (CLAUDE.md §5) forbids
claiming a render is correct without a real-GPU pixel diff — and moving a
label anchor is a pixel change. If I replaced the anchor for every
polygon, I would be changing the placement of every convex-country label too —
the 99% case that was already correct — in an environment that could not
re-verify a single one of them. Fixing the 1% by silently perturbing the 99%
you cannot re-check is a bad trade even when the new formula is “better.”
What actually happened
The observation that unlocked it: the convex case does not need fixing at all. The bbox centre is only wrong when it is outside the polygon. So test exactly that, and only fall back to a constructed interior point when the cheap answer fails:
/** Anchor for one polygon (outer + holes): keep the bbox centre when it is * already inside the filled area (byte-identical to the historical anchor * on the common convex case), else fall to a guaranteed-interior point. */function polygonAnchor(rings: [number, number][][]): [number, number] | null { const centre = ringBboxCentre(rings[0] as [number, number][]) if (centre && pointInRings(centre[0], centre[1], rings)) return centre return polygonInteriorPoint(rings) ?? centre}pointInRings is an even-odd test over the outer ring and the holes, so a
centre that lands in a donut hole reads as outside and also gets corrected.
The guaranteed-interior point is a single horizontal scanline — the cheapest
thing that is provably inside:
// intersect the line y = midLatitude with every ring edge, sort the// crossings, and return the midpoint of the LONGEST interior span. Even-odd// pairing means spans that lie inside a hole are skipped automatically.const y = (minY + maxY) / 2const xs: number[] = [] // x of each edge crossing the scanline// … collect crossings …xs.sort((p, q) => p - q)let bestMid = null, bestLen = -Infinityfor (let i = 0; i + 1 < xs.length; i += 2) { const len = xs[i + 1]! - xs[i]! if (len > bestLen) { bestLen = len bestMid = (xs[i]! + xs[i + 1]!) / 2 }}For the “U” at y=5 the crossings are {10,7,3,0}, sorted {0,3,7,10}; the
interior spans are [0,3] and [7,10]; the midpoint of the first is 1.5,
and (1.5, 5) is inside the left arm. The label moved from the notch onto the
polygon.
The MultiPolygon fix is the same idea with a size test in front: rank the
parts by |shoelace area| of their outer ring and anchor on the largest,
then run polygonAnchor on it. The mainland wins over the island because it
is bigger, not because it is later in the list.
The fix
Two behavioural changes, each confined to the inputs that were broken:
- Polygon: convex / centre-inside → the exact same value as before
(
ringBboxCentre(rings[0]), byte-for-byte). Concave or centre-in-hole → the scanline interior point. - MultiPolygon: single-part → unchanged from the old first-part anchor. Multi-part → the largest-area part, then the polygon rule above.
Nothing about a convex polygon’s anchor changed. That is the whole point: the diff is invisible to every label that was already placed correctly.
How we know it holds
Not with a screenshot — with an invariant. The test file carries a second,
independent even-odd point-in-polygon implementation (deliberately not the
one featureAnchor uses internally) and asserts two things per shape: the old
bbox centre fails it, and the new anchor passes it.
it('concave U-polygon: old bbox centre is OUTSIDE, new anchor is INSIDE', () => { expect(inside(5, 5, u)).toBe(false) // the historical anchor fell outside const a = featureAnchor({ type: 'Polygon', coordinates: u })! expect(inside(a[0], a[1], u)).toBe(true) // the new anchor is provably inside})Six cases pass: convex (returns exactly [5,5]), the concave U, a polygon
whose bbox centre lands in its hole, and a MultiPolygon whose anchor is inside
the larger part and outside the smaller. bun run build typechecks the map
package clean. The correctness claim is “the anchor is a point the polygon
contains” — a statement about geometry that a ray cast settles completely,
with no GPU in the loop. The pixels follow from the invariant; I did not have
to see them to know the label is now on land.
What generalizes
Two things, one per trap.
A “representative point” for a polygon is a real algorithm, not a centre. Bbox
centre, centroid, and average-of-vertices are all outside a sufficiently
concave shape; if you need a point the polygon contains — a label anchor, a
leader-line root, a hit-test fallback — you need ST_PointOnSurface or a
pole-of-inaccessibility, and a one-line scanline is enough to guarantee it.
And when you have to change behaviour in an environment that cannot fully
verify the result, restructure the change so the path you can’t re-check
stays provably untouched, and give the path you did change an invariant a
unit test can settle. Here that was one if: keep the old answer when it is
already correct, and confine both the new code and the risk to the inputs that
were broken. You do not need a GPU to prove a point is inside a polygon — you
only need to stop yourself from disturbing the points that were already fine.
References
- PostGIS, “ST_PointOnSurface” — a point guaranteed to lie on a surface, the SQL analogue of the scanline here.
- Mapbox, “polylabel” — pole of inaccessibility; the more expensive, more central guaranteed-interior point when the scanline midpoint is not central enough.
Read next
labels
7 min read
Three rounds to keep a label on the planet
A row of country labels floated in the sky above the globe's horizon. Round 1 — an angular margin, provably safe at every zoom — died because floaters and healthy labels interleave in angular depth (Nigeria 0.332 floats, Kenya 0.329 doesn't). Round 2's screen-space limb fixed Chad and missed two-line Burkina Faso by exactly one line of text. The gate only held once it moved to where the quad height actually exists.
verification
5 min read
Verified only where the bug showed: a hot-path fix that shipped a zoom-in regression
A WebGL2 flicker fix (#1139) was render-parity-verified on exactly one scenario — the zoom-OUT hold-tiles case that reproduced the original flicker, retained-content 0.19 → 0.79 matching WebGPU — and never on zoom-in or per-frame cost. It shipped an unmemoized per-frame classifyTile run twice per tile plus synchronous uploads, and stuttered with progressively-black fills on a Seoul zoom-in. Reverted 36 minutes later (#1140). A hot-path fix must be verified in its worst regime, not the one that showed the original bug.
rendering
7 min read
Draped at the wrong tile: a deferred write turned a shared uniform pool into a data race
A draped landcover polygon rendered hundreds of kilometres offshore — but only on WebGPU. Every vector slice of one source shares one Material uniform pool, and each slice resets the pool cursor to 0, so a later slice's tile i overwrote an earlier slice's tile-i buffer. WebGPU's queue.writeBuffer defers to submit (last-writer-wins), so every draw read the final bytes; WebGL2's immediate bufferSubData never showed it — and the differential test was structurally blind, because both frames aliased identically.