geo_coord 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/geo/coord.rb ADDED
@@ -0,0 +1,667 @@
1
+ # Geo::Coord is Ruby's library for handling [lat, lng] pairs of
2
+ # geographical coordinates. It provides most of basic functionality
3
+ # you may expect (storing and representing coordinate pair), as well
4
+ # as some geodesy math, like distances and azimuth, and comprehensive
5
+ # parsing/formatting features.
6
+ #
7
+ # See +Geo::Coord+ class docs for full description and usage examples.
8
+ #
9
+ module Geo
10
+ # Geo::Coord is main class of Geo module, representing
11
+ # +(latitude, longitude)+ pair. It stores coordinates in floating-point
12
+ # degrees form, provides access to coordinate components, allows complex
13
+ # formatting and parsing of coordinate pairs and performs geodesy
14
+ # calculations in standard WGS-84 coordinate reference system.
15
+ #
16
+ # == Examples of usage
17
+ #
18
+ # Creation:
19
+ #
20
+ # # From lat/lng pair:
21
+ # g = Geo::Coord.new(50.004444, 36.231389)
22
+ # # => #<Geo::Coord 50.004444,36.231389>
23
+ #
24
+ # # Or using keyword arguments form:
25
+ # g = Geo::Coord.new(lat: 50.004444, lng: 36.231389)
26
+ # # => #<Geo::Coord 50.004444,36.231389>
27
+ #
28
+ # # Keyword arguments also allow creation of Coord from components:
29
+ # g = Geo::Coord.new(latd: 50, latm: 0, lats: 16, lath: 'N', lngd: 36, lngm: 13, lngs: 53, lngh: 'E')
30
+ # # => #<Geo::Coord 50.004444,36.231389>
31
+ #
32
+ # For parsing API responses you'd like to use +from_h+,
33
+ # which accepts String and Symbol keys, any letter case,
34
+ # and knows synonyms (lng/lon/longitude):
35
+ #
36
+ # g = Geo::Coord.from_h('LAT' => 50.004444, 'LON' => 36.231389)
37
+ # # => #<Geo::Coord 50.004444,36.231389>
38
+ #
39
+ # For math, you'd probably like to be able to initialize
40
+ # Coord with radians rather than degrees:
41
+ #
42
+ # g = Geo::Coord.from_rad(0.8727421884291233, 0.6323570306208558)
43
+ # # => #<Geo::Coord 50.004444,36.231389>
44
+ #
45
+ # There's also family of parsing methods, with different applicability:
46
+ #
47
+ # # Tries to parse (lat, lng) pair:
48
+ # g = Geo::Coord.parse_ll('50.004444, 36.231389')
49
+ # # => #<Geo::Coord 50.004444,36.231389>
50
+ #
51
+ # # Tries to parse degrees/minutes/seconds:
52
+ # g = Geo::Coord.parse_dms('50° 0′ 16″ N, 36° 13′ 53″ E')
53
+ # # => #<Geo::Coord 50.004444,36.231389>
54
+ #
55
+ # # Tries to do best guess:
56
+ # g = Geo::Coord.parse('50.004444, 36.231389')
57
+ # # => #<Geo::Coord 50.004444,36.231389>
58
+ # g = Geo::Coord.parse('50° 0′ 16″ N, 36° 13′ 53″ E')
59
+ # # => #<Geo::Coord 50.004444,36.231389>
60
+ #
61
+ # # Allows user to provide pattern (see below for pattern language):
62
+ # g = Geo::Coord.strpcoord('50.004444, 36.231389', '%lat, %lng')
63
+ # # => #<Geo::Coord 50.004444,36.231389>
64
+ #
65
+ # Having Coord object, you can get its properties:
66
+ #
67
+ # g = Geo::Coord.new(50.004444, 36.231389)
68
+ # g.lat # => 50.004444
69
+ # g.latd # => 50 -- latitude degrees
70
+ # g.lath # => N -- latitude hemisphere
71
+ # g.lngh # => E -- longitude hemishpere
72
+ # g.phi # => 0.8727421884291233 -- longitude in radians
73
+ # g.latdms # => [50, 0, 15.998400000011316, "N"]
74
+ # # ...and so on
75
+ #
76
+ # Format and convert it:
77
+ #
78
+ # g.to_s # => "50.004444,36.231389"
79
+ # g.strfcoord('%latd°%latm′%lats″%lath %lngd°%lngm′%lngs″%lngh')
80
+ # # => "50°0′16″N 36°13′53″E"
81
+ #
82
+ # g.to_h(lat: 'LAT', lng: 'LON') # => {'LAT'=>50.004444, 'LON'=>36.231389}
83
+ #
84
+ # Do simple geodesy math:
85
+ #
86
+ # kharkiv = Geo::Coord.new(50.004444, 36.231389)
87
+ # kyiv = Geo::Coord.new(50.45, 30.523333)
88
+ #
89
+ # kharkiv.distance(kyiv) # => 410211.22377421556
90
+ # kharkiv.azimuth(kyiv) # => 279.12614358262067
91
+ # kharkiv.endpoint(410_211, 280) # => #<Geo::Coord 50.505975,30.531283>
92
+ #
93
+ class Coord
94
+ # Latitude, degrees, signed float.
95
+ attr_reader :lat
96
+
97
+ # Longitude, degrees, signed float.
98
+ attr_reader :lng
99
+
100
+ alias latitude lat
101
+ alias longitude lng
102
+ alias lon lng
103
+
104
+ class << self
105
+ # @private
106
+ LAT_KEYS = %i[lat latitude].freeze # :nodoc:
107
+ # @private
108
+ LNG_KEYS = %i[lng lon long longitude].freeze # :nodoc:
109
+
110
+ # Creates Coord from hash, containing latitude and longitude.
111
+ #
112
+ # This methos designed as a way for parsing responses from APIs and
113
+ # databases, so, it tries to be pretty liberal on its input:
114
+ # - accepts String or Symbol keys;
115
+ # - accepts any letter case;
116
+ # - accepts several synonyms for latitude ("lat" and "latitude")
117
+ # and longitude ("lng", "lon", "long", "longitude").
118
+ #
119
+ # g = Geo::Coord.from_h('LAT' => 50.004444, longitude: 36.231389)
120
+ # # => #<Geo::Coord 50.004444,36.231389>
121
+ #
122
+ def from_h(hash)
123
+ h = hash.map { |k, v| [k.to_s.downcase.to_sym, v] }.to_h
124
+ lat = h.values_at(*LAT_KEYS).compact.first or
125
+ raise(ArgumentError, "No latitude value found in #{hash}")
126
+ lng = h.values_at(*LNG_KEYS).compact.first or
127
+ raise(ArgumentError, "No longitude value found in #{hash}")
128
+
129
+ new(lat, lng)
130
+ end
131
+
132
+ # Creates Coord from φ and λ (latitude and longitude in radians).
133
+ #
134
+ # g = Geo::Coord.from_rad(0.8727421884291233, 0.6323570306208558)
135
+ # # => #<Geo::Coord 50.004444,36.231389>
136
+ #
137
+ def from_rad(phi, la)
138
+ new(phi * 180 / Math::PI, la * 180 / Math::PI)
139
+ end
140
+
141
+ # @private
142
+ INT_PATTERN = '[-+]?\d+'.freeze # :nodoc:
143
+ # @private
144
+ UINT_PATTERN = '\d+'.freeze # :nodoc:
145
+ # @private
146
+ FLOAT_PATTERN = '[-+]?\d+(?:\.\d*)?'.freeze # :nodoc:
147
+ # @private
148
+ UFLOAT_PATTERN = '\d+(?:\.\d*)?'.freeze # :nodoc:
149
+
150
+ # @private
151
+ DEG_PATTERN = '[ °d]'.freeze # :nodoc:
152
+ # @private
153
+ MIN_PATTERN = "['′m]".freeze # :nodoc:
154
+ # @private
155
+ SEC_PATTERN = '["″s]'.freeze # :nodoc:
156
+
157
+ # @private
158
+ LL_PATTERN = /^(#{FLOAT_PATTERN})\s*[,; ]\s*(#{FLOAT_PATTERN})$/ # :nodoc:
159
+
160
+ # @private
161
+ DMS_PATTERN = # :nodoc:
162
+ /^\s*
163
+ (?<latd>#{INT_PATTERN})#{DEG_PATTERN}\s*
164
+ ((?<latm>#{UINT_PATTERN})#{MIN_PATTERN}\s*
165
+ ((?<lats>#{UFLOAT_PATTERN})#{SEC_PATTERN}\s*)?)?
166
+ (?<lath>[NS])?
167
+ \s*[,; ]\s*
168
+ (?<lngd>#{INT_PATTERN})#{DEG_PATTERN}\s*
169
+ ((?<lngm>#{UINT_PATTERN})#{MIN_PATTERN}\s*
170
+ ((?<lngs>#{UFLOAT_PATTERN})#{SEC_PATTERN}\s*)?)?
171
+ (?<lngh>[EW])?
172
+ \s*$/x
173
+
174
+ # Parses Coord from string containing float latitude and longitude.
175
+ # Understands several types of separators/spaces between values.
176
+ #
177
+ # Geo::Coord.parse_ll('-50.004444 +36.231389')
178
+ # # => #<Geo::Coord -50.004444,36.231389>
179
+ #
180
+ # If parse_ll is not wise enough to understand your data, consider
181
+ # using ::strpcoord.
182
+ #
183
+ def parse_ll(str)
184
+ str.match(LL_PATTERN) do |m|
185
+ return new(m[1].to_f, m[2].to_f)
186
+ end
187
+ raise ArgumentError, "Can't parse #{str} as lat, lng"
188
+ end
189
+
190
+ # Parses Coord from string containing latitude and longitude in
191
+ # degrees-minutes-seconds-hemisphere format. Understands several
192
+ # types of separators, degree, minute, second signs, as well as
193
+ # explicit hemisphere and no-hemisphere (signed degrees) formats.
194
+ #
195
+ # Geo::Coord.parse_dms('50°0′16″N 36°13′53″E')
196
+ # # => #<Geo::Coord 50.004444,36.231389>
197
+ #
198
+ # If parse_dms is not wise enough to understand your data, consider
199
+ # using ::strpcoord.
200
+ #
201
+ def parse_dms(str)
202
+ str.match(DMS_PATTERN) do |m|
203
+ return new(
204
+ latd: m[:latd], latm: m[:latm], lats: m[:lats], lath: m[:lath],
205
+ lngd: m[:lngd], lngm: m[:lngm], lngs: m[:lngs], lngh: m[:lngh]
206
+ )
207
+ end
208
+ raise ArgumentError, "Can't parse #{str} as degrees-minutes-seconds"
209
+ end
210
+
211
+ # Tries its best to parse Coord from string containing it (in any
212
+ # known form).
213
+ #
214
+ # Geo::Coord.parse('-50.004444 +36.231389')
215
+ # # => #<Geo::Coord -50.004444,36.231389>
216
+ # Geo::Coord.parse('50°0′16″N 36°13′53″E')
217
+ # # => #<Geo::Coord 50.004444,36.231389>
218
+ #
219
+ # If you know exact form in which coordinates are
220
+ # provided, it may be wider to consider parse_ll, parse_dms or
221
+ # even ::strpcoord.
222
+ def parse(str)
223
+ # rubocop:disable Style/RescueModifier
224
+ parse_ll(str) rescue (parse_dms(str) rescue nil)
225
+ # rubocop:enable Style/RescueModifier
226
+ end
227
+
228
+ # @private
229
+ PARSE_PATTERNS = { # :nodoc:
230
+ '%latd' => "(?<latd>#{INT_PATTERN})",
231
+ '%latm' => "(?<latm>#{UINT_PATTERN})",
232
+ '%lats' => "(?<lats>#{UFLOAT_PATTERN})",
233
+ '%lath' => '(?<lath>[NS])',
234
+
235
+ '%lat' => "(?<lat>#{FLOAT_PATTERN})",
236
+
237
+ '%lngd' => "(?<lngd>#{INT_PATTERN})",
238
+ '%lngm' => "(?<lngm>#{UINT_PATTERN})",
239
+ '%lngs' => "(?<lngs>#{UFLOAT_PATTERN})",
240
+ '%lngh' => '(?<lngh>[EW])',
241
+
242
+ '%lng' => "(?<lng>#{FLOAT_PATTERN})"
243
+ }.freeze
244
+
245
+ # Parses +str+ into Coord with provided +pattern+.
246
+ #
247
+ # Example:
248
+ #
249
+ # Geo::Coord.strpcoord('-50.004444/+36.231389', '%lat/%lng')
250
+ # # => #<Geo::Coord -50.004444,36.231389>
251
+ #
252
+ # List of parsing flags:
253
+ #
254
+ # %lat :: Full latitude, float
255
+ # %latd :: Latitude degrees, integer, may be signed (instead of
256
+ # providing hemisphere info
257
+ # %latm :: Latitude minutes, integer, unsigned
258
+ # %lats :: Latitude seconds, float, unsigned
259
+ # %lath :: Latitude hemisphere, "N" or "S"
260
+ # %lng :: Full longitude, float
261
+ # %lngd :: Longitude degrees, integer, may be signed (instead of
262
+ # providing hemisphere info
263
+ # %lngm :: Longitude minutes, integer, unsigned
264
+ # %lngs :: Longitude seconds, float, unsigned
265
+ # %lngh :: Longitude hemisphere, "N" or "S"
266
+ #
267
+ def strpcoord(str, pattern)
268
+ pattern = PARSE_PATTERNS.inject(pattern) do |memo, (pfrom, pto)|
269
+ memo.gsub(pfrom, pto)
270
+ end
271
+ if (m = Regexp.new('^' + pattern).match(str))
272
+ h = m.names.map { |n| [n.to_sym, _extract_match(m, n)] }.to_h
273
+ new(h)
274
+ else
275
+ raise ArgumentError, "Coordinates str #{str} can't be parsed by pattern #{pattern}"
276
+ end
277
+ end
278
+
279
+ private
280
+
281
+ def _extract_match(match, name)
282
+ return nil unless match[name]
283
+ val = match[name]
284
+ name.end_with?('h') ? val : val.to_f
285
+ end
286
+ end
287
+
288
+ # Creates Coord object.
289
+ #
290
+ # There are three forms of usage:
291
+ # - <tt>Coord.new(lat, lng)</tt> with +lat+ and +lng+ being floats;
292
+ # - <tt>Coord.new(lat: lat, lng: lng)</tt> -- same as above, but
293
+ # with keyword arguments;
294
+ # - <tt>Geo::Coord.new(latd: 50, latm: 0, lats: 16, lath: 'N', lngd: 36, lngm: 13, lngs: 53, lngh: 'E')</tt> -- for
295
+ # cases when you have coordinates components already parsed;
296
+ #
297
+ # In keyword arguments form, any argument can be omitted and will be
298
+ # replaced with 0. But you can't mix, for example, "whole" latitude
299
+ # key +lat+ and partial longitude keys +lngd+, +lngm+ and so on.
300
+ #
301
+ # g = Geo::Coord.new(50.004444, 36.231389)
302
+ # # => #<Geo::Coord 50.004444,36.231389>
303
+ #
304
+ # # Or using keyword arguments form:
305
+ # g = Geo::Coord.new(lat: 50.004444, lng: 36.231389)
306
+ # # => #<Geo::Coord 50.004444,36.231389>
307
+ #
308
+ # # Keyword arguments also allow creation of Coord from components:
309
+ # g = Geo::Coord.new(latd: 50, latm: 0, lats: 16, lath: 'N', lngd: 36, lngm: 13, lngs: 53, lngh: 'E')
310
+ # # => #<Geo::Coord 50.004444,36.231389>
311
+ #
312
+ # # Providing defaults:
313
+ # g = Geo::Coord.new(lat: 50.004444)
314
+ # # => #<Geo::Coord 50.004444,0.000000>
315
+ #
316
+ def initialize(lat = nil, lng = nil, **opts)
317
+ @globe = Globes::Earth.instance
318
+
319
+ case
320
+ when lat && lng
321
+ _init(lat, lng)
322
+ when opts.key?(:lat) || opts.key?(:lng)
323
+ _init(opts[:lat], opts[:lng])
324
+ when opts.key?(:latd) || opts.key?(:lngd)
325
+ _init_dms(opts)
326
+ else
327
+ raise ArgumentError, "Can't create #{self.class} by provided data"
328
+ end
329
+ end
330
+
331
+ # Compares with +other+.
332
+ #
333
+ # Note, that comparison includes comparing floating point values,
334
+ # so, when two "almost exactly same" coord pairs are calculated using
335
+ # different methods, you can rarely expect them to be _exactly_ equal.
336
+ #
337
+ # Also, note that no greater/lower relation is defined on Coord, so,
338
+ # for example, you can't just sort an array of Coord.
339
+ def ==(other)
340
+ other.is_a?(self.class) && other.lat == lat && other.lng == lng
341
+ end
342
+
343
+ # Returns latitude degrees (unsigned integer).
344
+ def latd
345
+ lat.abs.to_i
346
+ end
347
+
348
+ # Returns latitude minutes (unsigned integer).
349
+ def latm
350
+ (lat.abs * 60).to_i % 60
351
+ end
352
+
353
+ # Returns latitude seconds (unsigned float).
354
+ def lats
355
+ (lat.abs * 3600) % 3600
356
+ end
357
+
358
+ # Returns latitude hemisphere (upcase letter 'N' or 'S').
359
+ def lath
360
+ lat > 0 ? 'N' : 'S'
361
+ end
362
+
363
+ # Returns longitude degrees (unsigned integer).
364
+ def lngd
365
+ lng.abs.to_i
366
+ end
367
+
368
+ # Returns longitude minutes (unsigned integer).
369
+ def lngm
370
+ (lng.abs * 60).to_i % 60
371
+ end
372
+
373
+ # Returns longitude seconds (unsigned float).
374
+ def lngs
375
+ (lng.abs * 3600) % 60
376
+ end
377
+
378
+ # Returns longitude hemisphere (upcase letter 'E' or 'W').
379
+ def lngh
380
+ lng > 0 ? 'E' : 'W'
381
+ end
382
+
383
+ # Returns latitude components: degrees, minutes, seconds and optionally
384
+ # a hemisphere:
385
+ #
386
+ # # Nothern hemisphere:
387
+ # g = Geo::Coord.new(50.004444, 36.231389)
388
+ #
389
+ # g.latdms # => [50, 0, 15.998400000011316, "N"]
390
+ # g.latdms(true) # => [50, 0, 15.998400000011316]
391
+ #
392
+ # # Southern hemisphere:
393
+ # g = Geo::Coord.new(-50.004444, 36.231389)
394
+ #
395
+ # g.latdms # => [50, 0, 15.998400000011316, "S"]
396
+ # g.latdms(true) # => [-50, 0, 15.998400000011316]
397
+ #
398
+ def latdms(nohemisphere = false)
399
+ nohemisphere ? [latsign * latd, latm, lats] : [latd, latm, lats, lath]
400
+ end
401
+
402
+ # Returns longitude components: degrees, minutes, seconds and optionally
403
+ # a hemisphere:
404
+ #
405
+ # # Eastern hemisphere:
406
+ # g = Geo::Coord.new(50.004444, 36.231389)
407
+ #
408
+ # g.lngdms # => [36, 13, 53.00040000000445, "E"]
409
+ # g.lngdms(true) # => [36, 13, 53.00040000000445]
410
+ #
411
+ # # Western hemisphere:
412
+ # g = Geo::Coord.new(50.004444, 36.231389)
413
+ #
414
+ # g.lngdms # => [36, 13, 53.00040000000445, "E"]
415
+ # g.lngdms(true) # => [-36, 13, 53.00040000000445]
416
+ #
417
+ def lngdms(nohemisphere = false)
418
+ nohemisphere ? [lngsign * lngd, lngm, lngs] : [lngd, lngm, lngs, lngh]
419
+ end
420
+
421
+ # Latitude in radians. Geodesy formulae almost alwayse use greek Phi
422
+ # for it.
423
+ def phi
424
+ deg2rad(lat)
425
+ end
426
+
427
+ alias φ phi
428
+
429
+ # Latitude in radians. Geodesy formulae almost alwayse use greek Lambda
430
+ # for it; we are using shorter name for not confuse with Ruby's +lambda+
431
+ # keyword.
432
+ def la
433
+ deg2rad(lng)
434
+ end
435
+
436
+ alias λ la
437
+
438
+ # Returns a string represent coordinates object.
439
+ #
440
+ # g.inspect # => "#<Geo::Coord 50.004444,36.231389>"
441
+ #
442
+ def inspect
443
+ '#<%s %s>' % [self.class.name, to_s]
444
+ end
445
+
446
+ # Returns a string representing coordinates.
447
+ #
448
+ # g.to_s # => "50.004444,36.231389"
449
+ #
450
+ def to_s
451
+ '%f,%f' % [lat, lng]
452
+ end
453
+
454
+ # Returns a two-element array of latitude and longitude.
455
+ #
456
+ # g.to_a # => [50.004444, 36.231389]
457
+ #
458
+ def to_a
459
+ [lat, lng]
460
+ end
461
+
462
+ # Returns hash of latitude and longitude. You can provide your keys
463
+ # if you want:
464
+ #
465
+ # g.to_h
466
+ # # => {:lat=>50.004444, :lng=>36.231389}
467
+ # g.to_h(lat: 'LAT', lng: 'LNG')
468
+ # # => {'LAT'=>50.004444, 'LNG'=>36.231389}
469
+ #
470
+ def to_h(lat: :lat, lng: :lng)
471
+ {lat => self.lat, lng => self.lng}
472
+ end
473
+
474
+ # @private
475
+ INTFLAGS = '\+'.freeze # :nodoc:
476
+ # @private
477
+ FLOATUFLAGS = /\.0\d+/ # :nodoc:
478
+ # @private
479
+ FLOATFLAGS = /\+?#{FLOATUFLAGS}?/ # :nodoc:
480
+
481
+ # @private
482
+ DIRECTIVES = { # :nodoc:
483
+ /%(#{INTFLAGS})?latds/ => proc { |m| "%<latds>#{m[1]}i" },
484
+ '%latd' => '%<latd>i',
485
+ '%latm' => '%<latm>i',
486
+ /%(#{FLOATUFLAGS})?lats/ => proc { |m| "%<lats>#{m[1] || '.0'}f" },
487
+ '%lath' => '%<lath>s',
488
+ /%(#{FLOATFLAGS})?lat/ => proc { |m| "%<lat>#{m[1]}f" },
489
+
490
+ /%(#{INTFLAGS})?lngds/ => proc { |m| "%<lngds>#{m[1]}i" },
491
+ '%lngd' => '%<lngd>i',
492
+ '%lngm' => '%<lngm>i',
493
+ /%(#{FLOATUFLAGS})?lngs/ => proc { |m| "%<lngs>#{m[1] || '.0'}f" },
494
+ '%lngh' => '%<lngh>s',
495
+ /%(#{FLOATFLAGS})?lng/ => proc { |m| "%<lng>#{m[1]}f" }
496
+ }.freeze
497
+
498
+ # Formats coordinates according to directives in +formatstr+.
499
+ #
500
+ # Each directive starts with +%+ and can contain some modifiers before
501
+ # its name.
502
+ #
503
+ # Acceptable modifiers:
504
+ # - unsigned integers: none;
505
+ # - signed integers: <tt>+</tt> for mandatory sign printing;
506
+ # - floats: same as integers and number of digits modifier, like
507
+ # <tt>.03</tt>.
508
+ #
509
+ # List of directives:
510
+ #
511
+ # %lat :: Full latitude, floating point, signed
512
+ # %latds :: Latitude degrees, integer, signed
513
+ # %latd :: Latitude degrees, integer, unsigned
514
+ # %latm :: Latitude minutes, integer, unsigned
515
+ # %lats :: Latitude seconds, floating point, unsigned
516
+ # %lath :: Latitude hemisphere, "N" or "S"
517
+ # %lng :: Full longitude, floating point, signed
518
+ # %lngds :: Longitude degrees, integer, signed
519
+ # %lngd :: Longitude degrees, integer, unsigned
520
+ # %lngm :: Longitude minutes, integer, unsigned
521
+ # %lngs :: Longitude seconds, floating point, unsigned
522
+ # %lngh :: Longitude hemisphere, "E" or "W"
523
+ #
524
+ # Examples:
525
+ #
526
+ # g = Geo::Coord.new(50.004444, 36.231389)
527
+ # g.strfcoord('%+lat, %+lng')
528
+ # # => "+50.004444, +36.231389"
529
+ # g.strfcoord("%latd°%latm'%lath -- %lngd°%lngm'%lngh")
530
+ # # => "50°0'N -- 36°13'E"
531
+ #
532
+ def strfcoord(formatstr)
533
+ h = full_hash
534
+
535
+ DIRECTIVES.reduce(formatstr) do |memo, (from, to)|
536
+ memo.gsub(from) do
537
+ to = to.call(Regexp.last_match) if to.is_a?(Proc)
538
+ to % h
539
+ end
540
+ end
541
+ end
542
+
543
+ # Calculates distance to +other+ in SI units (meters). Vincenty
544
+ # formula is used.
545
+ #
546
+ # kharkiv = Geo::Coord.new(50.004444, 36.231389)
547
+ # kyiv = Geo::Coord.new(50.45, 30.523333)
548
+ #
549
+ # kharkiv.distance(kyiv) # => 410211.22377421556
550
+ #
551
+ def distance(other)
552
+ @globe.inverse(phi, la, other.phi, other.la).first
553
+ end
554
+
555
+ # Calculates azimuth (direction) to +other+ in degrees. Vincenty
556
+ # formula is used.
557
+ #
558
+ # kharkiv = Geo::Coord.new(50.004444, 36.231389)
559
+ # kyiv = Geo::Coord.new(50.45, 30.523333)
560
+ #
561
+ # kharkiv.azimuth(kyiv) # => 279.12614358262067
562
+ #
563
+ def azimuth(other)
564
+ rad2deg(@globe.inverse(phi, la, other.phi, other.la).last)
565
+ end
566
+
567
+ # Given distance in meters and azimuth in degrees, calculates other
568
+ # point on globe being on that direction/azimuth from current.
569
+ # Vincenty formula is used.
570
+ #
571
+ # kharkiv = Geo::Coord.new(50.004444, 36.231389)
572
+ # kharkiv.endpoint(410_211, 280)
573
+ # # => #<Geo::Coord 50.505975,30.531283>
574
+ #
575
+ def endpoint(distance, azimuth)
576
+ phi2, la2 = @globe.direct(phi, la, distance, deg2rad(azimuth))
577
+ Coord.from_rad(phi2, la2)
578
+ end
579
+
580
+ private
581
+
582
+ def _init(lat, lng)
583
+ lat = lat.to_f
584
+ lng = lng.to_f
585
+
586
+ unless (-90..90).cover?(lat)
587
+ raise ArgumentError, "Expected latitude to be between -90 and 90, #{lat} received"
588
+ end
589
+
590
+ unless (-180..180).cover?(lng)
591
+ raise ArgumentError, "Expected longitude to be between -180 and 180, #{lng} received"
592
+ end
593
+
594
+ @lat = lat
595
+ @lng = lng
596
+ end
597
+
598
+ # @private
599
+ LATH = {'N' => 1, 'S' => -1}.freeze # :nodoc:
600
+ # @private
601
+ LNGH = {'E' => 1, 'W' => -1}.freeze # :nodoc:
602
+
603
+ def _init_dms(opts) # rubocop:disable Metrics/AbcSize
604
+ lat = (
605
+ opts[:latd].to_i +
606
+ opts[:latm].to_i / 60.0 +
607
+ opts[:lats].to_i / 3600.0
608
+ ) * guess_sign(opts[:lath], LATH)
609
+ lng = (
610
+ opts[:lngd].to_i +
611
+ opts[:lngm].to_i / 60.0 +
612
+ opts[:lngs].to_i / 3600.0
613
+ ) * guess_sign(opts[:lngh], LNGH)
614
+ _init(lat, lng)
615
+ end
616
+
617
+ def guess_sign(h, hemishperes)
618
+ return 1 unless h
619
+ hemishperes[h] or
620
+ raise ArgumentError, "Unidentified hemisphere: #{h}"
621
+ end
622
+
623
+ def latsign
624
+ lat <=> 0
625
+ end
626
+
627
+ def lngsign
628
+ lng <=> 0
629
+ end
630
+
631
+ def latds
632
+ lat.to_i
633
+ end
634
+
635
+ def lngds
636
+ lng.to_i
637
+ end
638
+
639
+ def full_hash
640
+ {
641
+ latd: latd,
642
+ latds: latds,
643
+ latm: latm,
644
+ lats: lats,
645
+ lath: lath,
646
+ lat: lat,
647
+
648
+ lngd: lngd,
649
+ lngds: lngds,
650
+ lngm: lngm,
651
+ lngs: lngs,
652
+ lngh: lngh,
653
+ lng: lng
654
+ }
655
+ end
656
+
657
+ def rad2deg(r)
658
+ (r / Math::PI * 180 + 360) % 360
659
+ end
660
+
661
+ def deg2rad(d)
662
+ d * Math::PI / 180
663
+ end
664
+ end
665
+ end
666
+
667
+ require_relative 'coord/globes'