geodetic 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 782ba82d99727dfa9a075fcdfb9dea89f631ebc78e0e105293745c64f77f9dae
4
- data.tar.gz: 59e9c844470d77026a9929c8b9ffd01a5d065fd491d8efb67106b0903267c681
3
+ metadata.gz: 6d81eb7c69d9aff7d512562f54e2413c91c8ff10b2361c9143a8afd0c1af0ca0
4
+ data.tar.gz: e7fb09dd1f7d4d97f57ea48f9cacb0e6afbd30abedab1aa04cbf1153c2fe4373
5
5
  SHA512:
6
- metadata.gz: 38543deb9e8d62aa20b4a90c9602b0e97806d7d2f55dd9d308a138b9aea2b033e6f51656a9d247ce750d5abcd5298f170825a3f0e4f013a63198bc467e1f3d8c
7
- data.tar.gz: fcb62fac876d350bd784d08bb17d45b7c14218a31ff6cfcce4be7e384e3f9a6a2c711ae3731b4a4241e5b34c062a8ddcdc2fb8ce6786ac114ff35d872ad8a7f3
6
+ metadata.gz: 90a2e1b8bbc1c6bdf59c7e326d4965f8ec85a16682bccba948fd1f104d7818a5fa34ee179fa00de0221cbfd0e9d9afbd5c667aac46c1a6c9c070b338618b461a
7
+ data.tar.gz: 85b40d6e961ec4782a9748232eed4191df1dd8a5f22df77afe68c63d64b6b23df0e8ff5ec4ae927038d60919a7825e09c2429bbd4aa8d4447b1737ff81aa4dfd
data/CHANGELOG.md CHANGED
@@ -15,12 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
 
16
16
  ### Added
17
17
 
18
- - **2 new coordinate systems** bringing the total from 15 to 17:
18
+ - **3 new coordinate systems** bringing the total from 15 to 18:
19
19
  - `Geodetic::Coordinate::GEOREF` — World Geographic Reference System (aviation/military geocode with variable precision from 15-degree tiles to 0.001-minute resolution)
20
20
  - `Geodetic::Coordinate::GARS` — Global Area Reference System (NGA standard with 30-minute cells, 15-minute quadrants, and 5-minute keypads)
21
- - Full cross-system conversions for GEOREF and GARS all 17 coordinate systems convert to/from every other system (289 conversion paths)
22
- - Spatial hash features for GEOREF and GARS: `neighbors`, `to_area`, `precision_in_meters`, `to_slug`, configurable precision
23
- - Documentation pages: `docs/coordinate-systems/georef.md` and `docs/coordinate-systems/gars.md`
21
+ - `Geodetic::Coordinate::H3` Uber's H3 Hexagonal Hierarchical Index (16 resolution levels, hexagonal cells via `libh3` C library through `fiddle`)
22
+ - Full cross-system conversions for GEOREF, GARS, and H3 all 18 coordinate systems convert to/from every other system (324 conversion paths)
23
+ - Spatial hash features for GEOREF, GARS, and H3: `neighbors`, `to_area`, `precision_in_meters`, `to_slug`, configurable precision
24
+ - H3-specific features: `grid_disk(k)`, `parent(res)`, `children(res)`, `pentagon?`, `cell_area`, `h3_index`, `resolution` (0-15)
25
+ - H3 `to_area` returns `Areas::Polygon` (6 vertices for hexagons, 5 for pentagons) instead of `Areas::Rectangle`
26
+ - H3 `neighbors` returns Array of 6 cells instead of directional Hash with 8 cardinal keys
27
+ - Graceful degradation: H3 raises clear error with installation instructions if `libh3` is not found; all other coordinate systems work normally
28
+ - Documentation pages: `docs/coordinate-systems/georef.md`, `docs/coordinate-systems/gars.md`, and `docs/coordinate-systems/h3.md`
24
29
 
25
30
  ### Changed
26
31
 
data/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  <td width="50%" valign="top">
14
14
  <strong>Key Features</strong><br>
15
15
 
16
- - <strong>17 Coordinate Systems</strong> - LLA, ECEF, UTM, ENU, NED, MGRS, USNG, Web Mercator, UPS, State Plane, BNG, GH36, GH, HAM, OLC, GEOREF, GARS<br>
16
+ - <strong>18 Coordinate Systems</strong> - LLA, ECEF, UTM, ENU, NED, MGRS, USNG, Web Mercator, UPS, State Plane, BNG, GH36, GH, HAM, OLC, GEOREF, GARS, H3<br>
17
17
  - <strong>Full Bidirectional Conversions</strong> - Every system converts to and from every other system<br>
18
18
  - <strong>Distance Calculations</strong> - Vincenty great-circle and straight-line with unit tracking<br>
19
19
  - <strong>Bearing Calculations</strong> - Forward azimuth, back azimuth, compass directions, elevation angles<br>
@@ -27,7 +27,7 @@
27
27
  </tr>
28
28
  </table>
29
29
 
30
- <p>Geodetic enables precise conversion between geodetic coordinate systems in Ruby. All 17 coordinate systems support complete bidirectional conversions with high precision. Review the <a href="https://madbomber.github.io/geodetic/">full documentation website</a> and explore the <a href="examples/">runnable examples</a>.</p>
30
+ <p>Geodetic enables precise conversion between geodetic coordinate systems in Ruby. All 18 coordinate systems support complete bidirectional conversions with high precision. Review the <a href="https://madbomber.github.io/geodetic/">full documentation website</a> and explore the <a href="examples/">runnable examples</a>.</p>
31
31
 
32
32
  ## Installation
33
33
 
@@ -43,6 +43,20 @@ Or install directly:
43
43
  gem install geodetic
44
44
  ```
45
45
 
46
+ ### Optional: H3 Hexagonal Index
47
+
48
+ The H3 coordinate system requires Uber's [H3 C library](https://h3geo.org/) installed on your system. Without it, all other 17 coordinate systems work normally; H3 operations will raise a helpful error.
49
+
50
+ ```bash
51
+ # macOS
52
+ brew install h3
53
+
54
+ # Linux (build from source)
55
+ # See https://h3geo.org/docs/installation
56
+ ```
57
+
58
+ You can also set the `LIBH3_PATH` environment variable to point to a custom `libh3` location.
59
+
46
60
  ## Usage
47
61
 
48
62
  ### Basic Coordinate Creation
@@ -542,7 +556,7 @@ The [`examples/`](examples/) directory contains runnable demo scripts showing pr
542
556
  | Script | Description |
543
557
  |--------|-------------|
544
558
  | [`01_basic_conversions.rb`](examples/01_basic_conversions.rb) | LLA, ECEF, UTM, ENU, NED conversions and roundtrips |
545
- | [`02_all_coordinate_systems.rb`](examples/02_all_coordinate_systems.rb) | All 17 coordinate systems, cross-system chains, and areas |
559
+ | [`02_all_coordinate_systems.rb`](examples/02_all_coordinate_systems.rb) | All 18 coordinate systems, cross-system chains, and areas |
546
560
  | [`03_distance_calculations.rb`](examples/03_distance_calculations.rb) | Distance class features, unit conversions, and arithmetic |
547
561
  | [`04_bearing_calculations.rb`](examples/04_bearing_calculations.rb) | Bearing class, compass directions, elevation angles, and chain bearings |
548
562
 
@@ -0,0 +1,312 @@
1
+ # Geodetic::Coordinate::H3
2
+
3
+ ## H3 Hexagonal Hierarchical Index
4
+
5
+ H3 is Uber's hierarchical geospatial indexing system that divides the globe into hexagonal cells (and 12 pentagons per resolution level) using an icosahedron projection. Each cell is identified by a 64-bit integer, typically displayed as a 15-character hex string like `872a1072bffffff`.
6
+
7
+ **H3 requires the `libh3` C library** installed on your system. Without it, all other coordinate systems work normally; H3 operations raise a clear error message with installation instructions.
8
+
9
+ ### Prerequisites
10
+
11
+ ```bash
12
+ # macOS (Homebrew)
13
+ brew install h3
14
+
15
+ # Linux (build from source)
16
+ git clone https://github.com/uber/h3.git
17
+ cd h3
18
+ cmake -B build -DCMAKE_INSTALL_PREFIX=/usr/local
19
+ cmake --build build
20
+ sudo cmake --install build
21
+ ```
22
+
23
+ You can also set the `LIBH3_PATH` environment variable to specify a custom library path:
24
+
25
+ ```bash
26
+ export LIBH3_PATH=/path/to/libh3.dylib
27
+ ```
28
+
29
+ Geodetic searches these paths automatically:
30
+ - `/opt/homebrew/lib/libh3.dylib` (macOS ARM Homebrew)
31
+ - `/usr/local/lib/libh3.dylib` (macOS Intel Homebrew)
32
+ - `/usr/lib/libh3.so` (Linux system)
33
+ - `/usr/local/lib/libh3.so` (Linux local install)
34
+
35
+ ### Key Differences from Other Spatial Hashes
36
+
37
+ | Feature | GH/OLC/GARS/GEOREF/HAM | H3 |
38
+ |---------|------------------------|-----|
39
+ | Cell shape | Rectangle | Hexagon (6 vertices) |
40
+ | `to_area` returns | `Areas::Rectangle` | `Areas::Polygon` |
41
+ | `neighbors` returns | Hash with 8 cardinal keys | Array of 6 cells |
42
+ | Code format | String | 64-bit integer (hex string) |
43
+ | Dependency | None (pure Ruby) | `libh3` (C library via fiddle) |
44
+
45
+ H3 is a **2D coordinate system** (no altitude). Conversions to/from other systems go through LLA as the intermediary. Each hex string represents a hexagonal cell; the coordinate's point value is the cell's centroid.
46
+
47
+ ## Constructor
48
+
49
+ ```ruby
50
+ # From a hex string
51
+ coord = Geodetic::Coordinate::H3.new("872a1072bffffff")
52
+
53
+ # From a hex string with 0x prefix
54
+ coord = Geodetic::Coordinate::H3.new("0x872a1072bffffff")
55
+
56
+ # From a 64-bit integer
57
+ coord = Geodetic::Coordinate::H3.new(0x872a1072bffffff)
58
+
59
+ # From any coordinate (converts via LLA)
60
+ coord = Geodetic::Coordinate::H3.new(lla_coord)
61
+ coord = Geodetic::Coordinate::H3.new(utm_coord, precision: 9)
62
+ ```
63
+
64
+ | Parameter | Type | Default | Description |
65
+ |-------------|-------------------------|---------|--------------------------------------------|
66
+ | `source` | String, Integer, or Coord | -- | An H3 hex string, integer, or coordinate |
67
+ | `precision` | Integer | 7 | H3 resolution level (0-15) |
68
+
69
+ Raises `ArgumentError` if the source string is empty, contains invalid hex characters, or does not represent a valid H3 cell index. String input is case-insensitive (normalized to lowercase). The `0x` prefix is stripped automatically.
70
+
71
+ ## Attributes
72
+
73
+ | Attribute | Type | Access | Description |
74
+ |------------|---------|-----------|----------------------------------|
75
+ | `code` | String | read-only | The hex string representation |
76
+ | `h3_index` | Integer | read-only | The 64-bit H3 cell index |
77
+
78
+ H3 is **immutable** -- there are no setter methods.
79
+
80
+ ## Resolution
81
+
82
+ H3 uses "resolution" (0-15) instead of string-length precision. Higher resolution means smaller cells.
83
+
84
+ | Resolution | Approximate Cell Area | Approximate Edge Length |
85
+ |------------|----------------------|------------------------|
86
+ | 0 | 4,357,449 km^2 | 1,108 km |
87
+ | 1 | 609,788 km^2 | 419 km |
88
+ | 2 | 86,801 km^2 | 158 km |
89
+ | 3 | 12,393 km^2 | 60 km |
90
+ | 4 | 1,770 km^2 | 23 km |
91
+ | 5 | 252 km^2 | 8.5 km |
92
+ | 6 | 36 km^2 | 3.2 km |
93
+ | 7 | 5.2 km^2 (default) | 1.2 km |
94
+ | 8 | 0.74 km^2 | 461 m |
95
+ | 9 | 0.105 km^2 | 174 m |
96
+ | 10 | 0.015 km^2 | 66 m |
97
+ | 11 | 0.002 km^2 | 25 m |
98
+ | 12 | 307 m^2 | 9.4 m |
99
+ | 13 | 43 m^2 | 3.6 m |
100
+ | 14 | 6.2 m^2 | 1.3 m |
101
+ | 15 | 0.9 m^2 | 0.5 m |
102
+
103
+ ```ruby
104
+ coord.resolution # => 7 (alias: coord.precision)
105
+ coord.cell_area # => 5182586.98 (square meters)
106
+ coord.precision_in_meters # => { lat: ~2276, lng: ~2276, area_m2: ~5182586 }
107
+ ```
108
+
109
+ ## Checking Availability
110
+
111
+ ```ruby
112
+ Geodetic::Coordinate::H3.available? # => true if libh3 is found
113
+ ```
114
+
115
+ ## Conversions
116
+
117
+ All conversions chain through LLA. The datum parameter defaults to `Geodetic::WGS84`.
118
+
119
+ ### Instance Methods
120
+
121
+ ```ruby
122
+ coord.to_lla # => LLA (centroid of the cell)
123
+ coord.to_ecef
124
+ coord.to_utm
125
+ coord.to_enu(reference_lla)
126
+ coord.to_ned(reference_lla)
127
+ coord.to_mgrs
128
+ coord.to_usng
129
+ coord.to_web_mercator
130
+ coord.to_ups
131
+ coord.to_state_plane(zone_code)
132
+ coord.to_bng
133
+ coord.to_gh36
134
+ coord.to_gh
135
+ coord.to_ham
136
+ coord.to_olc
137
+ coord.to_georef
138
+ coord.to_gars
139
+ ```
140
+
141
+ ### Class Methods
142
+
143
+ ```ruby
144
+ H3.from_lla(lla_coord)
145
+ H3.from_ecef(ecef_coord)
146
+ H3.from_utm(utm_coord)
147
+ H3.from_web_mercator(wm_coord)
148
+ H3.from_gh(gh_coord)
149
+ H3.from_georef(georef_coord)
150
+ H3.from_gars(gars_coord)
151
+ # ... and all other coordinate systems
152
+ ```
153
+
154
+ ### LLA Convenience Methods
155
+
156
+ ```ruby
157
+ lla = Geodetic::Coordinate::LLA.new(lat: 40.689167, lng: -74.044444)
158
+ h3 = lla.to_h3 # default resolution 7
159
+ h3 = lla.to_h3(precision: 9) # resolution 9
160
+
161
+ lla = Geodetic::Coordinate::LLA.from_h3(h3)
162
+ ```
163
+
164
+ ## Serialization
165
+
166
+ ### `to_s(format = nil)`
167
+
168
+ Returns the hex string. Pass `:integer` to get the 64-bit integer value.
169
+
170
+ ```ruby
171
+ coord = H3.new("872a1072bffffff")
172
+ coord.to_s # => "872a1072bffffff"
173
+ coord.to_s(:integer) # => 608693941536498687
174
+ coord.h3_index # => 608693941536498687
175
+ ```
176
+
177
+ ### `to_slug`
178
+
179
+ Alias for `to_s`. H3 hex strings are already URL-safe.
180
+
181
+ ### `to_a`
182
+
183
+ Returns `[lat, lng]` of the cell centroid.
184
+
185
+ ```ruby
186
+ coord.to_a # => [40.685..., -74.030...]
187
+ ```
188
+
189
+ ### `from_string` / `from_array`
190
+
191
+ ```ruby
192
+ H3.from_string("872a1072bffffff") # from hex string
193
+ H3.from_array([40.689167, -74.044444]) # from [lat, lng]
194
+ ```
195
+
196
+ ## Neighbors
197
+
198
+ Returns all adjacent cells as an Array of H3 instances. Hexagons have 6 neighbors; pentagons have 5.
199
+
200
+ Note: unlike the rectangular spatial hashes which return a directional Hash (`:N`, `:S`, etc.), H3 returns a flat Array because hexagonal cells do not have cardinal directions.
201
+
202
+ ```ruby
203
+ coord = H3.new("872a1072bffffff")
204
+ neighbors = coord.neighbors
205
+ # => [H3, H3, H3, H3, H3, H3]
206
+
207
+ neighbors.length # => 6
208
+ ```
209
+
210
+ ## Grid Disk
211
+
212
+ The `grid_disk(k)` method returns all cells within `k` steps. This is a generalization of `neighbors` (which is `grid_disk(1)` minus self).
213
+
214
+ ```ruby
215
+ coord.grid_disk(0) # => [self] (1 cell)
216
+ coord.grid_disk(1) # => [self + 6 neighbors] (7 cells)
217
+ coord.grid_disk(2) # => 19 cells
218
+ ```
219
+
220
+ ## Parent and Children
221
+
222
+ Navigate the H3 hierarchy by moving to coarser or finer resolution levels.
223
+
224
+ ```ruby
225
+ coord = H3.new("872a1072bffffff") # resolution 7
226
+ parent = coord.parent(5) # => H3 at resolution 5
227
+ parent.resolution # => 5
228
+
229
+ children = coord.children(8) # => Array of 7 H3 cells at resolution 8
230
+ children.length # => 7
231
+ children.first.resolution # => 8
232
+ ```
233
+
234
+ `parent` raises `ArgumentError` if the target resolution is not coarser (lower number). `children` raises `ArgumentError` if the target resolution is not finer (higher number).
235
+
236
+ ## Area
237
+
238
+ The `to_area` method returns the hexagonal cell boundary as an `Areas::Polygon` with 6 vertices (5 for pentagons).
239
+
240
+ ```ruby
241
+ area = coord.to_area
242
+ # => Geodetic::Areas::Polygon
243
+
244
+ area.includes?(coord.to_lla) # => true (centroid is inside the cell)
245
+ area.boundary.length # => 7 (6 vertices + closing point)
246
+ ```
247
+
248
+ ## Pentagon Detection
249
+
250
+ 12 cells at each resolution level are pentagons (artifacts of the icosahedral projection). These have 5 neighbors and 5 boundary vertices instead of 6.
251
+
252
+ ```ruby
253
+ coord.pentagon? # => false (most cells are hexagons)
254
+ ```
255
+
256
+ ## Cell Area
257
+
258
+ ```ruby
259
+ coord.cell_area # => 5182586.98 (square meters)
260
+ ```
261
+
262
+ ## Equality
263
+
264
+ Two H3 instances are equal if their hex strings match exactly.
265
+
266
+ ```ruby
267
+ H3.new("872a1072bffffff") == H3.new("872a1072bffffff") # => true
268
+ H3.new("872a1072bffffff") == H3.new(0x872a1072bffffff) # => true (integer)
269
+ H3.new("872a1072bffffff") == H3.new("87195da49ffffff") # => false
270
+ ```
271
+
272
+ ## `valid?`
273
+
274
+ Returns `true` if the H3 cell index is valid according to the H3 library.
275
+
276
+ ```ruby
277
+ coord.valid? # => true
278
+ ```
279
+
280
+ ## Universal Distance and Bearing Methods
281
+
282
+ H3 supports all universal distance and bearing methods via the `DistanceMethods` and `BearingMethods` mixins:
283
+
284
+ ```ruby
285
+ a = H3.new("872a1072bffffff") # Statue of Liberty area
286
+ b = H3.new("87195da49ffffff") # London area
287
+
288
+ a.distance_to(b) # => Distance (~5,570 km)
289
+ a.straight_line_distance_to(b) # => Distance
290
+ a.bearing_to(b) # => Bearing
291
+ a.elevation_to(b) # => Float (degrees)
292
+ ```
293
+
294
+ ## Well-Known H3 Cells
295
+
296
+ | Location | H3 Index (res 7) | Resolution |
297
+ |----------|------------------|------------|
298
+ | Statue of Liberty | `872a1072bffffff` | 7 |
299
+ | London | `87195da49ffffff` | 7 |
300
+ | Null Island (0, 0) | `87754e64dffffff` | 7 |
301
+
302
+ ## Implementation Notes
303
+
304
+ Geodetic uses Ruby's `fiddle` (part of the standard library) to call the H3 v4 C API directly. No gem dependency beyond `fiddle` is required. The H3 C library must be installed separately.
305
+
306
+ The library search order is:
307
+ 1. `LIBH3_PATH` environment variable
308
+ 2. `/opt/homebrew/lib/libh3.dylib` (macOS ARM)
309
+ 3. `/usr/local/lib/libh3.dylib` (macOS Intel)
310
+ 4. `/usr/lib/libh3.so` (Linux)
311
+ 5. `/usr/local/lib/libh3.so` (Linux local)
312
+ 6. Architecture-specific Linux paths
@@ -1,6 +1,6 @@
1
1
  # Coordinate Systems Overview
2
2
 
3
- The Geodetic gem supports 17 coordinate systems organized into six categories. All coordinate classes live under `Geodetic::Coordinate`.
3
+ The Geodetic gem supports 18 coordinate systems organized into six categories. All coordinate classes live under `Geodetic::Coordinate`.
4
4
 
5
5
  ## Global Systems
6
6
 
@@ -46,6 +46,7 @@ The Geodetic gem supports 17 coordinate systems organized into six categories. A
46
46
  | **OLC** | `Geodetic::Coordinate::OLC` | Open Location Code (Plus Codes). Google's open system for encoding locations into short codes like `849VCWC8+R9`. Uses a 20-character alphabet with 5 paired levels of base-20 encoding plus optional grid refinement. Includes a `+` separator at position 8. Supports neighbor lookup, area extraction, and configurable precision (default 10 characters for ~14 m resolution). |
47
47
  | **GEOREF** | `Geodetic::Coordinate::GEOREF` | World Geographic Reference System. A geocode system used in aviation and military applications that encodes positions using letter tiles (15° grid), letter degree subdivisions, and numeric minute pairs. Uses a 24-letter alphabet (A-Z excluding I and O). Supports variable precision from 15° tiles (2 chars) down to 0.01-minute resolution (12 chars). Default precision is 8 characters (1-minute resolution). |
48
48
  | **GARS** | `Geodetic::Coordinate::GARS` | Global Area Reference System. An NGA standard that divides the world into 30-minute cells identified by a 3-digit longitude band (001-720) and 2-letter latitude band. Cells are subdivided into 15-minute quadrants (1-4) and 5-minute keypads (1-9, telephone layout). Variable precision: 5 chars (30'), 6 chars (15'), 7 chars (5'). Default precision is 7 characters. |
49
+ | **H3** | `Geodetic::Coordinate::H3` | H3 Hexagonal Hierarchical Index. Uber's spatial indexing system that divides the globe into hexagonal cells (and 12 pentagons) at 16 resolution levels (0-15). Each cell is a 64-bit integer displayed as a hex string. Unlike the rectangular spatial hashes, `to_area` returns an `Areas::Polygon` with 6 vertices (5 for pentagons) and `neighbors` returns an Array of 6 cells. **Requires `libh3` installed** (`brew install h3` on macOS). |
49
50
 
50
51
  ## Regional Systems
51
52
 
@@ -60,25 +61,26 @@ The Geodetic gem supports 17 coordinate systems organized into six categories. A
60
61
 
61
62
  Every coordinate system can convert to every other coordinate system. The table below confirms full interoperability:
62
63
 
63
- | From \ To | LLA | ECEF | UTM | ENU | NED | MGRS | USNG | WebMercator | UPS | StatePlane | BNG | GH36 | GH | HAM | OLC | GEOREF | GARS |
64
- |-----------|-----|------|-----|-----|-----|------|------|-------------|-----|------------|-----|------|----|----|-----|--------|------|
65
- | **LLA** | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
66
- | **ECEF** | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
67
- | **UTM** | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
68
- | **ENU** | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
69
- | **NED** | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
70
- | **MGRS** | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
71
- | **USNG** | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
72
- | **WebMercator**| Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y |
73
- | **UPS** | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y |
74
- | **StatePlane** | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y |
75
- | **BNG** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y |
76
- | **GH36** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y |
77
- | **GH** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y |
78
- | **HAM** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y |
79
- | **OLC** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y |
80
- | **GEOREF** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y |
81
- | **GARS** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- |
64
+ | From \ To | LLA | ECEF | UTM | ENU | NED | MGRS | USNG | WebMercator | UPS | StatePlane | BNG | GH36 | GH | HAM | OLC | GEOREF | GARS | H3 |
65
+ |-----------|-----|------|-----|-----|-----|------|------|-------------|-----|------------|-----|------|----|----|-----|--------|------|-----|
66
+ | **LLA** | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
67
+ | **ECEF** | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
68
+ | **UTM** | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
69
+ | **ENU** | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
70
+ | **NED** | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
71
+ | **MGRS** | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
72
+ | **USNG** | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
73
+ | **WebMercator**| Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y |
74
+ | **UPS** | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y | Y |
75
+ | **StatePlane** | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y | Y |
76
+ | **BNG** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y | Y |
77
+ | **GH36** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y | Y |
78
+ | **GH** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y | Y |
79
+ | **HAM** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y | Y |
80
+ | **OLC** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y | Y |
81
+ | **GEOREF** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y | Y |
82
+ | **GARS** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- | Y |
83
+ | **H3** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -- |
82
84
 
83
85
  ## Universal Distance and Bearing Calculations
84
86
 
@@ -98,6 +100,6 @@ Conversions typically route through **LLA** or **ECEF** as intermediate steps:
98
100
  - **ECEF** is the intermediate for local tangent plane systems (ENU, NED), since the rotation from global Cartesian to local frames is straightforward in ECEF.
99
101
  - **ENU and NED** convert between each other directly by reordering axes and inverting the vertical component.
100
102
  - **MGRS and USNG** route through UTM, which in turn routes through LLA.
101
- - **WebMercator, UPS, BNG, StatePlane, GH36, GH, HAM, OLC, GEOREF, and GARS** all convert through LLA.
103
+ - **WebMercator, UPS, BNG, StatePlane, GH36, GH, HAM, OLC, GEOREF, GARS, and H3** all convert through LLA.
102
104
 
103
105
  For example, converting from BNG to NED follows the chain: `BNG -> LLA -> ECEF -> ENU -> NED`. The gem handles this automatically when you call a conversion method.
data/docs/index.md CHANGED
@@ -9,7 +9,7 @@
9
9
  <td width="50%" valign="top">
10
10
  <h2>Key Features</h2>
11
11
  <lu>
12
- <li><strong>17 Coordinate Systems</strong> - LLA, ECEF, UTM, ENU, NED, MGRS, USNG, Web Mercator, UPS, State Plane, BNG, GH36, GH, HAM, OLC<br>
12
+ <li><strong>18 Coordinate Systems</strong> - LLA, ECEF, UTM, ENU, NED, MGRS, USNG, Web Mercator, UPS, State Plane, BNG, GH36, GH, HAM, OLC<br>
13
13
  <li><strong>Full Bidirectional Conversions</strong> - Every system converts to and from every other system<br>
14
14
  <li><strong>Distance Calculations</strong> - Vincenty great-circle and straight-line with unit tracking<br>
15
15
  <li><strong>Bearing Calculations</strong> - Forward azimuth, back azimuth, compass directions, elevation angles<br>
@@ -24,11 +24,11 @@
24
24
  </tr>
25
25
  </table>
26
26
 
27
- Geodetic is a Ruby gem for converting between geodetic coordinate systems. It provides a clean, consistent API for working with 17 coordinate systems, 16 geodetic datums, geoid height calculations, and geographic area computations.
27
+ Geodetic is a Ruby gem for converting between geodetic coordinate systems. It provides a clean, consistent API for working with 18 coordinate systems, 16 geodetic datums, geoid height calculations, and geographic area computations.
28
28
 
29
29
  ## Coordinate Systems
30
30
 
31
- Geodetic supports full bidirectional conversion between all 17 coordinate systems:
31
+ Geodetic supports full bidirectional conversion between all 18 coordinate systems:
32
32
 
33
33
  | System | Class | Description |
34
34
  |--------|-------|-------------|
@@ -49,6 +49,7 @@ Geodetic supports full bidirectional conversion between all 17 coordinate system
49
49
  | **OLC** | `Geodetic::Coordinate::OLC` | Open Location Code / Plus Codes (Google's location encoding) |
50
50
  | **GEOREF** | `Geodetic::Coordinate::GEOREF` | World Geographic Reference System (aviation/military) |
51
51
  | **GARS** | `Geodetic::Coordinate::GARS` | Global Area Reference System (NGA standard) |
52
+ | **H3** | `Geodetic::Coordinate::H3` | Uber's hexagonal hierarchical index (requires `libh3`) |
52
53
 
53
54
  ## Additional Features
54
55
 
@@ -212,6 +212,15 @@ module Geodetic
212
212
  from_lla(lla, reference_lla)
213
213
  end
214
214
 
215
+ def to_h3(reference_lla, precision: 7)
216
+ H3.new(to_lla(reference_lla), precision: precision)
217
+ end
218
+
219
+ def self.from_h3(h3_coord, reference_lla)
220
+ lla = h3_coord.to_lla
221
+ from_lla(lla, reference_lla)
222
+ end
223
+
215
224
  def to_s(precision = 2)
216
225
  precision = precision.to_i
217
226
  if precision == 0
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ # H3 (Uber's Hexagonal Hierarchical Spatial Index) Coordinate
4
+ #
5
+ # A hierarchical geospatial index that divides the globe into hexagonal cells
6
+ # (and 12 pentagons) at 16 resolution levels (0-15). Each cell is identified
7
+ # by a 64-bit integer (H3Index), typically displayed as a 15-character hex string.
8
+ #
9
+ # REQUIRES: libh3 shared library (brew install h3)
10
+ # Uses Ruby's fiddle (stdlib) to call H3 v4 C API — no gem dependency.
11
+ #
12
+ # Key differences from other spatial hashes:
13
+ # - Cells are hexagons (6 vertices), not rectangles
14
+ # - to_area returns Areas::Polygon, not Areas::Rectangle
15
+ # - neighbors returns an Array (6 cells), not a directional Hash
16
+ # - "precision" maps to H3 resolution (0-15), not string length
17
+ #
18
+ # Usage:
19
+ # H3.new("872a1072bffffff") # from hex string
20
+ # H3.new(0x872a1072bffffff) # from integer
21
+ # H3.new(lla_coord) # from any coordinate
22
+ # H3.new(lla_coord, precision: 9) # resolution 9
23
+
24
+ require_relative 'spatial_hash'
25
+
26
+ module Geodetic
27
+ module Coordinate
28
+ class H3 < SpatialHash
29
+ # --- FFI bindings to libh3 via fiddle ---
30
+
31
+ module LibH3
32
+ require 'fiddle'
33
+
34
+ SEARCH_PATHS = [
35
+ ENV['LIBH3_PATH'],
36
+ '/opt/homebrew/lib/libh3.dylib',
37
+ '/opt/homebrew/lib/libh3.1.dylib',
38
+ '/usr/local/lib/libh3.dylib',
39
+ '/usr/local/lib/libh3.1.dylib',
40
+ '/usr/lib/libh3.so',
41
+ '/usr/lib/libh3.so.1',
42
+ '/usr/local/lib/libh3.so',
43
+ '/usr/local/lib/libh3.so.1',
44
+ '/usr/lib/x86_64-linux-gnu/libh3.so',
45
+ '/usr/lib/aarch64-linux-gnu/libh3.so',
46
+ ].compact.freeze
47
+
48
+ @handle = nil
49
+ @available = false
50
+
51
+ class << self
52
+ attr_reader :handle
53
+
54
+ def available?
55
+ @available
56
+ end
57
+
58
+ def require_library!
59
+ return if @available
60
+ raise Geodetic::Error,
61
+ "libh3 not found. Install H3: brew install h3 (macOS) " \
62
+ "or see https://h3geo.org/docs/installation. " \
63
+ "Set LIBH3_PATH env var to specify a custom library path."
64
+ end
65
+ end
66
+
67
+ begin
68
+ path = SEARCH_PATHS.find { |p| File.exist?(p) }
69
+ if path
70
+ @handle = Fiddle.dlopen(path)
71
+ @available = true
72
+ end
73
+ rescue Fiddle::DLError
74
+ @available = false
75
+ end
76
+
77
+ # Struct sizes
78
+ SIZEOF_LATLNG = 2 * Fiddle::SIZEOF_DOUBLE # 16 bytes
79
+ SIZEOF_CELL_BOUNDARY = 8 + 10 * SIZEOF_LATLNG # 168 bytes (int + pad + 10 LatLngs)
80
+ SIZEOF_H3INDEX = Fiddle::SIZEOF_LONG_LONG # 8 bytes
81
+ SIZEOF_INT64 = Fiddle::SIZEOF_LONG_LONG # 8 bytes
82
+
83
+ # Function bindings (lazy-loaded)
84
+ def self.bind(name, args, ret)
85
+ return nil unless @available
86
+ ptr = @handle[name]
87
+ Fiddle::Function.new(ptr, args, ret)
88
+ rescue Fiddle::DLError
89
+ nil
90
+ end
91
+
92
+ # H3Error latLngToCell(const LatLng *g, int res, H3Index *out)
93
+ F_LAT_LNG_TO_CELL = bind('latLngToCell',
94
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
95
+ Fiddle::TYPE_INT)
96
+
97
+ # H3Error cellToLatLng(H3Index h3, LatLng *g)
98
+ F_CELL_TO_LAT_LNG = bind('cellToLatLng',
99
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
100
+ Fiddle::TYPE_INT)
101
+
102
+ # H3Error cellToBoundary(H3Index h3, CellBoundary *gp)
103
+ F_CELL_TO_BOUNDARY = bind('cellToBoundary',
104
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
105
+ Fiddle::TYPE_INT)
106
+
107
+ # int getResolution(H3Index h)
108
+ F_GET_RESOLUTION = bind('getResolution',
109
+ [Fiddle::TYPE_LONG_LONG],
110
+ Fiddle::TYPE_INT)
111
+
112
+ # int isValidCell(H3Index h)
113
+ F_IS_VALID_CELL = bind('isValidCell',
114
+ [Fiddle::TYPE_LONG_LONG],
115
+ Fiddle::TYPE_INT)
116
+
117
+ # int isPentagon(H3Index h)
118
+ F_IS_PENTAGON = bind('isPentagon',
119
+ [Fiddle::TYPE_LONG_LONG],
120
+ Fiddle::TYPE_INT)
121
+
122
+ # H3Error gridDisk(H3Index origin, int k, H3Index *out)
123
+ F_GRID_DISK = bind('gridDisk',
124
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
125
+ Fiddle::TYPE_INT)
126
+
127
+ # H3Error maxGridDiskSize(int k, int64_t *out)
128
+ F_MAX_GRID_DISK_SIZE = bind('maxGridDiskSize',
129
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
130
+ Fiddle::TYPE_INT)
131
+
132
+ # H3Error cellAreaM2(H3Index h, double *out)
133
+ F_CELL_AREA_M2 = bind('cellAreaM2',
134
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
135
+ Fiddle::TYPE_INT)
136
+
137
+ # H3Error cellToParent(H3Index h, int parentRes, H3Index *out)
138
+ F_CELL_TO_PARENT = bind('cellToParent',
139
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
140
+ Fiddle::TYPE_INT)
141
+
142
+ # H3Error cellToChildrenSize(H3Index h, int childRes, int64_t *out)
143
+ F_CELL_TO_CHILDREN_SIZE = bind('cellToChildrenSize',
144
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
145
+ Fiddle::TYPE_INT)
146
+
147
+ # H3Error cellToChildren(H3Index h, int childRes, H3Index *out)
148
+ F_CELL_TO_CHILDREN = bind('cellToChildren',
149
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
150
+ Fiddle::TYPE_INT)
151
+
152
+ # --- High-level wrappers ---
153
+
154
+ def self.lat_lng_to_cell(lat_rad, lng_rad, resolution)
155
+ require_library!
156
+ latlng = Fiddle::Pointer.malloc(SIZEOF_LATLNG, Fiddle::RUBY_FREE)
157
+ latlng[0, SIZEOF_LATLNG] = [lat_rad, lng_rad].pack('d2')
158
+ out = Fiddle::Pointer.malloc(SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
159
+ err = F_LAT_LNG_TO_CELL.call(latlng, resolution, out)
160
+ raise ArgumentError, "H3 latLngToCell error (code #{err})" unless err == 0
161
+ out[0, SIZEOF_H3INDEX].unpack1('Q')
162
+ end
163
+
164
+ def self.cell_to_lat_lng(h3_index)
165
+ require_library!
166
+ latlng = Fiddle::Pointer.malloc(SIZEOF_LATLNG, Fiddle::RUBY_FREE)
167
+ err = F_CELL_TO_LAT_LNG.call(h3_index, latlng)
168
+ raise ArgumentError, "H3 cellToLatLng error (code #{err})" unless err == 0
169
+ lat_rad, lng_rad = latlng[0, SIZEOF_LATLNG].unpack('d2')
170
+ { lat: lat_rad * DEG_PER_RAD, lng: lng_rad * DEG_PER_RAD }
171
+ end
172
+
173
+ def self.cell_to_boundary(h3_index)
174
+ require_library!
175
+ cb = Fiddle::Pointer.malloc(SIZEOF_CELL_BOUNDARY, Fiddle::RUBY_FREE)
176
+ err = F_CELL_TO_BOUNDARY.call(h3_index, cb)
177
+ raise ArgumentError, "H3 cellToBoundary error (code #{err})" unless err == 0
178
+ num_verts = cb[0, 4].unpack1('i')
179
+ # Vertices start at offset 8 (4 byte int + 4 byte padding)
180
+ verts = cb[8, num_verts * SIZEOF_LATLNG].unpack("d#{num_verts * 2}")
181
+ (0...num_verts).map do |i|
182
+ { lat: verts[i * 2] * DEG_PER_RAD, lng: verts[i * 2 + 1] * DEG_PER_RAD }
183
+ end
184
+ end
185
+
186
+ def self.get_resolution(h3_index)
187
+ require_library!
188
+ F_GET_RESOLUTION.call(h3_index)
189
+ end
190
+
191
+ def self.is_valid_cell(h3_index)
192
+ require_library!
193
+ F_IS_VALID_CELL.call(h3_index) == 1
194
+ end
195
+
196
+ def self.is_pentagon(h3_index)
197
+ require_library!
198
+ F_IS_PENTAGON.call(h3_index) == 1
199
+ end
200
+
201
+ def self.grid_disk(h3_index, k)
202
+ require_library!
203
+ size_ptr = Fiddle::Pointer.malloc(SIZEOF_INT64, Fiddle::RUBY_FREE)
204
+ err = F_MAX_GRID_DISK_SIZE.call(k, size_ptr)
205
+ raise ArgumentError, "H3 maxGridDiskSize error (code #{err})" unless err == 0
206
+ max_size = size_ptr[0, SIZEOF_INT64].unpack1('q')
207
+
208
+ out = Fiddle::Pointer.malloc(max_size * SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
209
+ err = F_GRID_DISK.call(h3_index, k, out)
210
+ raise ArgumentError, "H3 gridDisk error (code #{err})" unless err == 0
211
+
212
+ out[0, max_size * SIZEOF_H3INDEX].unpack("Q#{max_size}").reject(&:zero?)
213
+ end
214
+
215
+ def self.cell_area_m2(h3_index)
216
+ require_library!
217
+ out = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE, Fiddle::RUBY_FREE)
218
+ err = F_CELL_AREA_M2.call(h3_index, out)
219
+ raise ArgumentError, "H3 cellAreaM2 error (code #{err})" unless err == 0
220
+ out[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
221
+ end
222
+
223
+ def self.cell_to_parent(h3_index, parent_res)
224
+ require_library!
225
+ out = Fiddle::Pointer.malloc(SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
226
+ err = F_CELL_TO_PARENT.call(h3_index, parent_res, out)
227
+ raise ArgumentError, "H3 cellToParent error (code #{err})" unless err == 0
228
+ out[0, SIZEOF_H3INDEX].unpack1('Q')
229
+ end
230
+
231
+ def self.cell_to_children(h3_index, child_res)
232
+ require_library!
233
+ size_ptr = Fiddle::Pointer.malloc(SIZEOF_INT64, Fiddle::RUBY_FREE)
234
+ err = F_CELL_TO_CHILDREN_SIZE.call(h3_index, child_res, size_ptr)
235
+ raise ArgumentError, "H3 cellToChildrenSize error (code #{err})" unless err == 0
236
+ count = size_ptr[0, SIZEOF_INT64].unpack1('q')
237
+
238
+ out = Fiddle::Pointer.malloc(count * SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
239
+ err = F_CELL_TO_CHILDREN.call(h3_index, child_res, out)
240
+ raise ArgumentError, "H3 cellToChildren error (code #{err})" unless err == 0
241
+
242
+ out[0, count * SIZEOF_H3INDEX].unpack("Q#{count}").reject(&:zero?)
243
+ end
244
+ end
245
+
246
+ # --- Class configuration ---
247
+
248
+ attr_reader :code
249
+
250
+ def self.default_precision = 7
251
+ def self.hash_system_name = :h3
252
+
253
+ def self.available?
254
+ LibH3.available?
255
+ end
256
+
257
+ # --- Constructor ---
258
+
259
+ def initialize(source, precision: self.class.default_precision)
260
+ case source
261
+ when Integer
262
+ hex = format('%x', source)
263
+ validate_code!(hex)
264
+ set_code(hex)
265
+ when String
266
+ normalized = normalize(source.strip)
267
+ validate_code!(normalized)
268
+ set_code(normalized)
269
+ when LLA
270
+ set_code(encode(source.lat, source.lng, precision))
271
+ else
272
+ if source.respond_to?(:to_lla)
273
+ lla = source.to_lla
274
+ set_code(encode(lla.lat, lla.lng, precision))
275
+ else
276
+ raise ArgumentError,
277
+ "Expected an H3 hex String, Integer, or coordinate object, got #{source.class}"
278
+ end
279
+ end
280
+ end
281
+
282
+ # --- H3-specific methods ---
283
+
284
+ # The 64-bit H3 cell index
285
+ def h3_index
286
+ @h3_index ||= code.to_i(16)
287
+ end
288
+
289
+ # H3 resolution (0-15)
290
+ def precision
291
+ @resolution ||= LibH3.get_resolution(h3_index)
292
+ end
293
+ alias_method :resolution, :precision
294
+
295
+ # Is this cell a pentagon? (12 per resolution level)
296
+ def pentagon?
297
+ LibH3.is_pentagon(h3_index)
298
+ end
299
+
300
+ # Parent cell at a coarser resolution
301
+ def parent(parent_resolution)
302
+ raise ArgumentError, "Parent resolution must be < #{resolution}" unless parent_resolution < resolution
303
+ parent_idx = LibH3.cell_to_parent(h3_index, parent_resolution)
304
+ self.class.new(parent_idx)
305
+ end
306
+
307
+ # Child cells at a finer resolution
308
+ def children(child_resolution)
309
+ raise ArgumentError, "Child resolution must be > #{resolution}" unless child_resolution > resolution
310
+ child_indices = LibH3.cell_to_children(h3_index, child_resolution)
311
+ child_indices.map { |idx| self.class.new(idx) }
312
+ end
313
+
314
+ # All cells within k steps (k=0 returns self, k=1 returns 7 cells, etc.)
315
+ def grid_disk(k = 1)
316
+ indices = LibH3.grid_disk(h3_index, k)
317
+ indices.map { |idx| self.class.new(idx) }
318
+ end
319
+
320
+ # Cell area in square meters
321
+ def cell_area
322
+ LibH3.cell_area_m2(h3_index)
323
+ end
324
+
325
+ # --- Override SpatialHash methods ---
326
+
327
+ def valid?
328
+ LibH3.is_valid_cell(h3_index)
329
+ end
330
+
331
+ # Returns all adjacent cells as an Array of H3 instances.
332
+ # Hexagons have 6 neighbors; pentagons have 5.
333
+ # Note: returns Array, not directional Hash (hexagons have no cardinal directions).
334
+ def neighbors
335
+ indices = LibH3.grid_disk(h3_index, 1)
336
+ indices.reject { |idx| idx == h3_index }.map { |idx| self.class.new(idx) }
337
+ end
338
+
339
+ # Returns the hexagonal cell boundary as an Areas::Polygon.
340
+ def to_area
341
+ verts = LibH3.cell_to_boundary(h3_index)
342
+ boundary = verts.map { |v| LLA.new(lat: v[:lat], lng: v[:lng], alt: 0.0) }
343
+ Areas::Polygon.new(boundary: boundary)
344
+ end
345
+
346
+ # Returns approximate precision in meters as { lat:, lng:, area_m2: }
347
+ def precision_in_meters
348
+ area = cell_area
349
+ edge = Math.sqrt(area)
350
+ { lat: edge, lng: edge, area_m2: area }
351
+ end
352
+
353
+ def to_s(format = nil)
354
+ if format == :integer
355
+ h3_index
356
+ else
357
+ code
358
+ end
359
+ end
360
+
361
+ def code_value
362
+ @code
363
+ end
364
+
365
+ protected
366
+
367
+ def normalize(string)
368
+ string.downcase.delete_prefix('0x')
369
+ end
370
+
371
+ def set_code(value)
372
+ @code = value
373
+ @h3_index = nil
374
+ @resolution = nil
375
+ end
376
+
377
+ private
378
+
379
+ def encode(lat, lng, resolution = self.class.default_precision)
380
+ resolution = resolution.clamp(0, 15)
381
+ lat_rad = lat * RAD_PER_DEG
382
+ lng_rad = lng * RAD_PER_DEG
383
+ idx = LibH3.lat_lng_to_cell(lat_rad, lng_rad, resolution)
384
+ format('%x', idx)
385
+ end
386
+
387
+ def decode(code_string)
388
+ idx = code_string.to_i(16)
389
+ LibH3.cell_to_lat_lng(idx)
390
+ end
391
+
392
+ def decode_bounds(code_string)
393
+ idx = code_string.to_i(16)
394
+ verts = LibH3.cell_to_boundary(idx)
395
+ lats = verts.map { |v| v[:lat] }
396
+ lngs = verts.map { |v| v[:lng] }
397
+ { min_lat: lats.min, max_lat: lats.max, min_lng: lngs.min, max_lng: lngs.max }
398
+ end
399
+
400
+ def validate_h3!(code_string)
401
+ raise ArgumentError, "H3 code cannot be empty" if code_string.empty?
402
+ raise ArgumentError, "Invalid H3 hex string: #{code_string}" unless code_string.match?(/\A[0-9a-f]+\z/)
403
+ idx = code_string.to_i(16)
404
+ raise ArgumentError, "Invalid H3 cell index: #{code_string}" unless LibH3.is_valid_cell(idx)
405
+ end
406
+
407
+ alias_method :validate_code!, :validate_h3!
408
+
409
+ register_hash_system(:h3, self, default_precision: 7)
410
+ Coordinate.register_class(self)
411
+ end
412
+ end
413
+ end
@@ -270,6 +270,14 @@ module Geodetic
270
270
  gars_coord.to_lla(datum)
271
271
  end
272
272
 
273
+ def to_h3(precision: 7)
274
+ H3.new(self, precision: precision)
275
+ end
276
+
277
+ def self.from_h3(h3_coord, datum = WGS84)
278
+ h3_coord.to_lla(datum)
279
+ end
280
+
273
281
  def self.from_lla(lla_coord, datum = WGS84)
274
282
  lla_coord
275
283
  end
@@ -193,6 +193,15 @@ module Geodetic
193
193
  from_lla(lla, reference_lla)
194
194
  end
195
195
 
196
+ def to_h3(reference_lla, precision: 7)
197
+ H3.new(to_lla(reference_lla), precision: precision)
198
+ end
199
+
200
+ def self.from_h3(h3_coord, reference_lla)
201
+ lla = h3_coord.to_lla
202
+ from_lla(lla, reference_lla)
203
+ end
204
+
196
205
  def to_s(precision = 2)
197
206
  precision = precision.to_i
198
207
  if precision == 0
@@ -305,6 +305,15 @@ module Geodetic
305
305
  from_lla(lla_coord, zone_code, datum)
306
306
  end
307
307
 
308
+ def to_h3(datum = nil, precision: 7)
309
+ H3.new(to_lla(datum), precision: precision)
310
+ end
311
+
312
+ def self.from_h3(h3_coord, zone_code, datum = WGS84)
313
+ lla_coord = h3_coord.to_lla(datum)
314
+ from_lla(lla_coord, zone_code, datum)
315
+ end
316
+
308
317
  # Unit conversion methods
309
318
  def to_meters
310
319
  zone_info = ZONES[@zone_code]
@@ -33,6 +33,7 @@ require_relative "coordinate/ham"
33
33
  require_relative "coordinate/olc"
34
34
  require_relative "coordinate/georef"
35
35
  require_relative "coordinate/gars"
36
+ require_relative "coordinate/h3"
36
37
 
37
38
  module Geodetic
38
39
  module Coordinate
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Geodetic
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: geodetic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -9,11 +9,11 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: A Ruby gem for converting between 17 geodetic coordinate systems (LLA,
12
+ description: A Ruby gem for converting between 18 geodetic coordinate systems (LLA,
13
13
  ECEF, UTM, ENU, NED, MGRS, USNG, Web Mercator, UPS, State Plane, BNG, GH36, GH,
14
- HAM, OLC, GEOREF, GARS) with Vincenty great-circle and ECEF Euclidean distance calculations,
15
- unit-aware Distance and Bearing classes, geoid height support, and geographic area
16
- operations.
14
+ HAM, OLC, GEOREF, GARS, H3) with Vincenty great-circle and ECEF Euclidean distance
15
+ calculations, unit-aware Distance and Bearing classes, geoid height support, and
16
+ geographic area operations.
17
17
  email:
18
18
  - dewayne@vanhoozer.me
19
19
  executables: []
@@ -35,6 +35,7 @@ files:
35
35
  - docs/coordinate-systems/georef.md
36
36
  - docs/coordinate-systems/gh.md
37
37
  - docs/coordinate-systems/gh36.md
38
+ - docs/coordinate-systems/h3.md
38
39
  - docs/coordinate-systems/ham.md
39
40
  - docs/coordinate-systems/index.md
40
41
  - docs/coordinate-systems/lla.md
@@ -72,6 +73,7 @@ files:
72
73
  - lib/geodetic/coordinate/georef.rb
73
74
  - lib/geodetic/coordinate/gh.rb
74
75
  - lib/geodetic/coordinate/gh36.rb
76
+ - lib/geodetic/coordinate/h3.rb
75
77
  - lib/geodetic/coordinate/ham.rb
76
78
  - lib/geodetic/coordinate/lla.rb
77
79
  - lib/geodetic/coordinate/mgrs.rb