All sections

H3 To Administrative Crosswalk

Preserving every cell-to-region relationship a boundary crossing creates, instead of collapsing a straddling H3 cell to a single administrative owner.

weightedstableh37 min read
Source geometry
h3_cell_set
Destination geometry
admin_county, census_geo, dma

Purpose

A crosswalk answers a different question than the partition built in administrative polygon to H3: not "which single region owns this cell" but "which regions does this cell touch, and by how much." It exists for downstream uses that need to split a quantity — population, spend, audience, screen inventory — across regions in proportion to real overlap, rather than force each cell into one bucket.

Source geometry and destination geometry

Source geometry is an h3_cell_set, normalized to EPSG:4326. Destination geometry is one or more administrative region types — admin_county, census_geo (tract or block group), or dma — each a polygon or multipolygon keyed by a stable region id (FIPS, GEOID, DMA code).

Exactness class

This conversion is weighted: it does not resolve to one right answer per cell, but a distribution of a cell's membership across every region it geometrically intersects, expressed as fractional weights. Two crosswalks built from the same cells and region polygons but a different weighting basis (raw area versus population) legitimately assign different shares of the same cell to the same region, and both are correct for their stated basis.

Containment rule and boundary behavior — membership rules

centroid
Cell belongs to the region whose polygon contains the cell center. Single-owner, cheap, blind to how much of the cell lies outside that region.
largest-overlap
Cell assigned to whichever region holds the largest share of its area. Single-owner, area-aware rather than point-aware.
any-overlap
Cell listed against every region it intersects at all, no fraction attached. Many-to-many, boolean — eligibility, not apportionment.
full-containment
Cell listed against a region only if the whole cell lies inside it. Straddling cells belong to no region alone; pair with a partial rule to avoid gaps.
area-weighted
Every (cell, region) pair retained, weight equal to intersection_area / cell area. Assumes uniform area density inside the cell.
population-weighted
Weight is region_population times (intersection_area / region_total_area). Assumes uniform population density, false near urban cores at fine resolution.
audience-weighted
Same interpolation, apportioning a platform or panel audience count instead of census population — subject to the panel's own coverage bias.
inventory-weighted
Weight is a count of physical or media units (screens, store fronts) intersecting the cell, apportioned by area or unit point locations.
probabilistic
Weight drawn from an exposure or gravity model instead of assumed-uniform area — road density, measured foot traffic. Only as good as that model's validation.

Why the many-to-many relationship must be preserved

A cell that straddles two regions is not an edge case to be resolved away — it is the geometric reality of overlaying a hexagonal grid on polygons whose boundaries were drawn without regard to that grid. Collapsing a straddling cell to a single argmax region discards the minority share entirely. Consider a cell split 70/30 between County A and County B: an argmax crosswalk assigns 100% of the cell to County A, which is now overcounted by the 30% it never held, while County B is undercounted by the 30% it did hold — in the same operation, on the same cell. Aggregated over every boundary cell along a county line, this is not noise but a systematic bias: smaller regions sharing a long boundary with a larger neighbor lose share every time, in the same direction. A many-to-many crosswalk is the only representation that lets a caller reconstruct the true split later — a single-owner table cannot be repaired downstream once the discarded fraction is gone.

Resolution behavior

Coarser resolutions have fewer, larger cells, so a higher fraction of the cells adjacent to any boundary straddle it. Finer resolutions reduce that fraction — cell area shrinks roughly sevenfold per step while boundary- adjacent cell count grows only with the boundary's length — but the fraction never reaches zero at any finite resolution, since a boundary is a continuous curve and the grid is discrete. A crosswalk is required at every resolution; what changes is the total misallocated area a single-owner rule would incur, not whether the problem exists.

Units and CRS

Region polygons and H3 cell boundaries are normalized to EPSG:4326 before intersection. intersection_area is computed as spherical (haversine- consistent) m², matching cellArea(cell, "m2"), so cell_coverage_fraction and region_coverage_fraction stay dimensionless ratios of comparable area units rather than a mix of projected and unprojected area.

Algorithm

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

// Many-to-many: every (cell, region) pair the cell actually touches,
// retained with an area-based weight.
const crosswalk = weightedCrosswalk(cells, regionPolygons, {
  resolution: 8,
  weightBy: "area", // or "population" | "audience" | "inventory"
});

// Single-owner reference view, DERIVED from the same overlaps —
// never treat this as the source of truth for apportionment.
const primaryRegion = maxOverlapAssignment(cells, regionPolygons, {
  resolution: 8,
});

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

import h3
from collections import defaultdict
from shapely.geometry import Polygon

def cell_polygon(cell: str) -> Polygon:
    # h3-py returns (lat, lng) pairs; shapely expects (x, y) = (lng, lat).
    boundary = h3.cell_to_boundary(cell)
    return Polygon([(lng, lat) for lat, lng in boundary])

def weighted_crosswalk(cells, region_polygons: dict[str, Polygon]):
    # Many-to-many: every (cell, region) pair the cell actually touches,
    # retained with an area-based weight.
    rows = []
    for cell in cells:
        cell_poly = cell_polygon(cell)
        cell_area = cell_poly.area
        for region_id, region_poly in region_polygons.items():
            overlap = cell_poly.intersection(region_poly).area
            if overlap > 0:
                rows.append({
                    "cell_id": cell,
                    "region_id": region_id,
                    "cell_coverage_fraction": overlap / cell_area,
                })
    return rows

def max_overlap_assignment(cells, region_polygons: dict[str, Polygon]):
    # Single-owner reference view, DERIVED from the same overlaps — an
    # argmax over each cell's rows, not a separate source of truth.
    best = defaultdict(lambda: (None, 0.0))
    for row in weighted_crosswalk(cells, region_polygons):
        cell, region, frac = row["cell_id"], row["region_id"], row["cell_coverage_fraction"]
        if frac > best[cell][1]:
            best[cell] = (region, frac)
    return {cell: region for cell, (region, _) in best.items()}

shapely here computes planar area on lat/lng coordinates, which is only adequate for illustration at this scale — the tested reference implementation in this knowledge base is the TypeScript in lib/, which uses spherical (haversine-consistent) area throughout.

Two adjacent regions at R8; boundary cells assigned by max overlap (blue=west, pink=east).
Rendered from the tested conversion code · Two adjacent regions at R8; boundary cells assigned by max overlap (blue=west, pink=east).

Parameters

Resolution, weighting basis (area, population, audience, inventory, or a supplied probabilistic model), region polygon vintage, and whether full-containment rows are emitted alongside partial rows.

Outputs

A weighted crosswalk table: cell_id, source_region_id, intersection_area, cell_coverage_fraction (intersection_area divided by cell area), region_coverage_fraction (intersection_area divided by region area), and, when a non-area basis is requested, an added weight column (population_weight, audience_weight, inventory_weight, or probability_weight). A cell touching three regions produces three rows.

Quality metrics

For each cell, the sum of cell_coverage_fraction across its rows should equal 1.0 within floating-point tolerance (typically 1e-6) if the region set is a true partition; a sum below 1.0 indicates a gap under the cell, and a sum above 1.0 indicates overlapping region polygons — both are per-cell diagnostics worth surfacing before the crosswalk ships. Compute coverage_ratio, overreach_ratio, and jaccard per region against its source polygon to confirm the crosswalk's apportioned area tracks the region's true area.

Edge cases

Duplicated region ids occur when a multipart region (an island county, a DMA split by a lake) is stored as multiple ring records sharing one id — deduplicating on region id before intersecting drops coverage from the smaller part. Stale boundaries are the more common failure: DMA and census vintages change year over year, so a crosswalk built against last year's polygons misapportions every cell near a moved boundary with no error raised — boundary_vintage must be recorded and checked against what the destination expects.

Assumptions and limitations

Area- and population-weighted crosswalks assume uniform density within the source region for whatever is being apportioned; this degrades visibly at coarse resolution near dense urban boundaries, where population is not remotely uniform across a county. When better information exists — a gravity model, foot-traffic panel, inventory point locations — prefer probabilistic or point-apportioned inventory-weighted over an area default.

Illustration — weighted vs argmax

Every (cell, region) overlap is kept; opacity is the cell coverage fraction, so cells straddling the seam fade — the information a max-overlap partition throws away.
Rendered from the tested conversion code · Every (cell, region) overlap is kept; opacity is the cell coverage fraction, so cells straddling the seam fade — the information a max-overlap partition throws away.
Edge cases affecting this page
  • - The same admin id mapping to multiple polygons (data error) breaks partition assumptions.
  • - Admin/postal/DMA boundaries change; using an old vintage misassigns cells.