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:
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
BIGINTcolumn 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¶
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.
from_string / from_array¶
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).
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