X-GIS
Blog

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) / 2
const xs: number[] = [] // x of each edge crossing the scanline
// … collect crossings …
xs.sort((p, q) => p - q)
let bestMid = null,
bestLen = -Infinity
for (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

  1. PostGIS, “ST_PointOnSurface” — a point guaranteed to lie on a surface, the SQL analogue of the scanline here.
  2. 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.