geodetic 0.1.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -4
  3. data/README.md +19 -5
  4. data/docs/coordinate-systems/bng.md +5 -5
  5. data/docs/coordinate-systems/ecef.md +23 -23
  6. data/docs/coordinate-systems/enu.md +3 -3
  7. data/docs/coordinate-systems/gars.md +246 -0
  8. data/docs/coordinate-systems/georef.md +221 -0
  9. data/docs/coordinate-systems/gh.md +7 -7
  10. data/docs/coordinate-systems/gh36.md +6 -6
  11. data/docs/coordinate-systems/h3.md +312 -0
  12. data/docs/coordinate-systems/ham.md +6 -6
  13. data/docs/coordinate-systems/index.md +40 -34
  14. data/docs/coordinate-systems/lla.md +26 -26
  15. data/docs/coordinate-systems/mgrs.md +3 -3
  16. data/docs/coordinate-systems/ned.md +3 -3
  17. data/docs/coordinate-systems/olc.md +6 -6
  18. data/docs/coordinate-systems/state-plane.md +2 -2
  19. data/docs/coordinate-systems/ups.md +4 -4
  20. data/docs/coordinate-systems/usng.md +2 -2
  21. data/docs/coordinate-systems/utm.md +23 -23
  22. data/docs/coordinate-systems/web-mercator.md +7 -7
  23. data/docs/getting-started/installation.md +17 -17
  24. data/docs/getting-started/quick-start.md +8 -8
  25. data/docs/index.md +22 -19
  26. data/docs/reference/areas.md +15 -15
  27. data/docs/reference/conversions.md +31 -31
  28. data/docs/reference/geoid-height.md +5 -5
  29. data/docs/reference/serialization.md +44 -44
  30. data/examples/01_basic_conversions.rb +10 -10
  31. data/examples/02_all_coordinate_systems.rb +24 -24
  32. data/lib/geodetic/areas/circle.rb +1 -1
  33. data/lib/geodetic/areas/polygon.rb +2 -2
  34. data/lib/geodetic/areas/rectangle.rb +6 -6
  35. data/lib/geodetic/{coordinates → coordinate}/bng.rb +3 -37
  36. data/lib/geodetic/{coordinates → coordinate}/ecef.rb +3 -33
  37. data/lib/geodetic/{coordinates → coordinate}/enu.rb +30 -1
  38. data/lib/geodetic/coordinate/gars.rb +233 -0
  39. data/lib/geodetic/coordinate/georef.rb +204 -0
  40. data/lib/geodetic/coordinate/gh.rb +161 -0
  41. data/lib/geodetic/{coordinates → coordinate}/gh36.rb +28 -187
  42. data/lib/geodetic/coordinate/h3.rb +413 -0
  43. data/lib/geodetic/coordinate/ham.rb +226 -0
  44. data/lib/geodetic/{coordinates → coordinate}/lla.rb +31 -1
  45. data/lib/geodetic/{coordinates → coordinate}/mgrs.rb +3 -33
  46. data/lib/geodetic/{coordinates → coordinate}/ned.rb +30 -1
  47. data/lib/geodetic/{coordinates → coordinate}/olc.rb +19 -225
  48. data/lib/geodetic/coordinate/spatial_hash.rb +342 -0
  49. data/lib/geodetic/{coordinates → coordinate}/state_plane.rb +30 -1
  50. data/lib/geodetic/{coordinates → coordinate}/ups.rb +3 -37
  51. data/lib/geodetic/{coordinates → coordinate}/usng.rb +3 -33
  52. data/lib/geodetic/{coordinates → coordinate}/utm.rb +3 -33
  53. data/lib/geodetic/{coordinates → coordinate}/web_mercator.rb +3 -33
  54. data/lib/geodetic/{coordinates.rb → coordinate.rb} +62 -45
  55. data/lib/geodetic/version.rb +1 -1
  56. data/lib/geodetic.rb +1 -1
  57. data/spatial_hash_idea.md +241 -0
  58. metadata +29 -20
  59. data/lib/geodetic/coordinates/gh.rb +0 -372
  60. data/lib/geodetic/coordinates/ham.rb +0 -435
@@ -17,11 +17,11 @@
17
17
  # GH36.new(lla_coord) # from any coordinate (converts via LLA)
18
18
  # GH36.new(utm_coord, precision: 8) # with custom precision
19
19
 
20
- module Geodetic
21
- module Coordinates
22
- class GH36
23
- require_relative '../datum'
20
+ require_relative 'spatial_hash'
24
21
 
22
+ module Geodetic
23
+ module Coordinate
24
+ class GH36 < SpatialHash
25
25
  # 6x6 encoding matrix mapping (row, col) to character
26
26
  # Row 0 is the northernmost latitude slice; row 5 is southernmost
27
27
  # Col 0 is the westernmost longitude slice; col 5 is easternmost
@@ -41,9 +41,6 @@ module Geodetic
41
41
  VALID_CHARS = '23456789bBCdDFgGhHjJKlLMnNPqQrRtTVWX'
42
42
  VALID_CHARS_SET = VALID_CHARS.chars.to_set.freeze
43
43
 
44
- # Default hash length (10 chars gives sub-meter precision)
45
- DEFAULT_LENGTH = 10
46
-
47
44
  # Reverse lookup: character -> [row, col] in the matrix
48
45
  CHAR_INDEX = {}.tap do |h|
49
46
  MATRIX.each_with_index do |row, r|
@@ -68,183 +65,22 @@ module Geodetic
68
65
 
69
66
  attr_reader :geohash
70
67
 
71
- # Create a GH36 from a geohash string or any coordinate object.
72
- #
73
- # GH36.new("bdrdC26BqH") # from geohash string
74
- # GH36.new(lla) # from LLA coordinate
75
- # GH36.new(utm, precision: 8) # from any coordinate with custom precision
76
- def initialize(source, precision: DEFAULT_LENGTH)
77
- case source
78
- when String
79
- validate_geohash!(source)
80
- @geohash = source
81
- when LLA
82
- @geohash = encode(source.lat, source.lng, precision)
83
- else
84
- if source.respond_to?(:to_lla)
85
- lla = source.to_lla
86
- @geohash = encode(lla.lat, lla.lng, precision)
87
- else
88
- raise ArgumentError,
89
- "Expected a geohash String or a coordinate object, got #{source.class}"
90
- end
91
- end
92
- end
93
-
94
- def precision
95
- @geohash.length
96
- end
97
-
98
- def to_s(truncate_to = nil)
99
- if truncate_to
100
- @geohash[0, truncate_to.to_i]
101
- else
102
- @geohash
103
- end
104
- end
105
-
106
- def to_a
107
- coords = decode(@geohash)
108
- [coords[:lat], coords[:lng]]
109
- end
110
-
111
- def self.from_array(array)
112
- new(LLA.new(lat: array[0].to_f, lng: array[1].to_f))
113
- end
114
-
115
- def self.from_string(string)
116
- new(string.strip)
117
- end
118
-
119
- # Decode to LLA (altitude is always 0.0 since GH36 is 2D)
120
- def to_lla(datum = WGS84)
121
- coords = decode(@geohash)
122
- LLA.new(lat: coords[:lat], lng: coords[:lng], alt: 0.0)
123
- end
124
-
125
- def self.from_lla(lla_coord, datum = WGS84, precision = DEFAULT_LENGTH)
126
- new(lla_coord, precision: precision)
127
- end
68
+ def self.default_precision = 10
69
+ def self.hash_system_name = :gh36
128
70
 
129
- # All other conversions chain through LLA
130
-
131
- def to_ecef(datum = WGS84)
132
- to_lla(datum).to_ecef(datum)
133
- end
134
-
135
- def self.from_ecef(ecef_coord, datum = WGS84, precision = DEFAULT_LENGTH)
136
- new(ecef_coord, precision: precision)
137
- end
138
-
139
- def to_utm(datum = WGS84)
140
- to_lla(datum).to_utm(datum)
141
- end
142
-
143
- def self.from_utm(utm_coord, datum = WGS84, precision = DEFAULT_LENGTH)
144
- new(utm_coord, precision: precision)
145
- end
146
-
147
- def to_enu(reference_lla, datum = WGS84)
148
- to_lla(datum).to_enu(reference_lla)
149
- end
150
-
151
- def self.from_enu(enu_coord, reference_lla, datum = WGS84, precision = DEFAULT_LENGTH)
152
- lla_coord = enu_coord.to_lla(reference_lla)
153
- new(lla_coord, precision: precision)
154
- end
155
-
156
- def to_ned(reference_lla, datum = WGS84)
157
- to_lla(datum).to_ned(reference_lla)
158
- end
159
-
160
- def self.from_ned(ned_coord, reference_lla, datum = WGS84, precision = DEFAULT_LENGTH)
161
- lla_coord = ned_coord.to_lla(reference_lla)
162
- new(lla_coord, precision: precision)
163
- end
164
-
165
- def to_mgrs(datum = WGS84, mgrs_precision = 5)
166
- MGRS.from_lla(to_lla(datum), datum, mgrs_precision)
167
- end
168
-
169
- def self.from_mgrs(mgrs_coord, datum = WGS84, precision = DEFAULT_LENGTH)
170
- new(mgrs_coord, precision: precision)
171
- end
71
+ # --- Subclass contract implementations ---
172
72
 
173
- def to_usng(datum = WGS84, usng_precision = 5)
174
- USNG.from_lla(to_lla(datum), datum, usng_precision)
175
- end
176
-
177
- def self.from_usng(usng_coord, datum = WGS84, precision = DEFAULT_LENGTH)
178
- new(usng_coord, precision: precision)
179
- end
180
-
181
- def to_web_mercator(datum = WGS84)
182
- WebMercator.from_lla(to_lla(datum), datum)
183
- end
184
-
185
- def self.from_web_mercator(wm_coord, datum = WGS84, precision = DEFAULT_LENGTH)
186
- new(wm_coord, precision: precision)
187
- end
188
-
189
- def to_ups(datum = WGS84)
190
- UPS.from_lla(to_lla(datum), datum)
191
- end
192
-
193
- def self.from_ups(ups_coord, datum = WGS84, precision = DEFAULT_LENGTH)
194
- new(ups_coord, precision: precision)
195
- end
196
-
197
- def to_state_plane(zone_code, datum = WGS84)
198
- StatePlane.from_lla(to_lla(datum), zone_code, datum)
199
- end
200
-
201
- def self.from_state_plane(sp_coord, datum = WGS84, precision = DEFAULT_LENGTH)
202
- new(sp_coord, precision: precision)
203
- end
204
-
205
- def to_bng(datum = WGS84)
206
- BNG.from_lla(to_lla(datum), datum)
207
- end
208
-
209
- def self.from_bng(bng_coord, datum = WGS84, precision = DEFAULT_LENGTH)
210
- new(bng_coord, precision: precision)
211
- end
212
-
213
- def to_gh(datum = WGS84, gh_precision: 12)
214
- GH.new(to_lla(datum), precision: gh_precision)
215
- end
216
-
217
- def self.from_gh(gh_coord, datum = WGS84, precision = DEFAULT_LENGTH)
218
- new(gh_coord, precision: precision)
219
- end
220
-
221
- def to_ham(datum = WGS84, ham_precision: 6)
222
- HAM.new(to_lla(datum), precision: ham_precision)
223
- end
224
-
225
- def self.from_ham(ham_coord, datum = WGS84, precision = DEFAULT_LENGTH)
226
- new(ham_coord, precision: precision)
227
- end
228
-
229
- def to_olc(datum = WGS84, olc_precision: 10)
230
- OLC.new(to_lla(datum), precision: olc_precision)
231
- end
232
-
233
- def self.from_olc(olc_coord, datum = WGS84, precision = DEFAULT_LENGTH)
234
- new(olc_coord, precision: precision)
73
+ def valid?
74
+ @geohash.length > 0 && @geohash.each_char.all? { |c| VALID_CHARS_SET.include?(c) }
235
75
  end
236
76
 
237
- def ==(other)
238
- return false unless other.is_a?(GH36)
239
- @geohash == other.geohash
77
+ def code_value
78
+ @geohash
240
79
  end
241
80
 
242
- def valid?
243
- @geohash.length > 0 && @geohash.each_char.all? { |c| VALID_CHARS_SET.include?(c) }
244
- end
81
+ # --- GH36-specific overrides (matrix-based algorithms) ---
245
82
 
246
- # Returns all 8 neighboring geohash cells as GH36 instances
247
- # Keys: :N, :S, :E, :W, :NE, :NW, :SE, :SW
83
+ # Uses recursive matrix-based neighbor calculation instead of bounds-based
248
84
  def neighbors
249
85
  DIRECTIONS.each_with_object({}) do |(dir, delta), result|
250
86
  hash = self.class.send(:neighbor_hash, @geohash, delta[0], delta[1])
@@ -252,7 +88,7 @@ module Geodetic
252
88
  end
253
89
  end
254
90
 
255
- # Returns the geohash cell as an Areas::Rectangle
91
+ # Uses decode_bounds via class method
256
92
  def to_area
257
93
  bb = self.class.send(:decode_bounds, @geohash)
258
94
  nw = LLA.new(lat: bb[:max_lat], lng: bb[:min_lng], alt: 0.0)
@@ -260,20 +96,23 @@ module Geodetic
260
96
  Areas::Rectangle.new(nw: nw, se: se)
261
97
  end
262
98
 
263
- # Returns precision in meters as {lat:, lng:}
99
+ # Uses formula-based precision instead of bounds-based
264
100
  def precision_in_meters
265
101
  one_degree_meters = (2 * Math::PI * 6_370_000) / 360.0
266
102
  lat_prec = (90.0 / (MATRIX_SIDE ** precision)) * one_degree_meters
267
103
  { lat: lat_prec, lng: lat_prec * 2 }
268
104
  end
269
105
 
270
- # URL-friendly slug (the geohash itself is already URL-safe)
271
- alias_method :to_slug, :to_s
106
+ protected
107
+
108
+ def set_code(value)
109
+ @geohash = value
110
+ end
272
111
 
273
112
  private
274
113
 
275
114
  # Encode lat/lng to a geohash string of given length
276
- def encode(lat, lng, length = DEFAULT_LENGTH)
115
+ def encode(lat, lng, length = self.class.default_precision)
277
116
  lat_min, lat_max = -90.0, 90.0
278
117
  lng_min, lng_max = -180.0, 180.0
279
118
 
@@ -323,7 +162,7 @@ module Geodetic
323
162
  }
324
163
  end
325
164
 
326
- # Decode a geohash string to its bounding box
165
+ # Decode a geohash string to its bounding box (class method for neighbor_hash access)
327
166
  def self.decode_bounds(geohash)
328
167
  lat_min, lat_max = -90.0, 90.0
329
168
  lng_min, lng_max = -180.0, 180.0
@@ -353,9 +192,6 @@ module Geodetic
353
192
  # Compute a neighbor hash by adjusting the last character's position
354
193
  # in the matrix. When the adjustment wraps beyond the matrix edge,
355
194
  # we recurse on the parent prefix and carry the wrap.
356
- #
357
- # Matrix layout: row 0 = north (high lat), row 5 = south (low lat)
358
- # col 0 = west (low lng), col 5 = east (high lng)
359
195
  def self.neighbor_hash(hash, row_delta, col_delta)
360
196
  return hash if hash.empty?
361
197
 
@@ -373,7 +209,7 @@ module Geodetic
373
209
  carry_col = 0
374
210
 
375
211
  if new_row < 0
376
- carry_row = row_delta # Propagate same direction
212
+ carry_row = row_delta
377
213
  new_row += MATRIX_SIDE
378
214
  elsif new_row >= MATRIX_SIDE
379
215
  carry_row = row_delta
@@ -403,6 +239,11 @@ module Geodetic
403
239
  raise ArgumentError, "Invalid Geohash-36 characters: #{invalid.join(', ')}"
404
240
  end
405
241
  end
242
+
243
+ alias_method :validate_code!, :validate_geohash!
244
+
245
+ register_hash_system(:gh36, self, default_precision: 10)
246
+ Coordinate.register_class(self)
406
247
  end
407
248
  end
408
249
  end
@@ -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