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:
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).
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.
