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
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GARS (Global Area Reference System) Coordinate
4
+ # A standardized geospatial reference system developed by the National
5
+ # Geospatial-Intelligence Agency (NGA). Divides Earth into hierarchical
6
+ # grid cells at three precision levels.
7
+ #
8
+ # Format: NNNLLqk where:
9
+ # NNN = 3-digit longitude band (001-720, each 0.5°)
10
+ # LL = 2-letter latitude band (AA-QZ, each 0.5°)
11
+ # q = quadrant digit 1-4 (optional, subdivides to 0.25°)
12
+ # k = keypad digit 1-9 (optional, subdivides to ~0.0833°)
13
+ #
14
+ # Quadrant layout (within 30' cell):
15
+ # +---+---+
16
+ # | 1 | 2 | (north)
17
+ # +---+---+
18
+ # | 3 | 4 | (south)
19
+ # +---+---+
20
+ #
21
+ # Keypad layout (within quadrant, telephone-style):
22
+ # +---+---+---+
23
+ # | 1 | 2 | 3 |
24
+ # +---+---+---+
25
+ # | 4 | 5 | 6 |
26
+ # +---+---+---+
27
+ # | 7 | 8 | 9 |
28
+ # +---+---+---+
29
+ #
30
+ # Valid code lengths: 5 (30'), 6 (15'), 7 (5')
31
+ #
32
+ # This is a 2D coordinate system (no altitude). Conversions to/from other
33
+ # systems go through LLA as the intermediary.
34
+ #
35
+ # Usage:
36
+ # GARS.new("006AG39") # from a GARS string
37
+ # GARS.new(lla_coord) # from any coordinate (converts via LLA)
38
+ # GARS.new(utm_coord, precision: 6) # 15-minute quadrant precision
39
+
40
+ require_relative 'spatial_hash'
41
+
42
+ module Geodetic
43
+ module Coordinate
44
+ class GARS < SpatialHash
45
+ # 24 letters (A-Z, omitting I and O)
46
+ LETTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ'.freeze
47
+ LETTERS_SET = LETTERS.chars.to_set.freeze
48
+
49
+ BANDS_PER_LETTER = 24
50
+
51
+ # Quadrant digit → [col, row] (col 0=west, row 0=south)
52
+ QUADRANT_DECODE = {
53
+ 1 => [0, 1], # NW
54
+ 2 => [1, 1], # NE
55
+ 3 => [0, 0], # SW
56
+ 4 => [1, 0], # SE
57
+ }.freeze
58
+
59
+ # Keypad digit → [col, row] (col 0=west, row 0=south)
60
+ KEYPAD_DECODE = {
61
+ 1 => [0, 2], 2 => [1, 2], 3 => [2, 2],
62
+ 4 => [0, 1], 5 => [1, 1], 6 => [2, 1],
63
+ 7 => [0, 0], 8 => [1, 0], 9 => [2, 0],
64
+ }.freeze
65
+
66
+ VALID_LENGTHS = [5, 6, 7].freeze
67
+
68
+ attr_reader :code
69
+
70
+ def self.default_precision = 7
71
+ def self.hash_system_name = :gars
72
+
73
+ # --- Subclass contract implementations ---
74
+
75
+ def valid?
76
+ VALID_LENGTHS.include?(@code.length) && valid_characters?(@code)
77
+ end
78
+
79
+ def code_value
80
+ @code
81
+ end
82
+
83
+ protected
84
+
85
+ def normalize(string)
86
+ result = String.new(capacity: string.length)
87
+ string.each_char.with_index do |ch, i|
88
+ result << (i >= 3 && i <= 4 ? ch.upcase : ch)
89
+ end
90
+ result
91
+ end
92
+
93
+ def set_code(value)
94
+ @code = value
95
+ end
96
+
97
+ private
98
+
99
+ def encode(lat, lng, length = self.class.default_precision)
100
+ length = length.clamp(5, 7)
101
+
102
+ adj_lng = (lng + 180.0).clamp(0.0, 360.0 - 1e-10)
103
+ adj_lat = (lat + 90.0).clamp(0.0, 180.0 - 1e-10)
104
+
105
+ # Longitude band (1-720, each 0.5°)
106
+ lon_band = (adj_lng / 0.5).to_i + 1
107
+ lon_band = lon_band.clamp(1, 720)
108
+
109
+ # Latitude band (1-360, each 0.5°)
110
+ lat_band = (adj_lat / 0.5).to_i + 1
111
+ lat_band = lat_band.clamp(1, 360)
112
+
113
+ result = format("%03d", lon_band)
114
+ first_idx = (lat_band - 1) / BANDS_PER_LETTER
115
+ second_idx = (lat_band - 1) % BANDS_PER_LETTER
116
+ result << LETTERS[first_idx] << LETTERS[second_idx]
117
+
118
+ return result if length <= 5
119
+
120
+ # Fractional position within 30' cell (0.0 to 1.0)
121
+ lon_frac = adj_lng / 0.5 - (lon_band - 1)
122
+ lat_frac = adj_lat / 0.5 - (lat_band - 1)
123
+
124
+ # Quadrant (1-4)
125
+ q_col = (lon_frac * 2).to_i.clamp(0, 1)
126
+ q_row = (lat_frac * 2).to_i.clamp(0, 1)
127
+ quadrant = (1 - q_row) * 2 + q_col + 1
128
+ result << quadrant.to_s
129
+
130
+ return result if length <= 6
131
+
132
+ # Keypad (1-9)
133
+ lon_within_q = lon_frac * 2 - q_col
134
+ lat_within_q = lat_frac * 2 - q_row
135
+ k_col = (lon_within_q * 3).to_i.clamp(0, 2)
136
+ k_row = (lat_within_q * 3).to_i.clamp(0, 2)
137
+ keypad = (2 - k_row) * 3 + k_col + 1
138
+ result << keypad.to_s
139
+
140
+ result
141
+ end
142
+
143
+ def decode(code)
144
+ bounds = decode_bounds(code)
145
+ {
146
+ lat: (bounds[:min_lat] + bounds[:max_lat]) / 2.0,
147
+ lng: (bounds[:min_lng] + bounds[:max_lng]) / 2.0
148
+ }
149
+ end
150
+
151
+ def decode_bounds(code)
152
+ lon_band = code[0, 3].to_i
153
+ first_idx = LETTERS.index(code[3])
154
+ second_idx = LETTERS.index(code[4])
155
+ raise ArgumentError, "Invalid GARS latitude band: #{code[3..4]}" unless first_idx && second_idx
156
+
157
+ lat_band = first_idx * BANDS_PER_LETTER + second_idx + 1
158
+
159
+ lng = -180.0 + (lon_band - 1) * 0.5
160
+ lat = -90.0 + (lat_band - 1) * 0.5
161
+ lng_step = 0.5
162
+ lat_step = 0.5
163
+
164
+ if code.length >= 6
165
+ quadrant = code[5].to_i
166
+ q_col, q_row = QUADRANT_DECODE[quadrant]
167
+ raise ArgumentError, "Invalid GARS quadrant: #{code[5]}" unless q_col
168
+
169
+ lng += q_col * 0.25
170
+ lat += q_row * 0.25
171
+ lng_step = 0.25
172
+ lat_step = 0.25
173
+ end
174
+
175
+ if code.length >= 7
176
+ keypad = code[6].to_i
177
+ k_col, k_row = KEYPAD_DECODE[keypad]
178
+ raise ArgumentError, "Invalid GARS keypad: #{code[6]}" unless k_col
179
+
180
+ cell_size = 0.25 / 3.0
181
+ lng += k_col * cell_size
182
+ lat += k_row * cell_size
183
+ lng_step = cell_size
184
+ lat_step = cell_size
185
+ end
186
+
187
+ { min_lat: lat, max_lat: lat + lat_step, min_lng: lng, max_lng: lng + lng_step }
188
+ end
189
+
190
+ def validate_gars!(code)
191
+ raise ArgumentError, "GARS code cannot be empty" if code.empty?
192
+ unless VALID_LENGTHS.include?(code.length)
193
+ raise ArgumentError, "GARS code must be 5, 6, or 7 characters (got #{code.length})"
194
+ end
195
+ unless valid_characters?(code)
196
+ raise ArgumentError, "Invalid GARS code: #{code}"
197
+ end
198
+ end
199
+
200
+ alias_method :validate_code!, :validate_gars!
201
+
202
+ def valid_characters?(code)
203
+ # First 3 chars: digits forming 001-720
204
+ return false unless code[0, 3].match?(/\A\d{3}\z/)
205
+ lon_band = code[0, 3].to_i
206
+ return false unless lon_band >= 1 && lon_band <= 720
207
+
208
+ # Next 2 chars: valid letters
209
+ return false unless LETTERS_SET.include?(code[3])
210
+ return false unless LETTERS_SET.include?(code[4])
211
+
212
+ # First letter of lat band must be A-Q (index 0-14)
213
+ first_idx = LETTERS.index(code[3])
214
+ return false if first_idx > 14
215
+
216
+ if code.length >= 6
217
+ q = code[5].to_i
218
+ return false unless q >= 1 && q <= 4
219
+ end
220
+
221
+ if code.length >= 7
222
+ k = code[6].to_i
223
+ return false unless k >= 1 && k <= 9
224
+ end
225
+
226
+ true
227
+ end
228
+
229
+ register_hash_system(:gars, self, default_precision: 7)
230
+ Coordinate.register_class(self)
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GEOREF (World Geographic Reference System) Coordinate
4
+ # A grid-based geocode for specifying locations on Earth, developed by the
5
+ # US military and adopted by ICAO for air navigation and air defense reporting.
6
+ #
7
+ # Encoding reads longitude first, then latitude. Uses a false coordinate
8
+ # system: longitude + 180°, latitude + 90° to make all values positive.
9
+ #
10
+ # Structure:
11
+ # Chars 1-2: 15° tiles (24 lng × 12 lat letters)
12
+ # Chars 3-4: 1° degree subdivision (15 letters each)
13
+ # Chars 5+: Minutes as even-length digit pairs (lng digits, then lat digits)
14
+ #
15
+ # Valid code lengths: 2, 4, 8, 10, 12, 14 characters
16
+ # (not 6 — minimum numeric portion is 2 digits per axis)
17
+ #
18
+ # This is a 2D coordinate system (no altitude). Conversions to/from other
19
+ # systems go through LLA as the intermediary.
20
+ #
21
+ # Usage:
22
+ # GEOREF.new("GJPJ3417") # from a GEOREF string
23
+ # GEOREF.new(lla_coord) # from any coordinate (converts via LLA)
24
+ # GEOREF.new(utm_coord, precision: 10) # with 0.1-minute precision
25
+
26
+ require_relative 'spatial_hash'
27
+
28
+ module Geodetic
29
+ module Coordinate
30
+ class GEOREF < SpatialHash
31
+ # 24 letters for longitude tiles (A-Z, omitting I and O)
32
+ TILE_LNG_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ'.freeze
33
+
34
+ # 12 letters for latitude tiles (A-M, omitting I)
35
+ TILE_LAT_CHARS = 'ABCDEFGHJKLM'.freeze
36
+
37
+ # 15 letters for degree subdivisions (A-Q, omitting I and O)
38
+ DEGREE_CHARS = 'ABCDEFGHJKLMNPQ'.freeze
39
+
40
+ TILE_LNG_SET = TILE_LNG_CHARS.chars.to_set.freeze
41
+ TILE_LAT_SET = TILE_LAT_CHARS.chars.to_set.freeze
42
+ DEGREE_SET = DEGREE_CHARS.chars.to_set.freeze
43
+
44
+ # Valid code lengths (no length 6 — can't have single-digit minutes)
45
+ VALID_LENGTHS = [2, 4, 8, 10, 12, 14].freeze
46
+
47
+ attr_reader :code
48
+
49
+ def self.default_precision = 8
50
+ def self.hash_system_name = :georef
51
+
52
+ # --- Subclass contract implementations ---
53
+
54
+ def valid?
55
+ VALID_LENGTHS.include?(@code.length) && valid_characters?(@code)
56
+ end
57
+
58
+ def code_value
59
+ @code
60
+ end
61
+
62
+ protected
63
+
64
+ def normalize(string)
65
+ string.upcase
66
+ end
67
+
68
+ def set_code(value)
69
+ @code = value
70
+ end
71
+
72
+ private
73
+
74
+ def encode(lat, lng, length = self.class.default_precision)
75
+ # Snap to nearest valid length
76
+ length = VALID_LENGTHS.min_by { |v| (v - length).abs }
77
+
78
+ # Normalize to positive values, clamping at boundaries
79
+ adj_lng = (lng + 180.0).clamp(0.0, 360.0 - 1e-10)
80
+ adj_lat = (lat + 90.0).clamp(0.0, 180.0 - 1e-10)
81
+
82
+ result = String.new(capacity: length)
83
+
84
+ # Tile: 15° cells
85
+ lng_tile = (adj_lng / 15.0).to_i.clamp(0, 23)
86
+ lat_tile = (adj_lat / 15.0).to_i.clamp(0, 11)
87
+ result << TILE_LNG_CHARS[lng_tile] << TILE_LAT_CHARS[lat_tile]
88
+ return result if length <= 2
89
+
90
+ # Degree within tile: 1° cells
91
+ lng_within_tile = adj_lng - lng_tile * 15.0
92
+ lat_within_tile = adj_lat - lat_tile * 15.0
93
+ lng_deg = lng_within_tile.to_i.clamp(0, 14)
94
+ lat_deg = lat_within_tile.to_i.clamp(0, 14)
95
+ result << DEGREE_CHARS[lng_deg] << DEGREE_CHARS[lat_deg]
96
+ return result if length <= 4
97
+
98
+ # Minutes (numeric pairs, easting then northing)
99
+ lng_minutes = (lng_within_tile - lng_deg) * 60.0
100
+ lat_minutes = (lat_within_tile - lat_deg) * 60.0
101
+
102
+ num_digits = (length - 4) / 2
103
+ scale = 10**(num_digits - 2)
104
+
105
+ lng_val = (lng_minutes * scale).to_i.clamp(0, 60 * scale - 1)
106
+ lat_val = (lat_minutes * scale).to_i.clamp(0, 60 * scale - 1)
107
+
108
+ result << format("%0#{num_digits}d", lng_val)
109
+ result << format("%0#{num_digits}d", lat_val)
110
+
111
+ result
112
+ end
113
+
114
+ def decode(code)
115
+ bounds = decode_bounds(code)
116
+ {
117
+ lat: (bounds[:min_lat] + bounds[:max_lat]) / 2.0,
118
+ lng: (bounds[:min_lng] + bounds[:max_lng]) / 2.0
119
+ }
120
+ end
121
+
122
+ def decode_bounds(code)
123
+ adj_lng = 0.0
124
+ adj_lat = 0.0
125
+
126
+ # Tile
127
+ lng_tile = TILE_LNG_CHARS.index(code[0])
128
+ lat_tile = TILE_LAT_CHARS.index(code[1])
129
+ raise ArgumentError, "Invalid GEOREF tile: #{code[0..1]}" unless lng_tile && lat_tile
130
+
131
+ adj_lng = lng_tile * 15.0
132
+ adj_lat = lat_tile * 15.0
133
+ lng_step = 15.0
134
+ lat_step = 15.0
135
+
136
+ if code.length >= 4
137
+ lng_deg = DEGREE_CHARS.index(code[2])
138
+ lat_deg = DEGREE_CHARS.index(code[3])
139
+ raise ArgumentError, "Invalid GEOREF degree: #{code[2..3]}" unless lng_deg && lat_deg
140
+
141
+ adj_lng += lng_deg
142
+ adj_lat += lat_deg
143
+ lng_step = 1.0
144
+ lat_step = 1.0
145
+ end
146
+
147
+ if code.length > 4
148
+ num_digits = (code.length - 4) / 2
149
+ lng_min_str = code[4, num_digits]
150
+ lat_min_str = code[4 + num_digits, num_digits]
151
+
152
+ scale = 10**(num_digits - 2)
153
+ lng_min = lng_min_str.to_f / scale
154
+ lat_min = lat_min_str.to_f / scale
155
+
156
+ adj_lng += lng_min / 60.0
157
+ adj_lat += lat_min / 60.0
158
+
159
+ lng_step = 1.0 / (60.0 * scale)
160
+ lat_step = 1.0 / (60.0 * scale)
161
+ end
162
+
163
+ lng = adj_lng - 180.0
164
+ lat = adj_lat - 90.0
165
+
166
+ { min_lat: lat, max_lat: lat + lat_step, min_lng: lng, max_lng: lng + lng_step }
167
+ end
168
+
169
+ def validate_georef!(code)
170
+ raise ArgumentError, "GEOREF code cannot be empty" if code.empty?
171
+ unless VALID_LENGTHS.include?(code.length)
172
+ raise ArgumentError,
173
+ "GEOREF code must be 2, 4, 8, 10, 12, or 14 characters (got #{code.length})"
174
+ end
175
+ unless valid_characters?(code)
176
+ raise ArgumentError, "Invalid GEOREF code: #{code}"
177
+ end
178
+ end
179
+
180
+ alias_method :validate_code!, :validate_georef!
181
+
182
+ def valid_characters?(code)
183
+ return false unless TILE_LNG_SET.include?(code[0])
184
+ return false unless TILE_LAT_SET.include?(code[1])
185
+
186
+ if code.length >= 4
187
+ return false unless DEGREE_SET.include?(code[2])
188
+ return false unless DEGREE_SET.include?(code[3])
189
+ end
190
+
191
+ if code.length > 4
192
+ digits = code[4..]
193
+ return false unless digits.match?(/\A\d+\z/)
194
+ return false unless digits.length.even?
195
+ end
196
+
197
+ true
198
+ end
199
+
200
+ register_hash_system(:georef, self, default_precision: 8)
201
+ Coordinate.register_class(self)
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Geohash (Base-32) Coordinate System
4
+ # The standard geohash algorithm by Gustavo Niemeyer that encodes latitude/longitude
5
+ # into a compact string using a 32-character alphabet (0-9, b-z excluding a, i, l, o).
6
+ # Uses interleaved longitude and latitude bits.
7
+ #
8
+ # Widely supported by Elasticsearch, Redis, PostGIS, and many geocoding services.
9
+ #
10
+ # This is a 2D coordinate system (no altitude). Conversions to/from other
11
+ # systems go through LLA as the intermediary.
12
+ #
13
+ # Usage:
14
+ # GH.new("dr5ru7") # from a geohash string
15
+ # GH.new(lla_coord) # from any coordinate (converts via LLA)
16
+ # GH.new(utm_coord, precision: 8) # with custom precision
17
+
18
+ require_relative 'spatial_hash'
19
+
20
+ module Geodetic
21
+ module Coordinate
22
+ class GH < SpatialHash
23
+ # Base-32 alphabet used by the standard geohash algorithm
24
+ BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'
25
+
26
+ # Reverse lookup: character -> index (0..31)
27
+ CHAR_INDEX = {}.tap do |h|
28
+ BASE32.each_char.with_index { |ch, i| h[ch] = i }
29
+ end.freeze
30
+
31
+ attr_reader :geohash
32
+
33
+ def self.default_precision = 12
34
+ def self.hash_system_name = :gh
35
+
36
+ # --- Subclass contract implementations ---
37
+
38
+ def valid?
39
+ @geohash.length > 0 && @geohash.each_char.all? { |c| CHAR_INDEX.key?(c) }
40
+ end
41
+
42
+ # Expose code_value for base class equality and other shared methods
43
+ def code_value
44
+ @geohash
45
+ end
46
+
47
+ protected
48
+
49
+ def normalize(string)
50
+ string.downcase
51
+ end
52
+
53
+ def set_code(value)
54
+ @geohash = value
55
+ end
56
+
57
+ private
58
+
59
+ # Encode lat/lng to a geohash string of given length using bit interleaving
60
+ def encode(lat, lng, length = self.class.default_precision)
61
+ lat_min, lat_max = -90.0, 90.0
62
+ lng_min, lng_max = -180.0, 180.0
63
+
64
+ result = String.new(capacity: length)
65
+ bits = 0
66
+ ch_idx = 0
67
+ even_bit = true # true = longitude bit, false = latitude bit
68
+
69
+ while result.length < length
70
+ if even_bit
71
+ mid = (lng_min + lng_max) / 2.0
72
+ if lng >= mid
73
+ ch_idx = (ch_idx << 1) | 1
74
+ lng_min = mid
75
+ else
76
+ ch_idx = ch_idx << 1
77
+ lng_max = mid
78
+ end
79
+ else
80
+ mid = (lat_min + lat_max) / 2.0
81
+ if lat >= mid
82
+ ch_idx = (ch_idx << 1) | 1
83
+ lat_min = mid
84
+ else
85
+ ch_idx = ch_idx << 1
86
+ lat_max = mid
87
+ end
88
+ end
89
+
90
+ even_bit = !even_bit
91
+ bits += 1
92
+
93
+ if bits == 5
94
+ result << BASE32[ch_idx]
95
+ bits = 0
96
+ ch_idx = 0
97
+ end
98
+ end
99
+
100
+ result
101
+ end
102
+
103
+ # Decode a geohash string to lat/lng (returns midpoint of bounding box)
104
+ def decode(geohash)
105
+ bounds = decode_bounds(geohash)
106
+ {
107
+ lat: (bounds[:min_lat] + bounds[:max_lat]) / 2.0,
108
+ lng: (bounds[:min_lng] + bounds[:max_lng]) / 2.0
109
+ }
110
+ end
111
+
112
+ # Decode a geohash string to its bounding box
113
+ def decode_bounds(geohash)
114
+ lat_min, lat_max = -90.0, 90.0
115
+ lng_min, lng_max = -180.0, 180.0
116
+ even_bit = true
117
+
118
+ geohash.each_char do |ch|
119
+ idx = CHAR_INDEX[ch]
120
+ raise ArgumentError, "Invalid geohash character: #{ch}" unless idx
121
+
122
+ 4.downto(0) do |i|
123
+ bit = (idx >> i) & 1
124
+ if even_bit
125
+ mid = (lng_min + lng_max) / 2.0
126
+ if bit == 1
127
+ lng_min = mid
128
+ else
129
+ lng_max = mid
130
+ end
131
+ else
132
+ mid = (lat_min + lat_max) / 2.0
133
+ if bit == 1
134
+ lat_min = mid
135
+ else
136
+ lat_max = mid
137
+ end
138
+ end
139
+ even_bit = !even_bit
140
+ end
141
+ end
142
+
143
+ { min_lat: lat_min, max_lat: lat_max, min_lng: lng_min, max_lng: lng_max }
144
+ end
145
+
146
+ def validate_geohash!(geohash)
147
+ raise ArgumentError, "Geohash string cannot be empty" if geohash.empty?
148
+ normalized = geohash.downcase
149
+ invalid = normalized.chars.reject { |c| CHAR_INDEX.key?(c) }
150
+ unless invalid.empty?
151
+ raise ArgumentError, "Invalid geohash characters: #{invalid.join(', ')}"
152
+ end
153
+ end
154
+
155
+ alias_method :validate_code!, :validate_geohash!
156
+
157
+ register_hash_system(:gh, self, default_precision: 12)
158
+ Coordinate.register_class(self)
159
+ end
160
+ end
161
+ end