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