All sections

H3 To Circumscribed Circle

Approximating an H3 cell with the smallest centered disk that fully contains it, guaranteeing coverage at the cost of overlap with neighboring cells

expansivestableh36 min read
Source geometry
h3_cell_set
Destination geometry
point_radius

Purpose

When a point-radius execution surface must guarantee that no part of a targeted cell is left unreached — a proximity alert, a store-visit radius, a coverage-complete media buy — the circumscribed circle is the correct construction. It trades the opposite direction from the inscribed circle: it never undershoots the cell, at the cost of claiming ground outside it and overlapping every neighboring cell's own circumscribed circle.

Source geometry and destination geometry

Source is an h3_cell_set, one circle computed per cell. Destination is point_radius: a center latitude/longitude and a radius in meters, one pair per input cell.

Definition

Center is the H3 cell center (cellToLatLng). Radius is the maximum geodesic distance from that center to any point on the cell's true boundary. For a hexagonal cell this maximum is attained exactly at a vertex — unlike the inscribed case, using the vertex set here is directionally correct, not a bug — but the implementation still densifies each edge with great-circle samples before taking the maximum, because that same routine must also be correct for distorted, non-regular, and pentagon cells where the true maximum can, in principle, sit off-vertex under numerical perturbation. A relative SAFETY_MARGIN = 1e-4 is added to the sampled maximum so the guarantee holds even between samples and against floating-point error in the underlying trig.

Robust algorithm

function circumscribedRadius(cell):
    center = cellCenter(cell)
    boundary = cellBoundary(cell)          # great-circle arcs, vertex list
    samples = densifyGreatCircle(boundary, perEdge=64)
    maxDist = 0
    for p in samples:
        d = haversineDistance(center, p)
        if d > maxDist: maxDist = d
    return maxDist * (1 + 1e-4)            # SAFETY_MARGIN growth
import { circumscribedCircle } from "@/lib/h3/circles";

const approx = circumscribedCircle(cell);
// approx.center: [lat, lng]
// approx.radiusMeters: maximum center->boundary distance, margin-grown
// approx.cellAreaM2, approx.circleAreaM2, approx.isPentagon

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

import math
import h3

DEFAULT_EDGE_SAMPLES = 64
SAFETY_MARGIN = 1e-4

def _to_unit_vector(lat: float, lng: float):
    lat_r, lng_r = math.radians(lat), math.radians(lng)
    return (
        math.cos(lat_r) * math.cos(lng_r),
        math.cos(lat_r) * math.sin(lng_r),
        math.sin(lat_r),
    )

def _to_latlng(v):
    x, y, z = v
    return (math.degrees(math.asin(z)), math.degrees(math.atan2(y, x)))

def _slerp(a, b, t: float):
    # Great-circle interpolation between two unit vectors — the same
    # densify step used on the inscribed-circle page, reused here because
    # the maximum, like the minimum, must be measured against the full
    # densified boundary, not just the vertex list.
    dot = max(-1.0, min(1.0, sum(ai * bi for ai, bi in zip(a, b))))
    theta = math.acos(dot)
    if theta == 0:
        return a
    sin_theta = math.sin(theta)
    wa = math.sin((1 - t) * theta) / sin_theta
    wb = math.sin(t * theta) / sin_theta
    return tuple(wa * ai + wb * bi for ai, bi in zip(a, b))

def densify_edge_geodesic(a, b, samples: int = DEFAULT_EDGE_SAMPLES):
    va, vb = _to_unit_vector(*a), _to_unit_vector(*b)
    return [_to_latlng(_slerp(va, vb, i / samples)) for i in range(samples + 1)]

def circumscribed_radius_m(cell: str) -> float:
    center = h3.cell_to_latlng(cell)
    boundary = h3.cell_to_boundary(cell)
    n = len(boundary)
    max_dist = 0.0
    for i in range(n):
        a, b = boundary[i], boundary[(i + 1) % n]
        for sample in densify_edge_geodesic(a, b):
            d = h3.great_circle_distance(center, sample, unit="m")
            if d > max_dist:
                max_dist = d
    return max_dist * (1 + SAFETY_MARGIN)  # grow so the guarantee holds between samples

h3-py has no circumscribedCircle helper; this mirrors the same densify-then-measure approach as the inscribed-circle page but takes the maximum center-to-sample great_circle_distance instead of the minimum, then grows it by the 1e-4 safety margin instead of shrinking it. For a regular hexagon the true maximum lands on a vertex, but the algorithm still densifies every edge rather than checking only the vertex list, so it stays correct for pentagons and distorted cells too. The tested reference implementation is the TypeScript in lib/.

The circumscribed circle reaches the farthest vertex: r=205.7 m for this R9 cell.
Rendered from the tested conversion code · The circumscribed circle reaches the farthest vertex: r=205.7 m for this R9 cell.

Containment guarantee

Every point of the true H3 cell lies inside the circumscribed disk: cell ⊆ circumscribed disk. This is the mirror-image guarantee of the inscribed circle's in the other direction, and it holds under the same spherical model and the same 1e-4 safety margin against the finite-sample approximation.

Resolution behavior

Radius scales with cell edge length, shrinking by roughly sqrt(7) ≈ 2.65× per resolution step, same as the inscribed circle. The relative overreach — overreach area as a fraction of cell area — is resolution-invariant for regular hexagons, since it is a function of cell shape, not size; the absolute duplicate-eligibility area shrinks with resolution even as the relative figure holds steady.

Units and CRS

Center and boundary coordinates are [lat, lng] (h3-js native order), EPSG:4326. Distances are spherical (haversine) meters on the same R_MEAN = 6,371,008.8 m sphere cellArea uses.

Quality metrics

overreach_ratio
area(circumscribed disk − cell) / area(cell). For a regular hexagon this is approximately 0.209 — the disk is about 20.9% larger than the cell it circumscribes.
duplicate_eligibility_area
computed via duplicateEligibilityAreaM2(circles) — total area double-counted across a set of overlapping circumscribed circles: Σ area(disk_i) − area(union of disks). Non-zero whenever any two neighboring cells' circumscribed circles overlap, which is every adjacent pair by construction.

For a regular hexagon, circumradius equals the cell's own "radius" R (center to vertex), so circleAreaM2 = π R² against a hexagon area of (3√3/2) R² ≈ 2.598 R², giving a disk/cell area ratio of about 1.209 — consistent with the cos(30°) ≈ 0.866 radius ratio between this construction and the inscribed circle: r_in / r_out = cos(30°) for a regular hexagon, so the two constructions bound the true cell area from below (0.907× cell area) and above (1.209× cell area) respectively, with the cell itself sitting strictly between.

Duplicate eligibility and experiment contamination

Because every circumscribed circle extends past its cell's true boundary, adjacent circles overlap in a band along every shared edge. Any ground in that band is eligible under two (or, near a hexagon corner, three) circles at once. This is a measurement hazard, not merely a targeting inefficiency: a device or household in an overlap band can be counted as reached by two "different" cells in a report, inflating apparent reach and corrupting per-cell frequency capping. duplicateEligibilityAreaM2 quantifies this directly from the set of circle features actually used, rather than from a theoretical hexagon — call it after generating the full circle set for a campaign, not per-cell in isolation, since the metric is inherently a property of the set.

Experiment contamination

If circumscribed circles are used to define treatment cells in a geo-lift test, the overlap band between a treatment cell and an adjacent control cell means the control cell is partially exposed to the treatment circle's radius. This leaks treatment into control and biases the measured lift downward. Use the inscribed circle, not this construction, whenever cells must remain mutually exclusive for measurement purposes.

Edge cases

Pentagons have five, shorter, less regular edges, and their circumradius is measured relative to a smaller, less regular cell area, so the overreach ratio at the 12 pentagon cells per resolution deviates further from the regular-hexagon figure above — flag them (isPentagon on the result) and do not average them into a fleet-wide overreach estimate. Cells whose boundary crosses an icosahedron face seam are still handled correctly by the distance-to-boundary maximum, since the algorithm makes no planarity assumption.

Assumptions and limitations

The circumscribed circle guarantees coverage but never non-overlap; treating a set of circumscribed circles as a partition (for exclusive budget allocation, for instance) will overcount the ground in every overlap band. Use duplicate_eligibility_area to size that overcounting before committing a media plan or a measurement design to this construction, and prefer the equal-area circle when the requirement is area-representative reach rather than guaranteed coverage.

Edge cases affecting this page
  • - 12 pentagon cells per resolution sit at icosahedron vertices; they break the 6-neighbour and regular-shape assumptions and have lower inscribed/circumscribed ratios.