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,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 Coordinates
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 Coordinates
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 Coordinates
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
- module Geodetic
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
- # Create an OLC from a plus code string or any coordinate object.
80
- #
81
- # OLC.new("849VCWC8+R9") # from plus code string
82
- # OLC.new(lla) # from LLA coordinate
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
- # Returns all 8 neighboring cells as OLC instances
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
- # Wrap longitude
283
- nlng += 360.0 if nlng < -180.0
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
- # Returns the plus code cell as an Areas::Rectangle
291
- def to_area
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
- # Returns precision in meters as {lat:, lng:}
299
- def precision_in_meters
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 = DEFAULT_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