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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -4
- data/README.md +19 -5
- data/docs/coordinate-systems/bng.md +5 -5
- data/docs/coordinate-systems/ecef.md +23 -23
- data/docs/coordinate-systems/enu.md +3 -3
- data/docs/coordinate-systems/gars.md +246 -0
- data/docs/coordinate-systems/georef.md +221 -0
- data/docs/coordinate-systems/gh.md +7 -7
- data/docs/coordinate-systems/gh36.md +6 -6
- data/docs/coordinate-systems/h3.md +312 -0
- data/docs/coordinate-systems/ham.md +6 -6
- data/docs/coordinate-systems/index.md +40 -34
- data/docs/coordinate-systems/lla.md +26 -26
- data/docs/coordinate-systems/mgrs.md +3 -3
- data/docs/coordinate-systems/ned.md +3 -3
- data/docs/coordinate-systems/olc.md +6 -6
- data/docs/coordinate-systems/state-plane.md +2 -2
- data/docs/coordinate-systems/ups.md +4 -4
- data/docs/coordinate-systems/usng.md +2 -2
- data/docs/coordinate-systems/utm.md +23 -23
- data/docs/coordinate-systems/web-mercator.md +7 -7
- data/docs/getting-started/installation.md +17 -17
- data/docs/getting-started/quick-start.md +8 -8
- data/docs/index.md +22 -19
- data/docs/reference/areas.md +15 -15
- data/docs/reference/conversions.md +31 -31
- data/docs/reference/geoid-height.md +5 -5
- data/docs/reference/serialization.md +44 -44
- data/examples/01_basic_conversions.rb +10 -10
- data/examples/02_all_coordinate_systems.rb +24 -24
- data/lib/geodetic/areas/circle.rb +1 -1
- data/lib/geodetic/areas/polygon.rb +2 -2
- data/lib/geodetic/areas/rectangle.rb +6 -6
- data/lib/geodetic/{coordinates → coordinate}/bng.rb +3 -37
- data/lib/geodetic/{coordinates → coordinate}/ecef.rb +3 -33
- data/lib/geodetic/{coordinates → coordinate}/enu.rb +30 -1
- data/lib/geodetic/coordinate/gars.rb +233 -0
- data/lib/geodetic/coordinate/georef.rb +204 -0
- data/lib/geodetic/coordinate/gh.rb +161 -0
- data/lib/geodetic/{coordinates → coordinate}/gh36.rb +28 -187
- data/lib/geodetic/coordinate/h3.rb +413 -0
- data/lib/geodetic/coordinate/ham.rb +226 -0
- data/lib/geodetic/{coordinates → coordinate}/lla.rb +31 -1
- data/lib/geodetic/{coordinates → coordinate}/mgrs.rb +3 -33
- data/lib/geodetic/{coordinates → coordinate}/ned.rb +30 -1
- data/lib/geodetic/{coordinates → coordinate}/olc.rb +19 -225
- data/lib/geodetic/coordinate/spatial_hash.rb +342 -0
- data/lib/geodetic/{coordinates → coordinate}/state_plane.rb +30 -1
- data/lib/geodetic/{coordinates → coordinate}/ups.rb +3 -37
- data/lib/geodetic/{coordinates → coordinate}/usng.rb +3 -33
- data/lib/geodetic/{coordinates → coordinate}/utm.rb +3 -33
- data/lib/geodetic/{coordinates → coordinate}/web_mercator.rb +3 -33
- data/lib/geodetic/{coordinates.rb → coordinate.rb} +62 -45
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic.rb +1 -1
- data/spatial_hash_idea.md +241 -0
- metadata +29 -20
- data/lib/geodetic/coordinates/gh.rb +0 -372
- 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
|