All sections

Arbitrary Polygon To H3

The four containment modes for polyfilling any polygon into H3 cells, when to use each, and how to retain overlap for downstream weighting.

approximatestableh37 min read
Source geometry
trade_area, geofence, parcel
Destination geometry
h3_cell_set

Purpose

Trade areas, geofences, and parcels arrive as free-form polygons with no partition guarantee and no administrative registry behind them. This is the general-purpose polygon-to-H3 conversion: every other polygon conversion in this knowledge base (administrative, buffered point-radius, corridor) reduces to this one after its own geometry-specific step. It exists in four containment modes because "is this cell in the polygon" has four different, equally legitimate answers.

Source geometry and destination geometry

Source geometry is one of trade_area, geofence, or parcel — a single polygon or multipolygon, normalized (geometry-normalization) to EPSG:4326 with closed rings, correct winding, and repaired self-intersections before polyfilling. Destination geometry is an h3_cell_set, optionally paired with per-cell overlap fractions.

Exactness class

Approximate, by design and by mode: full and intersect are conservative and expansive respectively at the two ends of a spectrum, center is the cheapest and least biased single answer, and threshold is a tunable approximation with no single "correct" cutoff.

Containment rule and boundary behavior

center
Include a cell if its center point lies inside the polygon. Cheapest to compute; no area math per cell. Coverage is close to the true area on average but can be biased for any single small polygon.
full
Include a cell only if the entire cell is contained in the polygon. Never overreaches — every included cell's full area is inside the source — but always underreaches at the boundary, since partial boundary cells are dropped entirely.
intersect
Include a cell if it touches the polygon at all, even by one square meter. Never underreaches — the union of included cells always covers the polygon — but always overreaches, sometimes substantially, at ragged boundaries.
threshold
Include a cell if its fractional overlap with the polygon meets or exceeds a caller-supplied threshold t. Tunable between full's conservatism and intersect's expansiveness; the only mode requiring a per-cell area computation before the containment decision.
center:      o--o--o--o        full:       [--][--]
             | polygon |                   | polygon |
             o--o--o--o                    [--][--]
             (cell IN if its o is inside)  (cell IN only if fully inside)

intersect:  [##][##][##]       threshold:  [##][--][xx]
            | polygon |                    | polygon |  (xx below t, dropped)
            [##][##][##]                   [##][ >=t ][xx]
            (any touch => IN)              (area fraction >= t => IN)

The threshold rule is the only one with a closed-form test per cell:

intersection area(cell,polygon)cell areat\frac{\text{intersection area}(cell, \text{polygon})}{\text{cell area}} \ge t

A common convention is t = 0.5, which behaves like a tie-break between full and intersect, but t is a caller parameter, not a constant — a compliance use case that must never overreach should push t toward 1.0 (converging on full), while a reach-maximizing use case should push it toward a small positive value (converging on intersect but excluding cells that only graze the boundary at a single point).

center · 89.9% coverage, 9% overreach
center · 89.9% coverage, 9% overreach
full · 49.4% coverage, 0% overreach
full · 49.4% coverage, 0% overreach
intersect · 100% coverage, 68% overreach
intersect · 100% coverage, 68% overreach
threshold t=0.5 · 20 cells
threshold t=0.5 · 20 cells

Resolution behavior

All four modes converge toward the true polygon area as resolution increases, because the maximum per-cell area error shrinks with cell size. full converges from below (coverage_ratio rising toward 1), intersect converges from above (overreach_ratio falling toward 0), and center and threshold oscillate around the true value with shrinking amplitude. For polygons smaller than a handful of cells at the chosen resolution, none of the modes converge usefully — see tiny-polygons below.

Units and CRS

EPSG:4326 input, normalized before polyfilling. Intersection and cell areas for threshold and for quality metrics are computed as spherical m² (haversine-consistent), not planar-projected m² — projecting to a local Cartesian frame before an area computation introduces its own distortion that should be measured, not assumed away, especially above 60° latitude.

Algorithm

import { polygonToH3 } from "@/lib/h3/polyfill";
import { weightedCrosswalk, maxOverlapAssignment } from "@/lib/h3/crosswalk";

const conservative = polygonToH3(tradeArea, { resolution: 9, mode: "full" });
const expansive = polygonToH3(tradeArea, { resolution: 9, mode: "intersect" });
const tuned = polygonToH3(tradeArea, {
  resolution: 9,
  mode: "threshold",
  threshold: 0.5,
});

// Partition assignment when a cell could belong to more than one polygon
const assigned = maxOverlapAssignment(tuned, [tradeAreaA, tradeAreaB], {
  resolution: 9,
});

// Retention-oriented crosswalk: keep every touched region per cell
const crosswalk = weightedCrosswalk(tradeArea, { resolution: 9 });

The same conversion with the Python bindings (h3-py v4):

import h3
from shapely.geometry import Polygon, shape

# center mode: h3-py's polygon_to_cells is center-containment only
outer_ring = [(lat, lng) for lng, lat in trade_area_coords]  # h3-py wants (lat, lng)
poly = h3.LatLngPoly(outer_ring)
center_cells = h3.polygon_to_cells(poly, res=9)

# full / intersect / threshold: h3-py has no mode argument, so classify
# each candidate cell's overlap area against the polygon ourselves.
shapely_poly = Polygon([(lng, lat) for lat, lng in outer_ring])
candidates = h3.polygon_to_cells(poly, res=9) | {
    n for c in center_cells for n in h3.grid_disk(c, 1)
}

def classify(cell, mode, threshold=0.5):
    boundary = Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(cell)])
    inter = boundary.intersection(shapely_poly).area
    if mode == "full":
        return inter == boundary.area
    if mode == "intersect":
        return inter > 0
    if mode == "threshold":
        return (inter / boundary.area) >= threshold

full_cells = {c for c in candidates if classify(c, "full")}
intersect_cells = {c for c in candidates if classify(c, "intersect")}
tuned_cells = {c for c in candidates if classify(c, "threshold", 0.5)}

The tested reference implementation for this conversion is the TypeScript in lib/; the Python above reproduces the same per-cell decisions with core h3-py calls plus shapely for area, since h3-py (like h3-js) only ships center containment natively.

Parameters

Resolution, containment mode, threshold t (mode threshold only), and whether output should be a partition (via maxOverlapAssignment) or a retained multi-region crosswalk (via weightedCrosswalk).

Outputs

For the four containment modes: a flat h3_cell_set at the stated resolution and mode. For weightedCrosswalk, one row per touched (cell, region) pair carrying cell_id, source_region_id, intersection_area, cell_coverage_fraction (intersection area over cell area), and region_coverage_fraction (intersection area over the source polygon's total area) — the fields needed to split a metric like population or spend proportionally across regions rather than assigning it to one.

Quality metrics

Report coverage_ratio, overreach_ratio, underreach_ratio, and jaccard per polygon per mode. full should show overreach_ratio = 0 by construction; intersect should show underreach_ratio = 0 by construction — if either is violated, the polyfill implementation has a bug, not the polygon.

Edge cases

Narrow polygons (a road-adjacent strip, a thin trade-area sliver) can be narrower than a cell's diameter at coarse resolutions, causing full to return zero cells while center and intersect still return a thin one-cell-wide line — always sanity-check full output is non-empty before trusting it downstream. Tiny polygons (sub-cell-area parcels) hit the same failure for full and produce a single, disproportionately large cell for center; threshold with a low t is usually the least-bad default here. Touching-only polygons (two trade areas that share a boundary but do not overlap) can still both claim the same boundary cell under intersect, which is correct per the rule but must be resolved with maxOverlapAssignment if a partition is required. Simplified boundaries (Douglas-Peucker-reduced trade areas from a mapping SDK) shift the true edge by the simplification tolerance, which should be recorded and treated as an additional uncertainty band on top of the containment mode's own error. Holes (a trade area with an excluded interior ring, e.g. a competitor's exclusive zone) must be respected by the polyfill implementation — a naive polyfill that ignores interior rings will silently include cells the source explicitly excludes.

Assumptions and limitations

This conversion assumes the input polygon has already been normalized — self-intersections repaired, rings closed and correctly wound — since polyfilling a malformed polygon produces an undefined or silently wrong cell set rather than an error. It also assumes callers know which of the four modes they need before running the conversion: switching modes after the fact on an already-polyfilled cell set is not possible without re-running against the source polygon.

Edge cases affecting this page
  • - Polygons thinner than a cell can yield zero center-contained cells.
  • - Polygons much smaller than a cell may be missed or over-represented by a single cell.
  • - A cell that only shares a boundary point/edge (zero area) with the polygon.
  • - Douglas-Peucker-style simplification shifts the boundary, moving which cells qualify.
  • - Interior rings (donuts) must be respected so cells inside a hole are excluded.