Skip to content

Geodetic::Coordinate::S2

S2 Spherical Geometry Index

S2 is Google's hierarchical geospatial indexing system. It projects a cube onto the unit sphere, then recursively subdivides each of the six cube faces into quadrilateral cells using a Hilbert space-filling curve. Each cell is identified by a 64-bit integer called a cell ID, typically displayed as a token — a hex string with trailing zeros stripped.

The name comes from the mathematical notation S² for the unit sphere.

S2 requires the libs2 C++ library installed on your system. Without it, all other coordinate systems work normally; S2 operations raise a clear error message with installation instructions.

Prerequisites

# macOS (Homebrew)
brew install s2geometry

# Linux (build from source)
git clone https://github.com/google/s2geometry.git
cd s2geometry
cmake -B build -DCMAKE_INSTALL_PREFIX=/usr/local
cmake --build build
sudo cmake --install build

You can also set the LIBS2_PATH environment variable to specify a custom library path:

export LIBS2_PATH=/path/to/libs2.dylib

Key Differences from Other Spatial Hashes

Feature GH/OLC/GARS/GEOREF/HAM H3 S2
Cell shape Rectangle Hexagon (6 vertices) Quadrilateral (4 vertices)
to_area returns Areas::BoundingBox Areas::Polygon (6 verts) Areas::Polygon (4 verts)
neighbors returns Hash with 8 cardinal keys Array of 6 cells Array of 4 edge cells
Code format String 64-bit integer (hex) 64-bit integer (hex token)
Hierarchy String prefix Parent/child by resolution Parent/child by level (quadtree)
Levels String length 0-15 0-30
Projection Rectangular lat/lng Icosahedron Cube-on-sphere
Distortion High at poles Low (~1.2x) Very low (~0.56%)
Spatial ordering Z-order (GH) / none None guaranteed Hilbert curve (locality)
Dependency None (pure Ruby) libh3 (C) libs2 (C++)

S2 is a 2D coordinate system (no altitude). Conversions to/from other systems go through LLA as the intermediary. Each token represents a quadrilateral cell; the coordinate's point value is the cell's centroid.

Cell ID vs Token

A cell ID and a token represent the same cell in two formats:

  • cell_id — the raw 64-bit unsigned integer. Use this for database storage and range queries. Standard B-tree indexes on a BIGINT column give you spatial queries for free.

  • token — the cell_id printed as hex with trailing zeros stripped. Use this for display, logging, and human-readable interchange. Shorter tokens represent coarser (larger) cells.

cell = S2.new(seattle, precision: 15)
cell.cell_id  # => 6093487605347778560
cell.to_s     # => "54906ab14"

# Equivalence: token is just compact hex of cell_id
"54906ab14".ljust(16, '0').to_i(16)  # => 6093487605347778560

Tokens naturally reveal the hierarchy — a parent's token is always a prefix of its descendants:

Level  0: "5"                 (face 2)
Level  5: "5494"
Level 10: "54906b"
Level 15: "54906ab14"
Level 20: "54906ab12f1"
Level 30: "54906ab12f10f899"

For database applications, store the cell_id as a BIGINT. Spatial containment queries become range scans:

-- Find all points inside a level-12 cell
SELECT * FROM locations
WHERE s2_cell_id BETWEEN :range_min AND :range_max

The Hilbert curve ordering guarantees that spatially nearby cells have numerically nearby cell_ids, so these range scans are efficient on standard B-tree indexes — no spatial index extension required.

Constructor

# From a token string
coord = Geodetic::Coordinate::S2.new("54906ab14")

# From a token with 0x prefix
coord = Geodetic::Coordinate::S2.new("0x54906ab14")

# From a 64-bit cell ID integer
coord = Geodetic::Coordinate::S2.new(6093487605347778560)

# From any coordinate (converts via LLA)
coord = Geodetic::Coordinate::S2.new(lla_coord)
coord = Geodetic::Coordinate::S2.new(utm_coord, precision: 20)
Parameter Type Default Description
source String, Integer, or Coord -- An S2 token, cell ID integer, or coordinate
precision Integer 15 S2 cell level (0-30)

Raises ArgumentError if the source string is empty, contains invalid hex characters, or does not produce a valid S2 cell ID. String input is case-insensitive (normalized to lowercase). The 0x prefix is stripped automatically.

Attributes

Attribute Type Access Description
code String read-only The token string representation
cell_id Integer read-only The 64-bit S2 cell ID

S2 is immutable — there are no setter methods.

Levels

S2 uses "level" (0-30) for its hierarchy. Higher level means smaller cells. Each level subdivides cells by 4 (quadtree), so area decreases by roughly 4x per level.

Level Approximate Cell Area Approximate Edge Length
0 85,201,316 km² ~9,200 km (face cell)
1 21,300,329 km² ~4,600 km
5 79,002 km² ~281 km
10 79.4 km² ~8.9 km
12 5.0 km² ~2.2 km
15 77,544 m² (default) ~278 m
18 1,212 m² ~35 m
20 75.7 m² ~8.7 m
24 0.30 m² (2,958 cm²) ~54 cm
28 11.55 cm² ~3.4 cm
30 0.72 cm² ~0.85 cm
coord.level                  # => 15 (alias: coord.precision)
coord.cell_area              # => 77544.2 (square meters)
coord.precision_in_meters    # => { lat: ~278, lng: ~278, area_m2: ~77544 }
S2.average_cell_area(15)     # => 79349.9 (average across all level-15 cells)

The Six Cube Faces

S2 projects a cube onto the sphere, giving 6 face cells at level 0:

Face Direction Center Token
0 +X (front) (0°, 0°) 1
1 +Y (right) (0°, 90°E) 3
2 +Z (top) (90°N, 0°) 5
3 -X (back) (0°, 180°) 7
4 -Y (left) (0°, 90°W) 9
5 -Z (bottom) (90°S, 0°) b

The cube-on-sphere projection limits distortion to approximately 0.56% across the entire Earth surface — far less than rectangular projections which distort heavily near the poles.

coord.face        # => 2 (Seattle is on face 2, the +Z/north pole face)
coord.face_cell?  # => false (only level-0 cells are face cells)

Checking Availability

Geodetic::Coordinate::S2.available?   # => true if libs2 is found

Conversions

All conversions chain through LLA. The datum parameter defaults to Geodetic::WGS84.

Instance Methods

coord.to_lla                    # => LLA (centroid of the cell)
coord.to_ecef
coord.to_utm
coord.to_enu(reference_lla)
coord.to_ned(reference_lla)
coord.to_mgrs
coord.to_usng
coord.to_web_mercator
coord.to_ups
coord.to_state_plane(zone_code)
coord.to_bng
coord.to_gh36
coord.to_gh
coord.to_ham
coord.to_olc
coord.to_georef
coord.to_gars
coord.to_h3                    # (requires libh3)

Class Methods

S2.from_lla(lla_coord)
S2.from_ecef(ecef_coord)
S2.from_utm(utm_coord)
S2.from_web_mercator(wm_coord)
S2.from_gh(gh_coord)
S2.from_h3(h3_coord)
# ... and all other coordinate systems

LLA Convenience Methods

lla = Geodetic::Coordinate::LLA.new(lat: 47.6062, lng: -122.3321)
s2 = lla.to_s2                    # default level 15
s2 = lla.to_s2(precision: 20)     # level 20

lla = Geodetic::Coordinate::LLA.from_s2(s2)

Serialization

to_s(format = nil)

Returns the token string. Pass :integer to get the 64-bit cell ID.

coord = S2.new("54906ab14")
coord.to_s             # => "54906ab14"
coord.to_s(:integer)   # => 6093487605347778560
coord.cell_id          # => 6093487605347778560

to_a

Returns [lat, lng] of the cell centroid.

coord.to_a    # => [47.605..., -122.334...]

from_string / from_array

S2.from_string("54906ab14")                  # from token
S2.from_array([47.6062, -122.3321])          # from [lat, lng]

WKT, WKB, GeoJSON

S2 coordinates support all standard serialization formats:

coord.to_wkt                        # => "POINT(-122.334371 47.605024)"
coord.to_wkt(srid: 4326)           # => "SRID=4326;POINT(-122.334371 47.605024)"
coord.to_wkb_hex                    # => "010100000014dbfb5466955ec0..."
coord.to_geojson                    # => {"type" => "Point", "coordinates" => [...]}

# Cell polygon serialization
coord.to_area.to_wkt               # => "POLYGON((-122.33392 47.606536, ...))"
coord.to_area.to_geojson           # => {"type" => "Polygon", ...}

Cell Hierarchy

S2 cells form a strict quadtree: each cell has exactly 4 children and 1 parent (except level-0 face cells which have no parent).

Parent

coord = S2.new("54906ab14")        # level 15
parent = coord.parent              # level 14 (one level up)
parent = coord.parent(10)          # level 10 (5 levels up)
parent = coord.parent(0)           # level 0 (face cell)

Raises ArgumentError if the target level is not coarser (lower number) than the current level.

Children

coord = S2.new("54906ab14")        # level 15
children = coord.children          # => [S2, S2, S2, S2] at level 16
children.length                    # => 4

Raises ArgumentError on leaf cells (level 30).

Ancestry Traversal

leaf = S2.new(seattle, precision: 30)
[30, 20, 10, 0].each do |lvl|
  ancestor = lvl == 30 ? leaf : leaf.parent(lvl)
  puts "Level #{lvl}: #{ancestor.to_s}"
end

Neighbors

Returns the 4 edge-adjacent cells as an Array. Unlike rectangular hashes (which return 8 directional neighbors), S2 cells are quadrilaterals with exactly 4 edges.

coord = S2.new("54906ab14")
neighbors = coord.neighbors
# => [S2, S2, S2, S2]

neighbors.length               # => 4
neighbors.first.level          # => 15 (same level as source)

Containment and Intersection

S2 cells have strict hierarchical containment — a cell contains exactly its descendants and intersects exactly its ancestors and descendants.

coarse = S2.new(seattle, precision: 10)
fine   = S2.new(seattle, precision: 20)

coarse.contains?(fine)       # => true
fine.contains?(coarse)       # => false
coarse.intersects?(fine)     # => true
fine.intersects?(coarse)     # => true

Database Range Scans

Every S2 cell defines a contiguous range of cell IDs that covers all its descendants. This enables spatial queries as simple integer range scans.

cell = S2.new(seattle, precision: 12)
cell.range_min   # => 6093487531259592705
cell.range_max   # => 6093487668698546175
cell.cell_id     # => 6093487599767896064 (between min and max)

Any descendant cell's ID falls within [range_min, range_max]:

child = S2.new(seattle, precision: 20)
child.cell_id >= cell.range_min  # => true
child.cell_id <= cell.range_max  # => true

Cell Area

S2 provides exact cell area calculations using the C++ library's spherical geometry.

coord.cell_area              # => 77544.2 (square meters for level 15)
S2.average_cell_area(15)     # => 79349.9 (global average for level 15)
coord.precision_in_meters    # => { lat: 278.5, lng: 278.5, area_m2: 77544.2 }

Cell areas are computed as exact spherical surface area (steradians) multiplied by R², giving results in square meters.

Cell Polygon (to_area)

The to_area method returns the quadrilateral cell boundary as an Areas::Polygon with 4 vertices.

area = coord.to_area
# => Geodetic::Areas::Polygon

area.includes?(coord.to_lla)    # => true (centroid is inside the cell)
area.boundary.length             # => 5 (4 vertices + closing point)

S2 cell boundaries are geodesics (great circle arcs), not straight lines in lat/lng space. The polygon approximation uses the 4 corner vertices connected by straight edges.

Leaf and Face Cells

coord.leaf?        # => true if level == 30 (smallest possible cell, ~0.7 cm²)
coord.face_cell?   # => true if level == 0 (one of 6 cube faces, ~85M km²)

Equality

Two S2 instances are equal if their token strings match exactly.

S2.new("54906ab14") == S2.new("54906ab14")                       # => true
S2.new("54906ab14") == S2.new(6093487605347778560)                # => true
S2.new("54906ab14") == S2.new("54906ab1c")                       # => false

valid?

Returns true if the cell ID has valid structure (correct face bits, properly positioned sentinel bit).

coord.valid?    # => true

Universal Distance and Bearing Methods

S2 supports all universal distance and bearing methods via the DistanceMethods and BearingMethods mixins:

seattle = S2.new(Coordinate::LLA.new(lat: 47.6062, lng: -122.3321), precision: 15)
nyc     = S2.new(Coordinate::LLA.new(lat: 40.7128, lng: -74.0060), precision: 15)

seattle.distance_to(nyc)                # => Distance (~3,876 km)
seattle.straight_line_distance_to(nyc)  # => Distance
seattle.bearing_to(nyc)                 # => Bearing (~83°, roughly east)
seattle.elevation_to(nyc)               # => Float (degrees)

Geodetic Arithmetic

S2 cells support the standard arithmetic operators:

s2 = S2.new(seattle, precision: 15)
v = Geodetic::Vector.new(distance: 10_000, bearing: 90)

s2 * v          # => LLA (translated cell center)
s2 + v          # => Segment (from center to destination)
s2 + distance   # => Circle (centered on cell center)
s2 + other_s2   # => Segment (between cell centers)

Well-Known S2 Tokens

Location Token (level 30) Token (level 15) Face
Seattle 54906ab12f10f899 54906ab14 2
New York 89c25a220cf80969 89c25a224 4
Tokyo 6018f25555544b7f 6018f254c 3
London 487604ce36748fa9 487604d04 2
Sydney 6b12ae3ff6290055 6b12ae3fc 3
Null Island (0°, 0°) 1000000000000001 1000000000000004 0

Implementation Notes

Geodetic uses Ruby's fiddle (part of the standard library) to call the S2 C++ library directly. Despite S2 being C++ (not C), the key functions are callable through their mangled symbol names. No gem dependency beyond fiddle is required. The S2 C++ library must be installed separately.

Functions called via Fiddle: - S2CellId::S2CellId(S2LatLng const&) — encode lat/lng to cell ID - S2CellId::ToFaceIJOrientation() — decode cell ID to face/IJ coordinates - S2CellId::GetEdgeNeighbors() — 4 adjacent cells - S2Cell::ExactArea() — spherical surface area in steradians - S2Cell::AverageArea(level) — average area for a level

Pure Ruby operations (no FFI overhead): - Cell level, face, parent, child — bit manipulation on the 64-bit cell ID - Token encode/decode — hex formatting with trailing zero stripping - Coordinate transforms — face/IJ → ST → UV → XYZ → lat/lng pipeline - Cell vertex computation — 4 corners via the coordinate pipeline - Containment/intersection — integer range comparisons - Range min/max — bit arithmetic for database scan ranges - Validity checks — sentinel bit position verification