Class: Geo::Coord

Inherits:
Object
  • Object
show all
Defined in:
lib/geo/coord.rb,
lib/geo/coord/version.rb

Overview

Geo::Coord is main class of Geo module, representing (latitude, longitude) pair. It stores coordinates in floating-point degrees form, provides access to coordinate components, allows complex formatting and parsing of coordinate pairs and performs geodesy calculations in standard WGS-84 coordinate reference system.

Examples of usage

Creation:

# From lat/lng pair:
g = Geo::Coord.new(50.004444, 36.231389)
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

# Or using keyword arguments form:
g = Geo::Coord.new(lat: 50.004444, lng: 36.231389)
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

# Keyword arguments also allow creation of Coord from components:
g = Geo::Coord.new(latd: 50, latm: 0, lats: 16, lath: 'N', lngd: 36, lngm: 13, lngs: 53, lngh: 'E')
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

For parsing API responses you’d like to use from_h, which accepts String and Symbol keys, any letter case, and knows synonyms (lng/lon/longitude):

g = Geo::Coord.from_h('LAT' => 50.004444, 'LON' => 36.231389)
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

For math, you’d probably like to be able to initialize Coord with radians rather than degrees:

g = Geo::Coord.from_rad(0.8727421884291233, 0.6323570306208558)
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

There’s also family of parsing methods, with different applicability:

# Tries to parse (lat, lng) pair:
g = Geo::Coord.parse_ll('50.004444, 36.231389')
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

# Tries to parse degrees/minutes/seconds:
g = Geo::Coord.parse_dms('50° 0′ 16″ N, 36° 13′ 53″ E')
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

# Tries to do best guess:
g = Geo::Coord.parse('50.004444, 36.231389')
# => #<Geo::Coord 50°0'16"N 36°13'53"E>
g = Geo::Coord.parse('50° 0′ 16″ N, 36° 13′ 53″ E')
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

# Allows user to provide pattern:
g = Geo::Coord.strpcoord('50.004444, 36.231389', '%lat, %lng')
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

Having Coord object, you can get its properties:

g = Geo::Coord.new(50.004444, 36.231389)
g.lat # => 50.004444
g.latd # => 50 -- latitude degrees
g.lath # => N -- latitude hemisphere
g.lngh # => E -- longitude hemishpere
g.phi  # => 0.8727421884291233 -- longitude in radians
g.latdms # => [50, 0, 15.998400000011316, "N"]
# ...and so on

Format and convert it:

g.to_s # => "50.004444,36.231389"
g.strfcoord('%latd°%latm′%lats″%lath %lngd°%lngm′%lngs″%lngh')
# => "50°0′16″N 36°13′53″E"

g.to_h(lat: 'LAT', lng: 'LON') # => {'LAT'=>50.004444, 'LON'=>36.231389}

Do simple geodesy math:

kharkiv = Geo::Coord.new(50.004444, 36.231389)
kyiv = Geo::Coord.new(50.45, 30.523333)

kharkiv.distance(kyiv) # => 410211.22377421556
kharkiv.azimuth(kyiv) # => 279.12614358262067
kharkiv.endpoint(410_211, 280) # => #<Geo::Coord 50.505975,30.531283>

Constant Summary collapse

VERSION =
'0.2.0'

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(lat = nil, lng = nil, **kwargs) ⇒ Coord

Creates Coord object.

There are three forms of usage:

  • Coord.new(lat, lng) with lat and lng being floats;

  • Coord.new(lat: lat, lng: lng) – same as above, but with keyword arguments;

  • Geo::Coord.new(latd: 50, latm: 0, lats: 16, lath: 'N', lngd: 36, lngm: 13, lngs: 53, lngh: 'E') – for cases when you have coordinates components already parsed;

In keyword arguments form, any argument can be omitted and will be replaced with 0. But you can’t mix, for example, “whole” latitude key lat and partial longitude keys lngd, lngm and so on.

g = Geo::Coord.new(50.004444, 36.231389)
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

# Or using keyword arguments form:
g = Geo::Coord.new(lat: 50.004444, lng: 36.231389)
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

# Keyword arguments also allow creation of Coord from components:
g = Geo::Coord.new(latd: 50, latm: 0, lats: 16, lath: 'N', lngd: 36, lngm: 13, lngs: 53, lngh: 'E')
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

# Providing defaults:
g = Geo::Coord.new(lat: 50.004444)
# => #<Geo::Coord 50°0'16"N 0°0'0"W>


327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/geo/coord.rb', line 327

def initialize(lat = nil, lng = nil, **kwargs) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
  @globe = Globes::Earth.instance

  # It is probably can be clearer with Ruby 2.7+ pattern-matching... or with less permissive
  # protocol :)
  kwargs = lat if lat.is_a?(Hash) && kwargs.empty? # Ruby 3.0

  case
  when lat && lng
    _init(lat, lng)
  when kwargs.key?(:lat) || kwargs.key?(:lng)
    _init(*kwargs.values_at(:lat, :lng))
  when kwargs.key?(:latd) || kwargs.key?(:lngd)
    _init_dms(**kwargs)
  else
    raise ArgumentError, "Can't create #{self.class} by provided data: (#{lat}, #{lng}, **#{kwargs}"
  end
end

Instance Attribute Details

#latObject (readonly) Also known as: latitude

Latitude, degrees, signed float.



100
101
102
# File 'lib/geo/coord.rb', line 100

def lat
  @lat
end

#lngObject (readonly) Also known as: longitude, lon

Longitude, degrees, signed float.



103
104
105
# File 'lib/geo/coord.rb', line 103

def lng
  @lng
end

Class Method Details

.from_h(hash) ⇒ Object

Creates Coord from hash, containing latitude and longitude.

This methos designed as a way for parsing responses from APIs and databases, so, it tries to be pretty liberal on its input:

  • accepts String or Symbol keys;

  • accepts any letter case;

  • accepts several synonyms for latitude (“lat” and “latitude”) and longitude (“lng”, “lon”, “long”, “longitude”).

    g = Geo::Coord.from_h('LAT' => 50.004444, longitude: 36.231389)
    # => #<Geo::Coord 50°0'16"N 36°13'53"E>
    


127
128
129
130
131
132
133
134
135
# File 'lib/geo/coord.rb', line 127

def from_h(hash)
  h = hash.map { |k, v| [k.to_s.downcase.to_sym, v] }.to_h
  lat = h.values_at(*LAT_KEYS).compact.first or
    raise(ArgumentError, "No latitude value found in #{hash}")
  lng = h.values_at(*LNG_KEYS).compact.first or
    raise(ArgumentError, "No longitude value found in #{hash}")

  new(lat, lng)
end

.from_rad(phi, la) ⇒ Object

Creates Coord from φ and λ (latitude and longitude in radians).

g = Geo::Coord.from_rad(0.8727421884291233, 0.6323570306208558)
# => #<Geo::Coord 50°0'16"N 36°13'53"E>


142
143
144
# File 'lib/geo/coord.rb', line 142

def from_rad(phi, la)
  new(phi * 180 / Math::PI, la * 180 / Math::PI)
end

.parse(str) ⇒ Object

Tries its best to parse Coord from string containing it (in any known form).

Geo::Coord.parse('-50.004444 +36.231389')
# => #<Geo::Coord 50°0'16"S 36°13'53"E>
Geo::Coord.parse('50°0′16″N 36°13′53″E')
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

If you know exact form in which coordinates are provided, it may be wider to consider parse_ll, parse_dms or even ::strpcoord.



234
235
236
237
238
# File 'lib/geo/coord.rb', line 234

def parse(str)
  # rubocop:disable Style/RescueModifier
  parse_ll(str) rescue (parse_dms(str) rescue nil)
  # rubocop:enable Style/RescueModifier
end

.parse_dms(str) ⇒ Object

Parses Coord from string containing latitude and longitude in degrees-minutes-seconds-hemisphere format. Understands several types of separators, degree, minute, second signs, as well as explicit hemisphere and no-hemisphere (signed degrees) formats.

Geo::Coord.parse_dms('50°0′16″N 36°13′53″E')
# => #<Geo::Coord 50°0'16"N 36°13'53"E>

If parse_dms is not wise enough to understand your data, consider using ::strpcoord.

Raises:

  • (ArgumentError)


213
214
215
216
217
218
219
220
221
# File 'lib/geo/coord.rb', line 213

def parse_dms(str)
  str.match(DMS_PATTERN) do |m|
    return new(
      latd: m[:latd], latm: m[:latm], lats: m[:lats], lath: m[:lath],
      lngd: m[:lngd], lngm: m[:lngm], lngs: m[:lngs], lngh: m[:lngh]
    )
  end
  raise ArgumentError, "Can't parse #{str} as degrees-minutes-seconds"
end

.parse_ll(str) ⇒ Object

Parses Coord from string containing float latitude and longitude. Understands several types of separators/spaces between values.

Geo::Coord.parse_ll('-50.004444 +36.231389')
# => #<Geo::Coord 50°0'16"S 36°13'53"E>

If parse_ll is not wise enough to understand your data, consider using ::strpcoord.

Raises:

  • (ArgumentError)


195
196
197
198
199
200
# File 'lib/geo/coord.rb', line 195

def parse_ll(str)
  str.match(LL_PATTERN) do |m|
    return new(m[1].to_f, m[2].to_f)
  end
  raise ArgumentError, "Can't parse #{str} as lat, lng"
end

.strpcoord(str, pattern) ⇒ Object

Parses str into Coord with provided pattern.

Example:

Geo::Coord.strpcoord('-50.004444/+36.231389', '%lat/%lng')
# => #<Geo::Coord -50.004444,36.231389>

List of parsing flags:

%lat

Full latitude, float

%latd

Latitude degrees, integer, may be signed (instead of providing hemisphere info

%latm

Latitude minutes, integer, unsigned

%lats

Latitude seconds, float, unsigned

%lath

Latitude hemisphere, “N” or “S”

%lng

Full longitude, float

%lngd

Longitude degrees, integer, may be signed (instead of providing hemisphere info

%lngm

Longitude minutes, integer, unsigned

%lngs

Longitude seconds, float, unsigned

%lngh

Longitude hemisphere, “N” or “S”

Raises:

  • (ArgumentError)


279
280
281
282
283
284
285
286
287
# File 'lib/geo/coord.rb', line 279

def strpcoord(str, pattern)
  pattern = PARSE_PATTERNS.inject(pattern) do |memo, (pfrom, pto)|
    memo.gsub(pfrom, pto)
  end
  match = Regexp.new("^#{pattern}").match(str)
  raise ArgumentError, "Coordinates str #{str} can't be parsed by pattern #{pattern}" unless match

  new(**match.names.map { |n| [n.to_sym, _extract_match(match, n)] }.to_h)
end

Instance Method Details

#==(other) ⇒ Object

Compares with other.

Note that no greater/lower relation is defined on Coord, so, for example, you can’t just sort an array of Coord.



350
351
352
# File 'lib/geo/coord.rb', line 350

def ==(other)
  other.is_a?(self.class) && other.lat == lat && other.lng == lng
end

#azimuth(other) ⇒ Object

Calculates azimuth (direction) to other in degrees. Vincenty formula is used.

kharkiv = Geo::Coord.new(50.004444, 36.231389)
kyiv = Geo::Coord.new(50.45, 30.523333)

kharkiv.azimuth(kyiv) # => 279.12614358262067


594
595
596
# File 'lib/geo/coord.rb', line 594

def azimuth(other)
  rad2deg(@globe.inverse(phi, la, other.phi, other.la).last)
end

#distance(other) ⇒ Object

Calculates distance to other in SI units (meters). Vincenty formula is used.

kharkiv = Geo::Coord.new(50.004444, 36.231389)
kyiv = Geo::Coord.new(50.45, 30.523333)

kharkiv.distance(kyiv) # => 410211.22377421556


582
583
584
# File 'lib/geo/coord.rb', line 582

def distance(other)
  @globe.inverse(phi, la, other.phi, other.la).first
end

#endpoint(distance, azimuth) ⇒ Object

Given distance in meters and azimuth in degrees, calculates other point on globe being on that direction/azimuth from current. Vincenty formula is used.

kharkiv = Geo::Coord.new(50.004444, 36.231389)
kharkiv.endpoint(410_211, 280)
# => #<Geo::Coord 50°30'22"N 30°31'53"E>


606
607
608
609
# File 'lib/geo/coord.rb', line 606

def endpoint(distance, azimuth)
  phi2, la2 = @globe.direct(phi, la, distance, deg2rad(azimuth))
  Coord.from_rad(phi2, la2)
end

#inspectObject

Returns a string represent coordinates object.

g.inspect  # => "#<Geo::Coord 50.004444,36.231389>"


453
454
455
# File 'lib/geo/coord.rb', line 453

def inspect
  strfcoord(%{#<#{self.class.name} %latd°%latm'%lats"%lath %lngd°%lngm'%lngs"%lngh>})
end

#laObject Also known as: λ

Latitude in radians. Geodesy formulae almost alwayse use greek Lambda for it; we are using shorter name for not confuse with Ruby’s lambda keyword.



443
444
445
# File 'lib/geo/coord.rb', line 443

def la
  deg2rad(lng)
end

#latdObject

Returns latitude degrees (unsigned integer).



355
356
357
# File 'lib/geo/coord.rb', line 355

def latd
  lat.abs.to_i
end

#latdms(hemisphere: true) ⇒ Object

Returns latitude components: degrees, minutes, seconds and optionally a hemisphere:

# Nothern hemisphere:
g = Geo::Coord.new(50.004444, 36.231389)

g.latdms                     # => [50, 0, 15.9984, "N"]
g.latdms(hemisphere: false)  # => [50, 0, 15.9984]

# Southern hemisphere:
g = Geo::Coord.new(-50.004444, 36.231389)

g.latdms                     # => [50, 0, 15.9984, "S"]
g.latdms(hemisphere: false)  # => [-50, 0, 15.9984]


409
410
411
# File 'lib/geo/coord.rb', line 409

def latdms(hemisphere: true)
  hemisphere ? [latd, latm, lats, lath] : [latsign * latd, latm, lats]
end

#lathObject

Returns latitude hemisphere (upcase letter ‘N’ or ‘S’).



370
371
372
# File 'lib/geo/coord.rb', line 370

def lath
  lat.positive? ? 'N' : 'S'
end

#latlngObject

Returns a two-element array of latitude and longitude.

g.latlng   # => [50.004444, 36.231389]


471
472
473
# File 'lib/geo/coord.rb', line 471

def latlng
  [lat, lng]
end

#latmObject

Returns latitude minutes (unsigned integer).



360
361
362
# File 'lib/geo/coord.rb', line 360

def latm
  (lat.abs * 60).to_i % 60
end

#latsObject

Returns latitude seconds (unsigned float).



365
366
367
# File 'lib/geo/coord.rb', line 365

def lats
  (lat.abs * 3600) % 60
end

#lngdObject

Returns longitude degrees (unsigned integer).



375
376
377
# File 'lib/geo/coord.rb', line 375

def lngd
  lng.abs.to_i
end

#lngdms(hemisphere: true) ⇒ Object

Returns longitude components: degrees, minutes, seconds and optionally a hemisphere:

# Eastern hemisphere:
g = Geo::Coord.new(50.004444, 36.231389)

g.lngdms                     # => [36, 13, 53.0004, "E"]
g.lngdms(hemisphere: false)  # => [36, 13, 53.0004]

# Western hemisphere:
g = Geo::Coord.new(50.004444, 36.231389)

g.lngdms                     # => [36, 13, 53.0004, "E"]
g.lngdms(hemisphere: false)  # => [-36, 13, 53.0004]


428
429
430
# File 'lib/geo/coord.rb', line 428

def lngdms(hemisphere: true)
  hemisphere ? [lngd, lngm, lngs, lngh] : [lngsign * lngd, lngm, lngs]
end

#lnghObject

Returns longitude hemisphere (upcase letter ‘E’ or ‘W’).



390
391
392
# File 'lib/geo/coord.rb', line 390

def lngh
  lng.positive? ? 'E' : 'W'
end

#lnglatObject

Returns a two-element array of longitude and latitude (reverse order to latlng).

g.lnglat   # => [36.231389, 50.004444]


479
480
481
# File 'lib/geo/coord.rb', line 479

def lnglat
  [lng, lat]
end

#lngmObject

Returns longitude minutes (unsigned integer).



380
381
382
# File 'lib/geo/coord.rb', line 380

def lngm
  (lng.abs * 60).to_i % 60
end

#lngsObject

Returns longitude seconds (unsigned float).



385
386
387
# File 'lib/geo/coord.rb', line 385

def lngs
  (lng.abs * 3600) % 60
end

#phiObject Also known as: φ

Latitude in radians. Geodesy formulae almost alwayse use greek Phi for it.



434
435
436
# File 'lib/geo/coord.rb', line 434

def phi
  deg2rad(lat)
end

#strfcoord(formatstr) ⇒ Object

Formats coordinates according to directives in formatstr.

Each directive starts with % and can contain some modifiers before its name.

Acceptable modifiers:

  • unsigned integers: none;

  • signed integers: + for mandatory sign printing;

  • floats: same as integers and number of digits modifier, like .03.

List of directives:

%lat

Full latitude, floating point, signed

%latds

Latitude degrees, integer, signed

%latd

Latitude degrees, integer, unsigned

%latm

Latitude minutes, integer, unsigned

%lats

Latitude seconds, floating point, unsigned

%lath

Latitude hemisphere, “N” or “S”

%lng

Full longitude, floating point, signed

%lngds

Longitude degrees, integer, signed

%lngd

Longitude degrees, integer, unsigned

%lngm

Longitude minutes, integer, unsigned

%lngs

Longitude seconds, floating point, unsigned

%lngh

Longitude hemisphere, “E” or “W”

Examples:

g = Geo::Coord.new(50.004444, 36.231389)
g.strfcoord('%+lat, %+lng')
# => "+50.004444, +36.231389"
g.strfcoord("%latd°%latm'%lath -- %lngd°%lngm'%lngh")
# => "50°0'N -- 36°13'E"

strfcoord handles seconds rounding implicitly:

pos = Geo::Coord.new(0.033333, 91.333333)
pos.lats # => 0.599988e2
pos.strfcoord('%latd %latm %.05lats') # => "0 1 59.99880"
pos.strfcoord('%latd %latm %lats')  # => "0 2 0"


560
561
562
563
564
565
566
567
568
569
570
571
572
# File 'lib/geo/coord.rb', line 560

def strfcoord(formatstr)
  h = full_hash

  DIRECTIVES.reduce(formatstr) do |memo, (from, to)|
    memo.gsub(from) do
      to = to.call(Regexp.last_match) if to.is_a?(Proc)
      res = to % h
      res, carrymin = guard_seconds(to, res)
      h[carrymin] += 1 if carrymin
      res
    end
  end
end

#to_h(lat: :lat, lng: :lng) ⇒ Object

Returns hash of latitude and longitude. You can provide your keys if you want:

g.to_h
# => {:lat=>50.004444, :lng=>36.231389}
g.to_h(lat: 'LAT', lng: 'LNG')
# => {'LAT'=>50.004444, 'LNG'=>36.231389}


491
492
493
# File 'lib/geo/coord.rb', line 491

def to_h(lat: :lat, lng: :lng)
  {lat => self.lat, lng => self.lng}
end

#to_s(dms: true) ⇒ Object

Returns a string representing coordinates.

g.to_s              # => "50°0'16\"N 36°13'53\"E"
g.to_s(dms: false)  # => "50.004444,36.231389"


462
463
464
465
# File 'lib/geo/coord.rb', line 462

def to_s(dms: true)
  format = dms ? %{%latd°%latm'%lats"%lath %lngd°%lngm'%lngs"%lngh} : '%lat,%lng'
  strfcoord(format)
end