All sections

Geometry Normalization

Every downstream H3 conversion assumes closed, correctly wound, EPSG 4326 rings with no self-intersections, and normalization is the single gate that must guarantee it.

exactstableh35 min read

Polyfilling, circle construction, and crosswalking all assume their input is a valid GeoJSON polygon in EPSG:4326 with closed, correctly wound rings and no self-intersections. None of those assumptions hold for raw vendor data by default. Normalization is the step that either makes them hold or reports, by code, exactly why it cannot — it is deliberately conservative: it repairs what is safe to repair and flags the rest rather than guessing.

What normalization covers

CRS detection and reprojection. Vendor shapefiles frequently arrive in a projected CRS (state plane, Albers equal-area, UTM zones) rather than geographic coordinates. Every polygon must be reprojected to EPSG:4326 (WGS84 longitude/latitude in decimal degrees) before any H3 operation, since h3-js and the polyfill/circle libraries in this KB assume geographic coordinates on a sphere. Reprojection error for CONUS-scale admin polygons is typically sub-meter, but should be verified rather than assumed for Alaska, Hawaii, and territories where projection choice matters more.

Axis-order validation. GeoJSON specifies [longitude, latitude]; many GIS tools, and essentially all human-readable lat/lng pairs, use the reverse order. A coordinate out of range in one order but valid in the other (for example (74.006, -40.7128): 74.006 is out of the minus-90-to-90 latitude range, but valid as (lng, lat) reversed) is flagged as a suspected axis swap rather than silently accepted, because guessing wrong places the geometry in a different hemisphere.

Ring closure. A linear ring's first and last coordinate must be identical; open rings are closed by appending the first vertex.

Winding order. RFC 7946 requires the exterior ring to wind counter-clockwise and interior (hole) rings to wind clockwise, both as seen from above the plane (right-hand rule on the sphere). Reversed winding does not always break area calculations, but it breaks assumptions some polygon-clipping and point-in-polygon implementations make about which side is "inside," so it is corrected unconditionally.

Self-intersection. Bowtie or otherwise self-crossing rings make area and containment undefined — a "polygon" that crosses itself does not have a well-defined inside. This is detected (via intersection-point search) and flagged as an error-severity issue; it is not auto-repaired, because the repair (commonly a zero-width buffer) can silently change which parts of the shape are considered interior, and that decision belongs upstream, with whoever owns the source file.

Duplicate vertex removal. Consecutive identical vertices are collapsed; they add no information and can degenerate downstream triangulation or edge-densification logic.

Multipolygon and hole handling. Every ring of every part must be passed through independently — holes retain their reversed winding and are excluded from containment, and every disjoint part (offshore islands, exclaves) must be normalized and carried forward; dropping a part because only the first ring was iterated is a common but silent failure.

Invalid coordinate rejection. Coordinates outside [-180, 180] longitude or [-90, 90] latitude that are not explained by an axis swap are rejected outright — a corrupted normalization result is worse than an explicit failure.

Antimeridian splitting, polar handling, simplification, precision, and temporal versioning are each significant enough to warrant their own treatment: antimeridian-crossing rings must be split rather than treated as planar (see antimeridian handling); polar geometry breaks the small-angle assumptions some simplification algorithms rely on; simplification tolerance must be recorded because it moves which H3 cells later qualify; coordinate precision below the target H3 resolution's edge length is false precision and should be capped, not trusted; and every normalized geometry should be stamped with the vintage of the source file it came from, because boundaries change over time even when the file format does not.

Validation checklist

  1. Confirm or detect source CRS; reproject to EPSG:4326 if not already.
  2. Range-check every coordinate against [-180,180] x [-90,90]; flag suspected axis swaps (out of range, valid when reversed) as errors.
  3. Close every ring (first vertex equals last).
  4. Remove consecutive duplicate vertices.
  5. Enforce RFC 7946 winding: exterior counter-clockwise, holes clockwise.
  6. Detect self-intersections; flag as an error, do not auto-repair.
  7. Verify every ring has at least 3 distinct vertices before closure.
  8. Iterate all rings of all parts for multipolygons; do not assume a single outer ring.
  9. Split any ring whose longitude span exceeds 180 degrees at the antimeridian rather than passing it through as planar.
  10. Record simplification tolerance and boundary vintage on the normalized output, not just on the source file.

Algorithm

import { normalizePolygon } from "@/lib/geometry/normalize";

const result = normalizePolygon(sourceRings);

if (!result.ok) {
  // result.issues contains only error-severity codes here, e.g.
  // "self_intersection", "suspected_axis_swap", "out_of_range_coord",
  // "too_few_vertices" — normalization refuses to guess past these.
  throw new Error(
    result.issues.map((i) => `${i.code}: ${i.detail}`).join("; "),
  );
}

// result.normalized is a closed, correctly wound EPSG:4326 GeoJSON polygon.
// result.issues may still contain warning-severity entries (e.g.
// "wrong_winding", "unclosed_ring", "duplicate_vertex") describing what
// was silently repaired — log them, don't discard them.
for (const issue of result.issues) {
  console.warn(`${issue.severity} ${issue.code}: ${issue.detail}`);
}

Edge cases

Self-intersecting rings (self-intersections) are detected via kink search and rejected rather than repaired in place. Holes must be verified for correct (clockwise) winding after any repair step, since a hole with exterior-style winding will be read as additional covered area instead of an exclusion. Multipart geometries (multipart-geometries) require iterating every ring of every part — a normalizer that assumes one outer ring will silently drop islands and exclaves. Axis-order reversal (axis-order-reversal) is only detectable, not always correctable: a coordinate that is in range in both orderings (for example, anything within about 90 degrees of the equator on both axes) cannot be disambiguated from range alone and needs a source-metadata check or a bounding-box sanity test against the expected region.

Assumptions and limitations

Normalization assumes the source rings represent a single coherent geometry at one CRS; it does not attempt to merge, dissolve, or reconcile geometries from multiple sources. It also does not perform reprojection from arbitrary projected CRSes in the browser bundle — it detects the symptom (coordinates outside the valid geographic-degree range with no valid axis-swap explanation) and requires an upstream reprojection step for non-4326 sources rather than guessing a projection to invert.

Illustration — holes are respected

A square with an interior ring: normalization keeps the hole, so center-fill excludes the cells inside it.
Rendered from the tested conversion code · A square with an interior ring: normalization keeps the hole, so center-fill excludes the cells inside it.
Edge cases affecting this page
  • - Bowtie/overlapping rings make area and containment undefined.
  • - Interior rings (donuts) must be respected so cells inside a hole are excluded.
  • - Disjoint parts (islands, exclaves) must all be filled; a single-ring assumption drops parts.
  • - Coordinates supplied as [lat,lng] where [lng,lat] is expected place geometry in the wrong hemisphere.