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,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Maidenhead Locator System (Grid Square) Coordinate
|
|
4
|
+
# The geographic encoding used worldwide in amateur radio. Encodes lat/lng
|
|
5
|
+
# into a hierarchical alphanumeric string (e.g., "FN31pr") using alternating
|
|
6
|
+
# letter/digit pairs at progressively finer resolution.
|
|
7
|
+
#
|
|
8
|
+
# Uses a false coordinate system: longitude is measured eastward from the
|
|
9
|
+
# antimeridian (adding 180°), latitude is measured from the South Pole
|
|
10
|
+
# (adding 90°), so all values are positive.
|
|
11
|
+
#
|
|
12
|
+
# This is a 2D coordinate system (no altitude). Conversions to/from other
|
|
13
|
+
# systems go through LLA as the intermediary.
|
|
14
|
+
#
|
|
15
|
+
# Valid locator lengths: 2, 4, 6, or 8 characters (1-4 pairs).
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# HAM.new("FN31pr") # from a locator string
|
|
19
|
+
# HAM.new(lla_coord) # from any coordinate (converts via LLA)
|
|
20
|
+
# HAM.new(utm_coord, precision: 8) # with extended precision
|
|
21
|
+
|
|
22
|
+
require_relative 'spatial_hash'
|
|
23
|
+
|
|
24
|
+
module Geodetic
|
|
25
|
+
module Coordinate
|
|
26
|
+
class HAM < SpatialHash
|
|
27
|
+
# Encoding levels (each level is a pair of characters)
|
|
28
|
+
# Level 1 (Field): A-R (18 letters), lng step = 20°, lat step = 10°
|
|
29
|
+
# Level 2 (Square): 0-9 (10 digits), lng step = 2°, lat step = 1°
|
|
30
|
+
# Level 3 (Subsquare): a-x (24 letters), lng step = 5', lat step = 2.5'
|
|
31
|
+
# Level 4 (Extended): 0-9 (10 digits), lng step = 30", lat step = 15"
|
|
32
|
+
|
|
33
|
+
FIELD_CHARS = ('A'..'R').to_a.freeze # 18 letters
|
|
34
|
+
SQUARE_CHARS = ('0'..'9').to_a.freeze # 10 digits
|
|
35
|
+
SUBSQUARE_CHARS = ('a'..'x').to_a.freeze # 24 letters
|
|
36
|
+
EXTENDED_CHARS = ('0'..'9').to_a.freeze # 10 digits
|
|
37
|
+
|
|
38
|
+
FIELD_COUNT = 18
|
|
39
|
+
SQUARE_COUNT = 10
|
|
40
|
+
SUBSQUARE_COUNT = 24
|
|
41
|
+
EXTENDED_COUNT = 10
|
|
42
|
+
|
|
43
|
+
# Longitude step sizes in degrees for each level
|
|
44
|
+
LNG_STEPS = [20.0, 2.0, 5.0 / 60.0, 0.5 / 60.0].freeze
|
|
45
|
+
# Latitude step sizes in degrees for each level
|
|
46
|
+
LAT_STEPS = [10.0, 1.0, 2.5 / 60.0, 0.25 / 60.0].freeze
|
|
47
|
+
|
|
48
|
+
# Divisor counts per level (how many subdivisions)
|
|
49
|
+
DIVISORS = [FIELD_COUNT, SQUARE_COUNT, SUBSQUARE_COUNT, EXTENDED_COUNT].freeze
|
|
50
|
+
|
|
51
|
+
attr_reader :locator
|
|
52
|
+
|
|
53
|
+
def self.default_precision = 6
|
|
54
|
+
def self.hash_system_name = :ham
|
|
55
|
+
|
|
56
|
+
# --- Subclass contract implementations ---
|
|
57
|
+
|
|
58
|
+
def to_s(truncate_to = nil)
|
|
59
|
+
if truncate_to
|
|
60
|
+
len = truncate_to.to_i
|
|
61
|
+
# Maidenhead locators must have even length
|
|
62
|
+
len = [len, 2].max
|
|
63
|
+
len -= 1 if len.odd?
|
|
64
|
+
@locator[0, len]
|
|
65
|
+
else
|
|
66
|
+
@locator
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def valid?
|
|
71
|
+
@locator.length >= 2 &&
|
|
72
|
+
@locator.length.even? &&
|
|
73
|
+
@locator.length <= 8 &&
|
|
74
|
+
valid_characters?(@locator)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def code_value
|
|
78
|
+
@locator
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
protected
|
|
82
|
+
|
|
83
|
+
def normalize(string)
|
|
84
|
+
result = String.new(capacity: string.length)
|
|
85
|
+
string.each_char.with_index do |ch, i|
|
|
86
|
+
pair = i / 2
|
|
87
|
+
case pair
|
|
88
|
+
when 0 then result << ch.upcase
|
|
89
|
+
when 1 then result << ch # digits, no case
|
|
90
|
+
when 2 then result << ch.downcase
|
|
91
|
+
when 3 then result << ch # digits, no case
|
|
92
|
+
else result << ch # preserve extra chars for validation to catch
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
result
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def set_code(value)
|
|
99
|
+
@locator = value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Encode lat/lng to a Maidenhead locator string
|
|
105
|
+
def encode(lat, lng, length = self.class.default_precision)
|
|
106
|
+
# Ensure even length, 2-8
|
|
107
|
+
length = length.to_i
|
|
108
|
+
length = [length, 2].max
|
|
109
|
+
length -= 1 if length.odd?
|
|
110
|
+
length = [length, 8].min
|
|
111
|
+
|
|
112
|
+
# Apply false coordinate offsets
|
|
113
|
+
adj_lng = lng + 180.0
|
|
114
|
+
adj_lat = lat + 90.0
|
|
115
|
+
|
|
116
|
+
result = String.new(capacity: length)
|
|
117
|
+
pairs = length / 2
|
|
118
|
+
|
|
119
|
+
pairs.times do |level|
|
|
120
|
+
divisor = DIVISORS[level]
|
|
121
|
+
|
|
122
|
+
lng_idx = (adj_lng / LNG_STEPS[level]).to_i
|
|
123
|
+
lat_idx = (adj_lat / LAT_STEPS[level]).to_i
|
|
124
|
+
|
|
125
|
+
# Clamp to valid range
|
|
126
|
+
lng_idx = [lng_idx, divisor - 1].min
|
|
127
|
+
lat_idx = [lat_idx, divisor - 1].min
|
|
128
|
+
|
|
129
|
+
case level
|
|
130
|
+
when 0
|
|
131
|
+
result << FIELD_CHARS[lng_idx] << FIELD_CHARS[lat_idx]
|
|
132
|
+
when 1
|
|
133
|
+
result << SQUARE_CHARS[lng_idx] << SQUARE_CHARS[lat_idx]
|
|
134
|
+
when 2
|
|
135
|
+
result << SUBSQUARE_CHARS[lng_idx] << SUBSQUARE_CHARS[lat_idx]
|
|
136
|
+
when 3
|
|
137
|
+
result << EXTENDED_CHARS[lng_idx] << EXTENDED_CHARS[lat_idx]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
adj_lng -= lng_idx * LNG_STEPS[level]
|
|
141
|
+
adj_lat -= lat_idx * LAT_STEPS[level]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
result
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Decode a Maidenhead locator to lat/lng (returns midpoint of bounding box)
|
|
148
|
+
def decode(locator)
|
|
149
|
+
bounds = decode_bounds(locator)
|
|
150
|
+
{
|
|
151
|
+
lat: (bounds[:min_lat] + bounds[:max_lat]) / 2.0,
|
|
152
|
+
lng: (bounds[:min_lng] + bounds[:max_lng]) / 2.0
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Decode a Maidenhead locator to its bounding box
|
|
157
|
+
def decode_bounds(locator)
|
|
158
|
+
lng_min = 0.0
|
|
159
|
+
lat_min = 0.0
|
|
160
|
+
|
|
161
|
+
pairs = locator.length / 2
|
|
162
|
+
|
|
163
|
+
pairs.times do |level|
|
|
164
|
+
c_lng = locator[level * 2]
|
|
165
|
+
c_lat = locator[level * 2 + 1]
|
|
166
|
+
|
|
167
|
+
case level
|
|
168
|
+
when 0
|
|
169
|
+
lng_min += (c_lng.ord - 'A'.ord) * LNG_STEPS[level]
|
|
170
|
+
lat_min += (c_lat.ord - 'A'.ord) * LAT_STEPS[level]
|
|
171
|
+
when 1
|
|
172
|
+
lng_min += (c_lng.ord - '0'.ord) * LNG_STEPS[level]
|
|
173
|
+
lat_min += (c_lat.ord - '0'.ord) * LAT_STEPS[level]
|
|
174
|
+
when 2
|
|
175
|
+
lng_min += (c_lng.ord - 'a'.ord) * LNG_STEPS[level]
|
|
176
|
+
lat_min += (c_lat.ord - 'a'.ord) * LAT_STEPS[level]
|
|
177
|
+
when 3
|
|
178
|
+
lng_min += (c_lng.ord - '0'.ord) * LNG_STEPS[level]
|
|
179
|
+
lat_min += (c_lat.ord - '0'.ord) * LAT_STEPS[level]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Remove false coordinate offsets
|
|
184
|
+
lng_min -= 180.0
|
|
185
|
+
lat_min -= 90.0
|
|
186
|
+
|
|
187
|
+
last_level = pairs - 1
|
|
188
|
+
{
|
|
189
|
+
min_lat: lat_min,
|
|
190
|
+
max_lat: lat_min + LAT_STEPS[last_level],
|
|
191
|
+
min_lng: lng_min,
|
|
192
|
+
max_lng: lng_min + LNG_STEPS[last_level]
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def validate_locator!(locator)
|
|
197
|
+
raise ArgumentError, "Maidenhead locator cannot be empty" if locator.empty?
|
|
198
|
+
raise ArgumentError, "Maidenhead locator must have even length (got #{locator.length})" if locator.length.odd?
|
|
199
|
+
raise ArgumentError, "Maidenhead locator must be 2, 4, 6, or 8 characters (got #{locator.length})" if locator.length > 8
|
|
200
|
+
|
|
201
|
+
normalized = normalize(locator)
|
|
202
|
+
unless valid_characters?(normalized)
|
|
203
|
+
raise ArgumentError, "Invalid Maidenhead locator: #{locator}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
alias_method :validate_code!, :validate_locator!
|
|
208
|
+
|
|
209
|
+
def valid_characters?(locator)
|
|
210
|
+
locator.each_char.with_index.all? do |ch, i|
|
|
211
|
+
pair = i / 2
|
|
212
|
+
case pair
|
|
213
|
+
when 0 then ('A'..'R').include?(ch)
|
|
214
|
+
when 1 then ('0'..'9').include?(ch)
|
|
215
|
+
when 2 then ('a'..'x').include?(ch)
|
|
216
|
+
when 3 then ('0'..'9').include?(ch)
|
|
217
|
+
else false
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
register_hash_system(:ham, self, default_precision: 6)
|
|
223
|
+
Coordinate.register_class(self)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -11,7 +11,7 @@ require_relative '../datum'
|
|
|
11
11
|
require_relative '../geoid_height'
|
|
12
12
|
|
|
13
13
|
module Geodetic
|
|
14
|
-
module
|
|
14
|
+
module Coordinate
|
|
15
15
|
class LLA
|
|
16
16
|
include GeoidHeightSupport
|
|
17
17
|
attr_reader :lat, :lng, :alt
|
|
@@ -254,6 +254,34 @@ module Geodetic
|
|
|
254
254
|
olc_coord.to_lla(datum)
|
|
255
255
|
end
|
|
256
256
|
|
|
257
|
+
def to_georef(precision: 8)
|
|
258
|
+
GEOREF.new(self, precision: precision)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def self.from_georef(georef_coord, datum = WGS84)
|
|
262
|
+
georef_coord.to_lla(datum)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def to_gars(precision: 7)
|
|
266
|
+
GARS.new(self, precision: precision)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def self.from_gars(gars_coord, datum = WGS84)
|
|
270
|
+
gars_coord.to_lla(datum)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def to_h3(precision: 7)
|
|
274
|
+
H3.new(self, precision: precision)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def self.from_h3(h3_coord, datum = WGS84)
|
|
278
|
+
h3_coord.to_lla(datum)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def self.from_lla(lla_coord, datum = WGS84)
|
|
282
|
+
lla_coord
|
|
283
|
+
end
|
|
284
|
+
|
|
257
285
|
def to_s(precision = 6)
|
|
258
286
|
precision = precision.to_i
|
|
259
287
|
if precision == 0
|
|
@@ -296,6 +324,8 @@ module Geodetic
|
|
|
296
324
|
raise ArgumentError, "Latitude must be between -90 and 90 degrees" if @lat < -90 || @lat > 90
|
|
297
325
|
raise ArgumentError, "Longitude must be between -180 and 180 degrees" if @lng < -180 || @lng > 180
|
|
298
326
|
end
|
|
327
|
+
|
|
328
|
+
Coordinate.register_class(self)
|
|
299
329
|
end
|
|
300
330
|
end
|
|
301
331
|
end
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# MGRS is based on UTM but uses a more compact alphanumeric format
|
|
6
6
|
|
|
7
7
|
module Geodetic
|
|
8
|
-
module
|
|
8
|
+
module Coordinate
|
|
9
9
|
class MGRS
|
|
10
10
|
require_relative '../datum'
|
|
11
11
|
|
|
@@ -154,38 +154,6 @@ module Geodetic
|
|
|
154
154
|
from_lla(bng_coord.to_lla(datum), datum, precision)
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
def to_gh36(datum = WGS84, precision: 10)
|
|
158
|
-
GH36.new(to_lla(datum), precision: precision)
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def self.from_gh36(gh36_coord, datum = WGS84, precision = 5)
|
|
162
|
-
from_lla(gh36_coord.to_lla(datum), datum, precision)
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def to_gh(datum = WGS84, precision: 12)
|
|
166
|
-
GH.new(to_lla(datum), precision: precision)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def self.from_gh(gh_coord, datum = WGS84, precision = 5)
|
|
170
|
-
from_lla(gh_coord.to_lla(datum), datum, precision)
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def to_ham(datum = WGS84, precision: 6)
|
|
174
|
-
HAM.new(to_lla(datum), precision: precision)
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def self.from_ham(ham_coord, datum = WGS84, precision = 5)
|
|
178
|
-
from_lla(ham_coord.to_lla(datum), datum, precision)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def to_olc(datum = WGS84, precision: 10)
|
|
182
|
-
OLC.new(to_lla(datum), precision: precision)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def self.from_olc(olc_coord, datum = WGS84, precision = 5)
|
|
186
|
-
from_lla(olc_coord.to_lla(datum), datum, precision)
|
|
187
|
-
end
|
|
188
|
-
|
|
189
157
|
def ==(other)
|
|
190
158
|
return false unless other.is_a?(MGRS)
|
|
191
159
|
|
|
@@ -336,6 +304,8 @@ module Geodetic
|
|
|
336
304
|
index = [0, [index, bands.length - 1].min].max
|
|
337
305
|
bands[index]
|
|
338
306
|
end
|
|
307
|
+
|
|
308
|
+
Coordinate.register_class(self)
|
|
339
309
|
end
|
|
340
310
|
end
|
|
341
311
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative '../datum'
|
|
4
4
|
|
|
5
5
|
module Geodetic
|
|
6
|
-
module
|
|
6
|
+
module Coordinate
|
|
7
7
|
class NED
|
|
8
8
|
attr_reader :n, :e, :d
|
|
9
9
|
alias_method :north, :n
|
|
@@ -175,6 +175,33 @@ module Geodetic
|
|
|
175
175
|
from_lla(lla, reference_lla)
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
+
def to_georef(reference_lla, precision: 8)
|
|
179
|
+
GEOREF.new(to_lla(reference_lla), precision: precision)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def self.from_georef(georef_coord, reference_lla)
|
|
183
|
+
lla = georef_coord.to_lla
|
|
184
|
+
from_lla(lla, reference_lla)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def to_gars(reference_lla, precision: 7)
|
|
188
|
+
GARS.new(to_lla(reference_lla), precision: precision)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.from_gars(gars_coord, reference_lla)
|
|
192
|
+
lla = gars_coord.to_lla
|
|
193
|
+
from_lla(lla, reference_lla)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def to_h3(reference_lla, precision: 7)
|
|
197
|
+
H3.new(to_lla(reference_lla), precision: precision)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def self.from_h3(h3_coord, reference_lla)
|
|
201
|
+
lla = h3_coord.to_lla
|
|
202
|
+
from_lla(lla, reference_lla)
|
|
203
|
+
end
|
|
204
|
+
|
|
178
205
|
def to_s(precision = 2)
|
|
179
206
|
precision = precision.to_i
|
|
180
207
|
if precision == 0
|
|
@@ -268,6 +295,8 @@ module Geodetic
|
|
|
268
295
|
def horizontal_distance_to_origin
|
|
269
296
|
Math.sqrt(@n**2 + @e**2)
|
|
270
297
|
end
|
|
298
|
+
|
|
299
|
+
Coordinate.register_class(self)
|
|
271
300
|
end
|
|
272
301
|
end
|
|
273
302
|
end
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
# OLC.new(lla_coord) # from any coordinate (converts via LLA)
|
|
23
23
|
# OLC.new(utm_coord, precision: 11) # with custom precision
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
module Coordinates
|
|
27
|
-
class OLC
|
|
28
|
-
require_relative '../datum'
|
|
25
|
+
require_relative 'spatial_hash'
|
|
29
26
|
|
|
27
|
+
module Geodetic
|
|
28
|
+
module Coordinate
|
|
29
|
+
class OLC < SpatialHash
|
|
30
30
|
# The 20-character alphabet used by Open Location Code
|
|
31
31
|
ALPHABET = '23456789CFGHJMPQRVWX'
|
|
32
32
|
|
|
@@ -49,9 +49,6 @@ module Geodetic
|
|
|
49
49
|
GRID_ROWS = 5
|
|
50
50
|
GRID_COLS = 4
|
|
51
51
|
|
|
52
|
-
# Default code length (10 significant chars = standard full code, ~14m)
|
|
53
|
-
DEFAULT_LENGTH = 10
|
|
54
|
-
|
|
55
52
|
# Maximum supported code length
|
|
56
53
|
MAX_LENGTH = 15
|
|
57
54
|
|
|
@@ -62,43 +59,12 @@ module Geodetic
|
|
|
62
59
|
LAT_INITIAL = 20.0
|
|
63
60
|
LNG_INITIAL = 20.0
|
|
64
61
|
|
|
65
|
-
# Direction offsets for neighbors: [lat_direction, lng_direction]
|
|
66
|
-
DIRECTIONS = {
|
|
67
|
-
N: [ 1, 0],
|
|
68
|
-
S: [-1, 0],
|
|
69
|
-
E: [ 0, 1],
|
|
70
|
-
W: [ 0, -1],
|
|
71
|
-
NE: [ 1, 1],
|
|
72
|
-
NW: [ 1, -1],
|
|
73
|
-
SE: [-1, 1],
|
|
74
|
-
SW: [-1, -1]
|
|
75
|
-
}.freeze
|
|
76
|
-
|
|
77
62
|
attr_reader :code
|
|
78
63
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
#
|
|
83
|
-
# OLC.new(utm, precision: 11) # from any coordinate with custom precision
|
|
84
|
-
def initialize(source, precision: DEFAULT_LENGTH)
|
|
85
|
-
case source
|
|
86
|
-
when String
|
|
87
|
-
normalized = source.strip.upcase
|
|
88
|
-
validate_code!(normalized)
|
|
89
|
-
@code = normalized
|
|
90
|
-
when LLA
|
|
91
|
-
@code = encode(source.lat, source.lng, precision)
|
|
92
|
-
else
|
|
93
|
-
if source.respond_to?(:to_lla)
|
|
94
|
-
lla = source.to_lla
|
|
95
|
-
@code = encode(lla.lat, lla.lng, precision)
|
|
96
|
-
else
|
|
97
|
-
raise ArgumentError,
|
|
98
|
-
"Expected a plus code String or a coordinate object, got #{source.class}"
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
64
|
+
def self.default_precision = 10
|
|
65
|
+
def self.hash_system_name = :olc
|
|
66
|
+
|
|
67
|
+
# --- Subclass contract implementations ---
|
|
102
68
|
|
|
103
69
|
def precision
|
|
104
70
|
# Number of significant characters (excluding '+' and padding)
|
|
@@ -116,145 +82,6 @@ module Geodetic
|
|
|
116
82
|
end
|
|
117
83
|
end
|
|
118
84
|
|
|
119
|
-
def to_a
|
|
120
|
-
coords = decode(@code)
|
|
121
|
-
[coords[:lat], coords[:lng]]
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def self.from_array(array)
|
|
125
|
-
new(LLA.new(lat: array[0].to_f, lng: array[1].to_f))
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def self.from_string(string)
|
|
129
|
-
new(string.strip)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# Decode to LLA (altitude is always 0.0 since OLC is 2D)
|
|
133
|
-
def to_lla(datum = WGS84)
|
|
134
|
-
coords = decode(@code)
|
|
135
|
-
# Clamp to LLA valid range (midpoint near poles can hit 90.0)
|
|
136
|
-
lat = coords[:lat].clamp(-90.0, 90.0)
|
|
137
|
-
lng = coords[:lng].clamp(-180.0, 180.0)
|
|
138
|
-
LLA.new(lat: lat, lng: lng, alt: 0.0)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def self.from_lla(lla_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
142
|
-
new(lla_coord, precision: precision)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# All other conversions chain through LLA
|
|
146
|
-
|
|
147
|
-
def to_ecef(datum = WGS84)
|
|
148
|
-
to_lla(datum).to_ecef(datum)
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def self.from_ecef(ecef_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
152
|
-
new(ecef_coord, precision: precision)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def to_utm(datum = WGS84)
|
|
156
|
-
to_lla(datum).to_utm(datum)
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def self.from_utm(utm_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
160
|
-
new(utm_coord, precision: precision)
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def to_enu(reference_lla, datum = WGS84)
|
|
164
|
-
to_lla(datum).to_enu(reference_lla)
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def self.from_enu(enu_coord, reference_lla, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
168
|
-
lla_coord = enu_coord.to_lla(reference_lla)
|
|
169
|
-
new(lla_coord, precision: precision)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def to_ned(reference_lla, datum = WGS84)
|
|
173
|
-
to_lla(datum).to_ned(reference_lla)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def self.from_ned(ned_coord, reference_lla, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
177
|
-
lla_coord = ned_coord.to_lla(reference_lla)
|
|
178
|
-
new(lla_coord, precision: precision)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def to_mgrs(datum = WGS84, mgrs_precision = 5)
|
|
182
|
-
MGRS.from_lla(to_lla(datum), datum, mgrs_precision)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def self.from_mgrs(mgrs_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
186
|
-
new(mgrs_coord, precision: precision)
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def to_usng(datum = WGS84, usng_precision = 5)
|
|
190
|
-
USNG.from_lla(to_lla(datum), datum, usng_precision)
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def self.from_usng(usng_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
194
|
-
new(usng_coord, precision: precision)
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def to_web_mercator(datum = WGS84)
|
|
198
|
-
WebMercator.from_lla(to_lla(datum), datum)
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def self.from_web_mercator(wm_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
202
|
-
new(wm_coord, precision: precision)
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def to_ups(datum = WGS84)
|
|
206
|
-
UPS.from_lla(to_lla(datum), datum)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def self.from_ups(ups_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
210
|
-
new(ups_coord, precision: precision)
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def to_state_plane(zone_code, datum = WGS84)
|
|
214
|
-
StatePlane.from_lla(to_lla(datum), zone_code, datum)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def self.from_state_plane(sp_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
218
|
-
new(sp_coord, precision: precision)
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def to_bng(datum = WGS84)
|
|
222
|
-
BNG.from_lla(to_lla(datum), datum)
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def self.from_bng(bng_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
226
|
-
new(bng_coord, precision: precision)
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def to_gh36(datum = WGS84, gh36_precision: 10)
|
|
230
|
-
GH36.new(to_lla(datum), precision: gh36_precision)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def self.from_gh36(gh36_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
234
|
-
new(gh36_coord, precision: precision)
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def to_gh(datum = WGS84, gh_precision: 12)
|
|
238
|
-
GH.new(to_lla(datum), precision: gh_precision)
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
def self.from_gh(gh_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
242
|
-
new(gh_coord, precision: precision)
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def to_ham(datum = WGS84, ham_precision: 6)
|
|
246
|
-
HAM.new(to_lla(datum), precision: ham_precision)
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
def self.from_ham(ham_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
250
|
-
new(ham_coord, precision: precision)
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def ==(other)
|
|
254
|
-
return false unless other.is_a?(OLC)
|
|
255
|
-
@code == other.code
|
|
256
|
-
end
|
|
257
|
-
|
|
258
85
|
def valid?
|
|
259
86
|
return false unless @code.include?(SEPARATOR)
|
|
260
87
|
significant = @code.delete(SEPARATOR).delete(PADDING)
|
|
@@ -262,61 +89,25 @@ module Geodetic
|
|
|
262
89
|
significant.each_char.all? { |c| CHAR_INDEX.key?(c) }
|
|
263
90
|
end
|
|
264
91
|
|
|
265
|
-
|
|
266
|
-
# Keys: :N, :S, :E, :W, :NE, :NW, :SE, :SW
|
|
267
|
-
def neighbors
|
|
268
|
-
bb = decode_bounds(@code)
|
|
269
|
-
lat_step = bb[:max_lat] - bb[:min_lat]
|
|
270
|
-
lng_step = bb[:max_lng] - bb[:min_lng]
|
|
271
|
-
center_lat = (bb[:min_lat] + bb[:max_lat]) / 2.0
|
|
272
|
-
center_lng = (bb[:min_lng] + bb[:max_lng]) / 2.0
|
|
273
|
-
len = precision
|
|
274
|
-
|
|
275
|
-
DIRECTIONS.each_with_object({}) do |(dir, delta), result|
|
|
276
|
-
nlat = center_lat + delta[0] * lat_step
|
|
277
|
-
nlng = center_lng + delta[1] * lng_step
|
|
278
|
-
|
|
279
|
-
# Clamp latitude to valid range
|
|
280
|
-
nlat = nlat.clamp(-89.99999999, 89.99999999)
|
|
92
|
+
protected
|
|
281
93
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
nlng -= 360.0 if nlng > 180.0
|
|
285
|
-
|
|
286
|
-
result[dir] = self.class.new(LLA.new(lat: nlat, lng: nlng), precision: len)
|
|
287
|
-
end
|
|
94
|
+
def normalize(string)
|
|
95
|
+
string.upcase
|
|
288
96
|
end
|
|
289
97
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
bb = decode_bounds(@code)
|
|
293
|
-
nw = LLA.new(lat: bb[:max_lat], lng: bb[:min_lng], alt: 0.0)
|
|
294
|
-
se = LLA.new(lat: bb[:min_lat], lng: bb[:max_lng], alt: 0.0)
|
|
295
|
-
Areas::Rectangle.new(nw: nw, se: se)
|
|
98
|
+
def set_code(value)
|
|
99
|
+
@code = value
|
|
296
100
|
end
|
|
297
101
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
bb = decode_bounds(@code)
|
|
301
|
-
lat_center = (bb[:min_lat] + bb[:max_lat]) / 2.0
|
|
302
|
-
|
|
303
|
-
lat_meters_per_deg = 111_320.0
|
|
304
|
-
lng_meters_per_deg = 111_320.0 * Math.cos(lat_center * Math::PI / 180.0)
|
|
305
|
-
|
|
306
|
-
lat_range = bb[:max_lat] - bb[:min_lat]
|
|
307
|
-
lng_range = bb[:max_lng] - bb[:min_lng]
|
|
308
|
-
|
|
309
|
-
{ lat: lat_range * lat_meters_per_deg, lng: lng_range * lng_meters_per_deg }
|
|
102
|
+
def code_value
|
|
103
|
+
@code
|
|
310
104
|
end
|
|
311
105
|
|
|
312
|
-
# URL-friendly slug (the plus code with '+' is already URL-safe)
|
|
313
|
-
alias_method :to_slug, :to_s
|
|
314
|
-
|
|
315
106
|
private
|
|
316
107
|
|
|
317
108
|
# Encode lat/lng to a plus code of given length.
|
|
318
109
|
# Uses pre-computed place values to avoid floating-point accumulation errors.
|
|
319
|
-
def encode(lat, lng, code_length =
|
|
110
|
+
def encode(lat, lng, code_length = self.class.default_precision)
|
|
320
111
|
code_length = code_length.clamp(2, MAX_LENGTH)
|
|
321
112
|
|
|
322
113
|
# Clamp latitude, normalize longitude
|
|
@@ -474,6 +265,9 @@ module Geodetic
|
|
|
474
265
|
raise ArgumentError, "Invalid plus code character: #{c}" unless CHAR_INDEX.key?(c)
|
|
475
266
|
end
|
|
476
267
|
end
|
|
268
|
+
|
|
269
|
+
register_hash_system(:olc, self, default_precision: 10)
|
|
270
|
+
Coordinate.register_class(self)
|
|
477
271
|
end
|
|
478
272
|
end
|
|
479
273
|
end
|