geodetic 0.0.1

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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/CHANGELOG.md +15 -0
  5. data/COMMITS.md +196 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +471 -0
  8. data/Rakefile +8 -0
  9. data/docs/coordinate-systems/bng.md +60 -0
  10. data/docs/coordinate-systems/ecef.md +215 -0
  11. data/docs/coordinate-systems/enu.md +77 -0
  12. data/docs/coordinate-systems/gh36.md +192 -0
  13. data/docs/coordinate-systems/index.md +93 -0
  14. data/docs/coordinate-systems/lla.md +304 -0
  15. data/docs/coordinate-systems/mgrs.md +81 -0
  16. data/docs/coordinate-systems/ned.md +83 -0
  17. data/docs/coordinate-systems/state-plane.md +60 -0
  18. data/docs/coordinate-systems/ups.md +53 -0
  19. data/docs/coordinate-systems/usng.md +74 -0
  20. data/docs/coordinate-systems/utm.md +257 -0
  21. data/docs/coordinate-systems/web-mercator.md +67 -0
  22. data/docs/getting-started/installation.md +65 -0
  23. data/docs/getting-started/quick-start.md +175 -0
  24. data/docs/index.md +58 -0
  25. data/docs/reference/areas.md +195 -0
  26. data/docs/reference/conversions.md +351 -0
  27. data/docs/reference/datums.md +134 -0
  28. data/docs/reference/geoid-height.md +182 -0
  29. data/docs/reference/serialization.md +252 -0
  30. data/examples/01_basic_conversions.rb +187 -0
  31. data/examples/02_all_coordinate_systems.rb +310 -0
  32. data/examples/03_distance_calculations.rb +224 -0
  33. data/examples/04_bearing_calculations.rb +236 -0
  34. data/lib/geodetic/areas/circle.rb +29 -0
  35. data/lib/geodetic/areas/polygon.rb +57 -0
  36. data/lib/geodetic/areas/rectangle.rb +55 -0
  37. data/lib/geodetic/areas.rb +5 -0
  38. data/lib/geodetic/bearing.rb +94 -0
  39. data/lib/geodetic/coordinates/bng.rb +366 -0
  40. data/lib/geodetic/coordinates/ecef.rb +229 -0
  41. data/lib/geodetic/coordinates/enu.rb +244 -0
  42. data/lib/geodetic/coordinates/gh36.rb +384 -0
  43. data/lib/geodetic/coordinates/lla.rb +268 -0
  44. data/lib/geodetic/coordinates/mgrs.rb +317 -0
  45. data/lib/geodetic/coordinates/ned.rb +246 -0
  46. data/lib/geodetic/coordinates/state_plane.rb +451 -0
  47. data/lib/geodetic/coordinates/ups.rb +325 -0
  48. data/lib/geodetic/coordinates/usng.rb +274 -0
  49. data/lib/geodetic/coordinates/utm.rb +261 -0
  50. data/lib/geodetic/coordinates/web_mercator.rb +242 -0
  51. data/lib/geodetic/coordinates.rb +260 -0
  52. data/lib/geodetic/datum.rb +62 -0
  53. data/lib/geodetic/distance.rb +146 -0
  54. data/lib/geodetic/geoid_height.rb +299 -0
  55. data/lib/geodetic/version.rb +5 -0
  56. data/lib/geodetic.rb +13 -0
  57. data/mkdocs.yml +140 -0
  58. data/sig/geodetic.rbs +4 -0
  59. metadata +104 -0
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../datum'
4
+
5
+ module Geodetic
6
+ module Coordinates
7
+ class ENU
8
+ attr_reader :e, :n, :u
9
+ alias_method :east, :e
10
+ alias_method :north, :n
11
+ alias_method :up, :u
12
+
13
+ def initialize(e: 0.0, n: 0.0, u: 0.0)
14
+ @e = e.to_f
15
+ @n = n.to_f
16
+ @u = u.to_f
17
+ end
18
+
19
+ def e=(value)
20
+ @e = value.to_f
21
+ end
22
+ alias_method :east=, :e=
23
+
24
+ def n=(value)
25
+ @n = value.to_f
26
+ end
27
+ alias_method :north=, :n=
28
+
29
+ def u=(value)
30
+ @u = value.to_f
31
+ end
32
+ alias_method :up=, :u=
33
+
34
+ def to_ecef(reference_ecef, reference_lla = nil)
35
+ raise ArgumentError, "Expected ECEF" unless reference_ecef.is_a?(ECEF)
36
+
37
+ if reference_lla.nil?
38
+ reference_lla = reference_ecef.to_lla
39
+ end
40
+
41
+ lat_rad = reference_lla.lat * RAD_PER_DEG
42
+ lon_rad = reference_lla.lng * RAD_PER_DEG
43
+
44
+ sin_lat = Math.sin(lat_rad)
45
+ cos_lat = Math.cos(lat_rad)
46
+ sin_lon = Math.sin(lon_rad)
47
+ cos_lon = Math.cos(lon_rad)
48
+
49
+ delta_x = -sin_lon * @e - sin_lat * cos_lon * @n + cos_lat * cos_lon * @u
50
+ delta_y = cos_lon * @e - sin_lat * sin_lon * @n + cos_lat * sin_lon * @u
51
+ delta_z = cos_lat * @n + sin_lat * @u
52
+
53
+ x = reference_ecef.x + delta_x
54
+ y = reference_ecef.y + delta_y
55
+ z = reference_ecef.z + delta_z
56
+
57
+ ECEF.new(x: x, y: y, z: z)
58
+ end
59
+
60
+ def self.from_ecef(ecef, reference_ecef, reference_lla = nil)
61
+ raise ArgumentError, "Expected ECEF" unless ecef.is_a?(ECEF)
62
+ raise ArgumentError, "Expected ECEF" unless reference_ecef.is_a?(ECEF)
63
+
64
+ ecef.to_enu(reference_ecef, reference_lla)
65
+ end
66
+
67
+ def to_ned
68
+ NED.new(n: @n, e: @e, d: -@u)
69
+ end
70
+
71
+ def self.from_ned(ned)
72
+ raise ArgumentError, "Expected NED" unless ned.is_a?(NED)
73
+
74
+ ned.to_enu
75
+ end
76
+
77
+ def to_lla(reference_lla)
78
+ raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
79
+
80
+ reference_ecef = reference_lla.to_ecef
81
+ ecef = self.to_ecef(reference_ecef, reference_lla)
82
+ ecef.to_lla
83
+ end
84
+
85
+ def self.from_lla(lla, reference_lla)
86
+ raise ArgumentError, "Expected LLA" unless lla.is_a?(LLA)
87
+ raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
88
+
89
+ lla.to_enu(reference_lla)
90
+ end
91
+
92
+ def to_utm(reference_lla, datum = WGS84)
93
+ raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
94
+
95
+ lla = self.to_lla(reference_lla)
96
+ lla.to_utm(datum)
97
+ end
98
+
99
+ def self.from_utm(utm, reference_lla, datum = WGS84)
100
+ raise ArgumentError, "Expected UTM" unless utm.is_a?(UTM)
101
+ raise ArgumentError, "Expected LLA" unless reference_lla.is_a?(LLA)
102
+
103
+ lla = utm.to_lla(datum)
104
+ lla.to_enu(reference_lla)
105
+ end
106
+
107
+ def to_mgrs(reference_lla, datum = WGS84, precision = 5)
108
+ MGRS.from_lla(to_lla(reference_lla), datum, precision)
109
+ end
110
+
111
+ def self.from_mgrs(mgrs_coord, reference_lla, datum = WGS84)
112
+ lla = mgrs_coord.to_lla(datum)
113
+ from_lla(lla, reference_lla)
114
+ end
115
+
116
+ def to_usng(reference_lla, datum = WGS84, precision = 5)
117
+ USNG.from_lla(to_lla(reference_lla), datum, precision)
118
+ end
119
+
120
+ def self.from_usng(usng_coord, reference_lla, datum = WGS84)
121
+ lla = usng_coord.to_lla(datum)
122
+ from_lla(lla, reference_lla)
123
+ end
124
+
125
+ def to_web_mercator(reference_lla, datum = WGS84)
126
+ WebMercator.from_lla(to_lla(reference_lla), datum)
127
+ end
128
+
129
+ def self.from_web_mercator(wm_coord, reference_lla, datum = WGS84)
130
+ lla = wm_coord.to_lla(datum)
131
+ from_lla(lla, reference_lla)
132
+ end
133
+
134
+ def to_ups(reference_lla, datum = WGS84)
135
+ UPS.from_lla(to_lla(reference_lla), datum)
136
+ end
137
+
138
+ def self.from_ups(ups_coord, reference_lla, datum = WGS84)
139
+ lla = ups_coord.to_lla(datum)
140
+ from_lla(lla, reference_lla)
141
+ end
142
+
143
+ def to_state_plane(reference_lla, zone_code, datum = WGS84)
144
+ StatePlane.from_lla(to_lla(reference_lla), zone_code, datum)
145
+ end
146
+
147
+ def self.from_state_plane(sp_coord, reference_lla, datum = WGS84)
148
+ lla = sp_coord.to_lla(datum)
149
+ from_lla(lla, reference_lla)
150
+ end
151
+
152
+ def to_bng(reference_lla)
153
+ BNG.from_lla(to_lla(reference_lla))
154
+ end
155
+
156
+ def self.from_bng(bng_coord, reference_lla)
157
+ lla = bng_coord.to_lla
158
+ from_lla(lla, reference_lla)
159
+ end
160
+
161
+ def to_gh36(reference_lla, precision: 10)
162
+ GH36.new(to_lla(reference_lla), precision: precision)
163
+ end
164
+
165
+ def self.from_gh36(gh36_coord, reference_lla)
166
+ lla = gh36_coord.to_lla
167
+ from_lla(lla, reference_lla)
168
+ end
169
+
170
+ def to_s(precision = 2)
171
+ precision = precision.to_i
172
+ if precision == 0
173
+ "#{@e.round}, #{@n.round}, #{@u.round}"
174
+ else
175
+ format("%.#{precision}f, %.#{precision}f, %.#{precision}f", @e, @n, @u)
176
+ end
177
+ end
178
+
179
+ def to_a
180
+ [@e, @n, @u]
181
+ end
182
+
183
+ def self.from_array(array)
184
+ new(e: array[0].to_f, n: array[1].to_f, u: array[2].to_f)
185
+ end
186
+
187
+ def self.from_string(string)
188
+ parts = string.split(',').map(&:strip)
189
+ new(e: parts[0].to_f, n: parts[1].to_f, u: parts[2].to_f)
190
+ end
191
+
192
+ def ==(other)
193
+ return false unless other.is_a?(ENU)
194
+
195
+ delta_e = (@e - other.e).abs
196
+ delta_n = (@n - other.n).abs
197
+ delta_u = (@u - other.u).abs
198
+
199
+ delta_e <= 1e-6 && delta_n <= 1e-6 && delta_u <= 1e-6
200
+ end
201
+
202
+
203
+ def horizontal_distance_to(other)
204
+ raise ArgumentError, "Expected ENU" unless other.is_a?(ENU)
205
+
206
+ de = @e - other.e
207
+ dn = @n - other.n
208
+
209
+ Math.sqrt(de**2 + dn**2)
210
+ end
211
+
212
+ # Local tangent-plane bearing to another ENU point (degrees, 0-360).
213
+ # For great-circle bearing across coordinate systems, use the universal bearing_to.
214
+ def local_bearing_to(other)
215
+ raise ArgumentError, "Expected ENU" unless other.is_a?(ENU)
216
+
217
+ de = other.e - @e
218
+ dn = other.n - @n
219
+
220
+ bearing_rad = Math.atan2(de, dn)
221
+ bearing_deg = bearing_rad * DEG_PER_RAD
222
+
223
+ bearing_deg += 360 if bearing_deg < 0
224
+ bearing_deg
225
+ end
226
+
227
+ def distance_to_origin
228
+ Math.sqrt(@e**2 + @n**2 + @u**2)
229
+ end
230
+
231
+ def bearing_from_origin
232
+ bearing_rad = Math.atan2(@e, @n)
233
+ bearing_deg = bearing_rad * DEG_PER_RAD
234
+
235
+ bearing_deg += 360 if bearing_deg < 0
236
+ bearing_deg
237
+ end
238
+
239
+ def horizontal_distance_to_origin
240
+ Math.sqrt(@e**2 + @n**2)
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Geohash-36 Coordinate System
4
+ # A hierarchical spatial hashing algorithm that encodes latitude/longitude
5
+ # into a compact, URL-friendly string using a case-sensitive 36-character alphabet.
6
+ # Uses a 6x6 grid subdivision (radix-36) providing higher precision per character
7
+ # than standard Geohash (radix-32).
8
+ #
9
+ # Character set: 23456789bBCdDFgGhHjJKlLMnNPqQrRtTVWX
10
+ # (avoids vowels, vowel-like numbers, and ambiguous characters like 0/O, 1/I/l)
11
+ #
12
+ # This is a 2D coordinate system (no altitude). Conversions to/from other
13
+ # systems go through LLA as the intermediary.
14
+ #
15
+ # Usage:
16
+ # GH36.new("bdrdC26BqH") # from a geohash string
17
+ # GH36.new(lla_coord) # from any coordinate (converts via LLA)
18
+ # GH36.new(utm_coord, precision: 8) # with custom precision
19
+
20
+ module Geodetic
21
+ module Coordinates
22
+ class GH36
23
+ require_relative '../datum'
24
+
25
+ # 6x6 encoding matrix mapping (row, col) to character
26
+ # Row 0 is the northernmost latitude slice; row 5 is southernmost
27
+ # Col 0 is the westernmost longitude slice; col 5 is easternmost
28
+ MATRIX = [
29
+ ['2', '3', '4', '5', '6', '7'],
30
+ ['8', '9', 'b', 'B', 'C', 'd'],
31
+ ['D', 'F', 'g', 'G', 'h', 'H'],
32
+ ['j', 'J', 'K', 'l', 'L', 'M'],
33
+ ['n', 'N', 'P', 'q', 'Q', 'r'],
34
+ ['R', 't', 'T', 'V', 'W', 'X']
35
+ ].freeze
36
+
37
+ MATRIX_SIDE = 6
38
+ MAX_INDEX = 5
39
+
40
+ # Valid characters in a Geohash-36 string
41
+ VALID_CHARS = '23456789bBCdDFgGhHjJKlLMnNPqQrRtTVWX'
42
+ VALID_CHARS_SET = VALID_CHARS.chars.to_set.freeze
43
+
44
+ # Default hash length (10 chars gives sub-meter precision)
45
+ DEFAULT_LENGTH = 10
46
+
47
+ # Reverse lookup: character -> [row, col] in the matrix
48
+ CHAR_INDEX = {}.tap do |h|
49
+ MATRIX.each_with_index do |row, r|
50
+ row.each_with_index do |ch, c|
51
+ h[ch] = [r, c]
52
+ end
53
+ end
54
+ end.freeze
55
+
56
+ # Neighbor direction offsets as [row_delta, col_delta]
57
+ # Matrix row 0 = north, row 5 = south; col 0 = west, col 5 = east
58
+ DIRECTIONS = {
59
+ N: [-1, 0],
60
+ S: [ 1, 0],
61
+ E: [ 0, 1],
62
+ W: [ 0, -1],
63
+ NE: [-1, 1],
64
+ NW: [-1, -1],
65
+ SE: [ 1, 1],
66
+ SW: [ 1, -1]
67
+ }.freeze
68
+
69
+ attr_reader :geohash
70
+
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
128
+
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
172
+
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 ==(other)
214
+ return false unless other.is_a?(GH36)
215
+ @geohash == other.geohash
216
+ end
217
+
218
+ def valid?
219
+ @geohash.length > 0 && @geohash.each_char.all? { |c| VALID_CHARS_SET.include?(c) }
220
+ end
221
+
222
+ # Returns all 8 neighboring geohash cells as GH36 instances
223
+ # Keys: :N, :S, :E, :W, :NE, :NW, :SE, :SW
224
+ def neighbors
225
+ DIRECTIONS.each_with_object({}) do |(dir, delta), result|
226
+ hash = self.class.send(:neighbor_hash, @geohash, delta[0], delta[1])
227
+ result[dir] = self.class.new(hash)
228
+ end
229
+ end
230
+
231
+ # Returns the geohash cell as an Areas::Rectangle
232
+ def to_area
233
+ bb = self.class.send(:decode_bounds, @geohash)
234
+ nw = LLA.new(lat: bb[:max_lat], lng: bb[:min_lng], alt: 0.0)
235
+ se = LLA.new(lat: bb[:min_lat], lng: bb[:max_lng], alt: 0.0)
236
+ Areas::Rectangle.new(nw: nw, se: se)
237
+ end
238
+
239
+ # Returns precision in meters as {lat:, lng:}
240
+ def precision_in_meters
241
+ one_degree_meters = (2 * Math::PI * 6_370_000) / 360.0
242
+ lat_prec = (90.0 / (MATRIX_SIDE ** precision)) * one_degree_meters
243
+ { lat: lat_prec, lng: lat_prec * 2 }
244
+ end
245
+
246
+ # URL-friendly slug (the geohash itself is already URL-safe)
247
+ alias_method :to_slug, :to_s
248
+
249
+ private
250
+
251
+ # Encode lat/lng to a geohash string of given length
252
+ def encode(lat, lng, length = DEFAULT_LENGTH)
253
+ lat_min, lat_max = -90.0, 90.0
254
+ lng_min, lng_max = -180.0, 180.0
255
+
256
+ result = String.new(capacity: length)
257
+
258
+ length.times do
259
+ # Subdivide longitude into 6 slices
260
+ lng_slice = (lng_max - lng_min) / MATRIX_SIDE.to_f
261
+ col = 0
262
+ MATRIX_SIDE.times do |i|
263
+ left = lng_min + i * lng_slice
264
+ right = lng_min + (i + 1) * lng_slice
265
+ if (i == 0 ? lng >= left : lng > left) && lng <= right
266
+ col = i
267
+ lng_min = left
268
+ lng_max = right
269
+ break
270
+ end
271
+ end
272
+
273
+ # Subdivide latitude into 6 slices (row 0 = south, row 5 = north)
274
+ lat_slice = (lat_max - lat_min) / MATRIX_SIDE.to_f
275
+ row = 0
276
+ MATRIX_SIDE.times do |i|
277
+ bottom = lat_min + i * lat_slice
278
+ top = lat_min + (i + 1) * lat_slice
279
+ if (i == 0 ? lat >= bottom : lat > bottom) && lat <= top
280
+ row = MAX_INDEX - i # Invert: matrix row 0 = north
281
+ lat_min = bottom
282
+ lat_max = top
283
+ break
284
+ end
285
+ end
286
+
287
+ result << MATRIX[row][col]
288
+ end
289
+
290
+ result
291
+ end
292
+
293
+ # Decode a geohash string to lat/lng (returns midpoint of bounding box)
294
+ def decode(geohash)
295
+ bounds = self.class.send(:decode_bounds, geohash)
296
+ {
297
+ lat: (bounds[:min_lat] + bounds[:max_lat]) / 2.0,
298
+ lng: (bounds[:min_lng] + bounds[:max_lng]) / 2.0
299
+ }
300
+ end
301
+
302
+ # Decode a geohash string to its bounding box
303
+ def self.decode_bounds(geohash)
304
+ lat_min, lat_max = -90.0, 90.0
305
+ lng_min, lng_max = -180.0, 180.0
306
+
307
+ geohash.each_char do |ch|
308
+ indices = CHAR_INDEX[ch]
309
+ raise ArgumentError, "Invalid Geohash-36 character: #{ch}" unless indices
310
+
311
+ row, col = indices
312
+ lat_row = MAX_INDEX - row # Invert back to bottom-up
313
+
314
+ lng_slice = (lng_max - lng_min) / MATRIX_SIDE.to_f
315
+ lng_min_new = lng_min + col * lng_slice
316
+ lng_max = lng_min + (col + 1) * lng_slice
317
+ lng_min = lng_min_new
318
+
319
+ lat_slice = (lat_max - lat_min) / MATRIX_SIDE.to_f
320
+ lat_min_new = lat_min + lat_row * lat_slice
321
+ lat_max = lat_min + (lat_row + 1) * lat_slice
322
+ lat_min = lat_min_new
323
+ end
324
+
325
+ { min_lat: lat_min, max_lat: lat_max, min_lng: lng_min, max_lng: lng_max }
326
+ end
327
+ private_class_method :decode_bounds
328
+
329
+ # Compute a neighbor hash by adjusting the last character's position
330
+ # in the matrix. When the adjustment wraps beyond the matrix edge,
331
+ # we recurse on the parent prefix and carry the wrap.
332
+ #
333
+ # Matrix layout: row 0 = north (high lat), row 5 = south (low lat)
334
+ # col 0 = west (low lng), col 5 = east (high lng)
335
+ def self.neighbor_hash(hash, row_delta, col_delta)
336
+ return hash if hash.empty?
337
+
338
+ prefix = hash[0..-2]
339
+ last_char = hash[-1]
340
+ indices = CHAR_INDEX[last_char]
341
+ raise ArgumentError, "Invalid Geohash-36 character: #{last_char}" unless indices
342
+
343
+ row, col = indices
344
+ new_row = row + row_delta
345
+ new_col = col + col_delta
346
+
347
+ # Check if we need to carry to the parent
348
+ carry_row = 0
349
+ carry_col = 0
350
+
351
+ if new_row < 0
352
+ carry_row = row_delta # Propagate same direction
353
+ new_row += MATRIX_SIDE
354
+ elsif new_row >= MATRIX_SIDE
355
+ carry_row = row_delta
356
+ new_row -= MATRIX_SIDE
357
+ end
358
+
359
+ if new_col < 0
360
+ carry_col = col_delta
361
+ new_col += MATRIX_SIDE
362
+ elsif new_col >= MATRIX_SIDE
363
+ carry_col = col_delta
364
+ new_col -= MATRIX_SIDE
365
+ end
366
+
367
+ if (carry_row != 0 || carry_col != 0) && !prefix.empty?
368
+ prefix = neighbor_hash(prefix, carry_row, carry_col)
369
+ end
370
+
371
+ prefix + MATRIX[new_row][new_col]
372
+ end
373
+ private_class_method :neighbor_hash
374
+
375
+ def validate_geohash!(geohash)
376
+ raise ArgumentError, "Geohash-36 string cannot be empty" if geohash.empty?
377
+ invalid = geohash.chars.reject { |c| VALID_CHARS_SET.include?(c) }
378
+ unless invalid.empty?
379
+ raise ArgumentError, "Invalid Geohash-36 characters: #{invalid.join(', ')}"
380
+ end
381
+ end
382
+ end
383
+ end
384
+ end