SpeciesFileGroup/taxonworks

View on GitHub
lib/utilities/geo.rb

Summary

Maintainability
F
6 days
Test Coverage
# General purpose Geo related methods
module Utilities
  # Special general routines for Geo-specific itams
  module Geo

    # !!
    # A series of values that are used
    # to unify geographic name strings *for display* (e.g. generating print).
    # These ultimately all belong somewhere else,
    # and are arbitrary decisions made by TaxonWorks collaborators.
    DICTIONARY = {
      'United States of America' => 'United States',
    }.freeze

    # TODO: move to /lib
    # @return [Nil]
    #  currently handling this client side
    def gps_data
      # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
      # (5 digits after decimal point if available)
      # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
      # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

      # check if gps data is in d m s (could be edited manually)
      #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
      # N = +
      # S = -
      # E = +
      # W = -
      # Altitude should be based on reference of sea level
      # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

      # From discussion with Jim -
      # create a utility library called "GeoConvert" and define single method
      # that will convert from degrees min sec to decimal degree
      # - maybe 2 versions? - one returns string, other decimal?
    end


=begin
To add a new (discovered) symbol:
  1) To find the Unicode string for any character, use Utilities::Geo.uni_string('c') (remove the first '\').
  2) Add the Unicode string (i.e, "\uNNNN") to SPECIAL_LATLONG_SYMBOLS (below), selecting either degrees
      (starting with 'do*'), or tickmarks (starting at "'").
  3) Add the Unicode to the proper section in the regexp in the corresponding section (degrees, minutes, or seconds).
      NB: all the minutes symbols are duplicated in the seconds section because sometimes two successive tickmarks
          (for minutes) are used for seconds.

  degree symbols, in addition to 'd', 'o', and '*'
  \u00b0  "°"  \u00ba  "º"  \u02da  "˚"  \u030a  "?"  \u221e "∞"  \u222b "∫"

  tick symbols, in addition to "'" ("\u0027""), and '"' ("\u0022")
  \u00a5  "¥"  \u00b4  "´"
  \u02B9  "ʹ"  \u02BA  "ʺ"  \u02BB  "ʻ"  \u02BC  "ʼ"  \u02CA "ˊ"
  \u02EE  "ˮ"  \u2032  "′"  \u2033  "″"
  \u2019  "’"  \u201D  "”"    added June 2020

  Significant figures/digits: any of the digits of a number beginning with the digit farthest to the left
  that is not zero and ending with the last digit farthest to the right that is either not zero
  or that is a zero but is considered to be exact
=end

    SPECIAL_LATLONG_SYMBOLS = "do*\u00b0\u00ba\u02DA\u030a\u221e\u222b\u0027\u00b4\u02B9\u02BA\u02BB\u02BC\u02CA\u02EE\u2032\u2033\u0022\u2019\u201D".freeze

    LAT_LON_REGEXP = Regexp.new(/(?<lat>-?\d+\.?\d*),?\s*(?<long>-?\d+\.?\d*)/)

    # DMS_REGEX = "(?<degrees>-*\d+)[do*\u00b0\u00ba\u02DA\u030a\u221e\u222b]\s*(?<minutes>\d+\.*\d*)
    # [\u0027\u00a5\u00b4\u02b9\u02bb\u02bc\u02ca\u2032]*\s*((?<seconds>\d+\.*\d*)
    # [\u0027\u00a5\u00b4\u02b9\u02ba\u02bb\u02bc\u02ca\u02ee\u2032\u2033\u0022]+)*"

    # http://en.wikiversity.org/wiki/Geographic_coordinate_conversion
    # http://stackoverflow.com/questions/1774985/converting-degree-minutes-seconds-to-decimal-degrees
    # http://stackoverflow.com/questions/1774781/how-do-i-convert-coordinates-to-google-friendly-coordinates

    # POINT_ONE_DIAGONAL = 15690.343288662 # 15690.343288662  # Not used?
    # TEN_WEST           = 1113194.90779206  # Not used?
    # TEN_NORTH          = 1105854.83323573  # Not used?

    # EARTH_RADIUS       = 6371000 # km, 3959 miles (mean Earth radius) # Not used?
    # RADIANS_PER_DEGREE = ::Math::PI/180.0
    # DEGREES_PER_RADIAN = 180.0/::Math::PI

    ONE_WEST       = 111_319.490779206 # meters/degree
    ONE_WEST_MEAN  = 111_319.444444444 # meters/degree (calculated mean)
    ONE_NORTH      = 110_574.38855796 # meters/degree

    #
    # class ConvertToDecimalDegrees
    #   attr_reader(:dd, :dms)
    #
    #   # @param [String] coordinate
    #   def initialize(coordinate)
    #     @dms = coordinate
    #     @dd  = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(coordinate)
    #   end
    # end

    class CoordinatesFromLabel
      attr_reader(:verbatim_label, :coordinates)

      # @param [String] label
      def initialize(label)
        @verbatim_label = label
        @coordinates = Utilities::Geo.coordinates_regex_from_verbatim_label(label)
      end
    end

    # 12345 (presume meters)
    # 123.45
    # 123 ft > 123 ft. > 123 feet > 1 foot > 123 f > 123 f.
    # 123 m > 123 meters > 123 m.
    # 123 km > 123 km. > 123 kilometers
    # 123 mi > 123 milee > 123 miles
    #
    # @param [String] dist_in
    # @return [String]      #     ##### changed from previous type
    def self.distance_in_meters(dist_in)
      dist_in   = '0.0 meters' if dist_in.blank?
      elevation = dist_in.strip.downcase
      pieces    = elevation.split(' ')
      # value     = elevation.to_f
      if pieces.count > 1 # two pieces, second is distance unit
        piece = 1
      else # one piece, may contain distance unit.
        piece = 0
      end
      value = pieces[0]
      scale = 1 # default is meters

      /(?<ft>f[oe]*[t]*\.*)|(?<m>[^k]m(eters)*[\.]*)|(?<km>kilometer(s)*|k[m]*[\.]*)|(?<mi>mi(le(s)*)*)/ =~ pieces[piece]
      # scale = $&

      scale = 1 if m.present?    # previously 1.0
      scale = 0.3048 if ft.present?
      scale = 1000 if km.present? # previously 1000.0
      scale = 1_609.344 if mi.present?

      value_sig = significant_digits(value.to_s)
      if value_sig[0].include?('.')
        s_value = value_sig[0].to_f
      else
        s_value = value_sig[0].to_i
      end
      s_sig = value_sig[1]

      distance = s_value * scale
      distance = conform_significant(distance.to_s, s_sig)  ####### previously .to_f
      distance
    end
=begin
    #  ' = \u0027, converted so that the regex can be used for SQL
    REGEXP_COORD_1 = {
      # tt1: /\D?(?<lat>\d+\.\d+\s*(?<ca>[NS])*)\s(?<long>\d+\.\d+\s*(?<co>[EW])*)/i,
      dd1a: /(\d+\.\d+\s*([NS]))\s*(\d+\.\d+\s*([EW]))/i,

      dd1b: /(([NS])\s*\d+\.\d+)\s*(([EW])\s*\d+\.\d+\s*)/i,

      dd2:  /(\d+[\. ]\d+(\u0027?)\s*([NS]))[, ]?\s*(\d+[\. ]\d+(\u0027?)\s*([EW]))/i,

      dm1:  /\D(\d+) ?([\*°ººo\u02DA ]) ?(\d+[\.|,]\d+|\d+) ?([ ´\u0027\u02B9\u02BC\u02CA])? ?([NS])[\.,;]? ?(\d+) ?([\*°ººo\u02DA ]) ?(\d+[\.|,]\d+|\d+) ?([ ´\u0027\u02B9\u02BC\u02CA])? ?([WE])\W/i,

      dms2: /\W([NS])\.? ?(\d+) ?([\*°ººo\u02DA ]) ?(\d+) ?([ ´\u0027\u02B9\u02BC\u02CA]) ?(\d+[\.|,]\d+|\d+) ?([ ""´\u02BA\u02EE\u0027\u02B9\u02BC\u02CA])([´\u0027\u02B9\u02BC\u02CA])?[\.,;]? ?([WE])\.? ?(\d+) ?([\*°ººo\u02DA ]) ?(\d+) ?([ \u0027´\u02B9\u02BC\u02CA]) ?(\d+[\.|,]\d+|\d+) ?([ ""´\u02BA\u02EE\u0027\u02B9\u02BC\u02CA])?([´\u0027\u02B9\u02BC\u02CA])?/i,

      dm3:  /\W([NS])\.? ?(\d+) ?([\*°ººo\u02DA ]) ?(\d+[\.|,]\d+|\d+) ?([ ´\u0027\u02B9\u02BC\u02CA])[\.,;]? ?([WE])\.? ?(\d+) ?([\*°ººo\u02DA ]) ?(\d+[\.|,]\d+|\d+) ?([ ´\u0027\u02B9\u02BC\u02CA])?/i,

      dms4: /\D(\d+) ?([\*°ººo\u02DA ]) ?(\d+[\.,]\d+|\d+) ?([ ´\u0027\u02B9\u02BC\u02CA])? ?(\d+)(")? ?([NS])(\d+) ?([\*°ººo\u02DA ]) ?(\d+[\.,]\d+|\d+) ?([ ´\u0027\u02B9\u02BC\u02CA])? ?(\d+)(["\u0027])? ?([EW])/i,

      dd5:  /\W([NS])\.? ?(\d+[\.|,]\d+|\d+) ?([\*°ººo\u02DA ])[\.,;]?\s*([WE])\.? ?(\d+[\.|,]\d+|\d+) ?([\*°ººo\u02DA ])?/i,

      dd6:  /\D(\d+[\.|,]\d+|\d+) ?([\*°ººo\u02DA ]) ?([NS])[\.,;]?\s*(\d+[\.|,]\d+|\d+) ?([\*°ººo\u02DA ]) ?([WE])\W/i,

      dd7:  /\[(-?\d+[\.|,]\d+|\-?d+),.*?(-?\d+[\.|,]\d+|\-?d+)\]/i
    }.freeze
=end
    #  ' = \u0027, converted so that the regex can be used for SQL
    # Added Unicode right single (u2019) and double (u201D) quote as minutes seconds
    REGEXP_COORD   = {
      # tt1: /\D?(?<lat>\d+\.\d+\s*(?<ca>[NS])*)\s(?<long>\d+\.\d+\s*(?<co>[EW])*)/i,
      dd1a: {
        reg: /(?<lat>\d+\.\d+\s*[NS])\s*(?<long>\d+\.\d+\s*[EW])/i,
        hlp: 'decimal degrees, trailing ordinal, e.g. 23.23N  44.44W'
      },

      dd1b: {
        reg: /(?<lat>[NS]\s*\d+\.\d+)\s*(?<long>[EW]\s*\d+\.\d+)/i,
        hlp: 'decimal degrees, leading ordinal, e.g. N23.23  W44.44'
      },

      dd2:  {
        reg: /(?<lat>\d+[\. ]\d+\u0027?\s*[NS]),?\s*(?<long>\d+[\. ]\d+\u0027?\s*[EW])/i,
        hlp: "decimal degrees, trailing ordinal, e.g. 43.836' N, 89.258' W"
      },

      dm1:  {
        reg: /(?<lat>\d+\s*[\*°o\u02DA ](\d+[\.,]\d+|\d+)\s*[ ´\u0027\u02B9\u02BC\u02CA\u2019]?\s*[NS])[\.,;]?\s*(?<long>\d+\s*[\*°ºo\u02DA ](\d+[\.,]\d+|\d+)\s*[ ´\u0027\u02B9\u02BC\u02CA\u2019]?\s*[WE])/i,
        hlp: "degrees, decimal minutes, trailing ordinal, e.g. 45 54.2'N, 78 43.5'E"
      },

      dms2: {
        reg: /(?<lat>[NS]\.?\s*\d+\s*[\*°ºo\u02DA ]\s*\d+\s*[ ´\u0027\u02B9\u02BC\u02CA\u2019]\s*(\d+[\.,]\d+|\d+)\s*[ "´\u02BA\u02EE\u0027\u02B9\u02BC\u02CA\u201D][´\u0027\u02B9\u02BC\u02CA]?)[\.,;]?\s*(?<long>[WE]\.?\s*\d+\s*[\*°ºo\u02DA ]\s*\d+\s*[ \u0027´\u02B9\u02BC\u02CA\u2019]\s*(\d+[\.,]\d+|\d+)\s*[ "´\u02BA\u02EE\u0027\u02B9\u02BC\u02CA\u201D]?[´\u0027\u02B9\u02BC\u02CA]?)/i,
        hlp: "degrees, minutes, decimal seconds, leading ordinal, e.g. S42°5'18.1\" W88º11'43.3\""
      },

      dm3:  {
        reg: /(?<lat>[NS]\.?\s*\d+\s*[\*°ºo\u02DA ]\s*(\d+[\.,]\d+|\d+)\s*([ ´\u0027\u02B9\u02BC\u02CA\u2019]))[\.,;]?\s*(?<long>[WE]\.?\s*\d+\s*[\*°ºo\u02DA ]\s*(\d+[\.,]\d+|\d+)\s*[ ´\u0027\u02B9\u02BC\u02CA\u2019]?)/i,
        hlp: "degrees, decimal minutes, leading ordinal, e.g. S42º5.18' W88°11.43'"
      },

      dms4: {
        reg: /(?<lat>\d+\s*[\*°ºo\u02DA ]\s*(\d+[\.,]\d+|\d+)\s*[ ´\u0027\u02B9\u02BC\u02CA\u2019]?\s*(\d+[\.,]\d+|\d+)["\u201D]?\s*[NS])\s*(?<long>\d+\s*[\*°ºo\u02DA ]\s*(\d+[\.,]\d+|\d+)\s*[ ´\u0027\u02B9\u02BC\u02CA\u2019]?\s*(\d+[\.,]\d+|\d+)+["\u201D]?\s*[EW])/i,
        hlp: "degrees, minutes, decimal seconds, trailing ordinal, e.g. 24º7'2.0\"S65º24'13.1\"W"
      },

      dd5:  {
        reg: /(?<lat>[NS]\.?\s*(\d+[\.,]\d+|\d+)\s*[\*°ºo\u02DA ])[\.,;]?\s*(?<long>([WE])\.?\s*(\d+[\.,]\d+|\d+)\s*[\*°ºo\u02DA ]?)/i,
        hlp: 'decimal degrees, leading ordinal, e.g. S42.18° W88.34°'
      },

      dd6:  {
        reg: /(?<lat>(\d+[\.,]\d+|\d+)\s*[\*°ºo\u02DA ]\s*[NS])[\.,;]?\s*(?<long>(\d+[\.|,]\d+|\d+)\s*[\*°ºo\u02DA ]\s*[WE])/i,
        hlp: 'decimal degrees, trailing ordinal, e.g. 42.18°S 88.43°W'
      },

      dd7:  {
        reg: /\[(?<lat>-?\d+[\.,]\d+|\-?d+),.*?(?<long>-?\d+[\.,]\d+|\-?d+)\]/i,
        hlp: 'decimal degrees, no ordinal, specific format, e.g. [12.263, -49.398]'
      }
    }.freeze
    # @param [String] label
    # @param [String] filters
    # @return [Array] of possible coordinate strings
    def self.hunt_lat_long_full(label, filters = REGEXP_COORD.keys)
      trials = {}
      filters.each_with_index {|kee, _dex|
        kee_string         = kee.to_s.upcase
        trials[kee_string] = {}
        named              = REGEXP_COORD[kee][:reg].match(label)
        unless named.nil?
          trials[kee_string][:piece] = named[0]
          trials[kee_string][:lat]   = named[:lat]
          trials[kee_string][:long]  = named[:long]
          named
        end
        trials[kee_string][:method] = "text, #{kee_string}"
      }
      trials
    end

    # rubocop:disable Metrics/MethodLength, Metrics/BlockNesting
    # @param [String] label
    # @param [String] how
    # @return [Hash] of possible lat/long pairs
    def self.hunt_lat_long(label, how = ' ')
      if how.nil?
        pieces = [label]
      else
        pieces = label.split(how)
      end
      lat_long = {}
      pieces.each do |piece|
        # group of possible regex configurations
        # m = /(?<lat>\d+\.\d+\s*(?<ca>[NS])*)\s(?<long>\d+\.\d+\s*(?<co>[EW])*)/i =~ piece
        m = REGEXP_COORD[:dd1a][:reg].match(piece)
        if m.nil?
          piece.each_char do |c|
            next unless SPECIAL_LATLONG_SYMBOLS.include?(c)
            test = Utilities::Geo.degrees_minutes_seconds_to_decimal_degrees(piece)
            unless test.nil?
              if test.to_f.is_a? Numeric
                # might be a lat/long
                lat_long[:piece] = piece
                if lat_long[:lat].nil?
                  lat_long[:lat] = piece
                else
                  lat_long[:long]  = piece
                  lat_long[:piece] = [lat_long[:lat], piece].join(how)
                end
              end
            end
            break
          end
        else
          lat_long[:piece] = m[0]
          lat_long[:lat]   = m[:lat]
          lat_long[:long]  = m[:long]
        end
      end
      lat_long
    end
    # rubocop:enable Metrics/MethodLength, Metrics/BlockNesting

    # @param [String] label
    # @param [Array] filters
    # @return [Array]
    def self.hunt_wrapper(label, filters = REGEXP_COORD.keys)

      trials = self.hunt_lat_long_full(label, filters)

      ';, '.each_char {|sep|
        trial = self.hunt_lat_long(label, sep)
        found = "#{trial[:piece]}"
        unless trial[:lat].nil? and !trial[:long].nil?
          _found = "(#{sep})" if found.blank?
        end
        trials["(#{sep})"] = trial.merge!(method: "(#{sep})")
      }
      trials
    end

    # @param [String] c as single character
    # @return [Boolean]
    def self.is_lat_long_special(c)
      SPECIAL_LATLONG_SYMBOLS.include?(c)
    end

    # rubocop:disable Metrics/MethodLength
    # 42∞5'18.1"S88∞11'43.3"W
    # S42∞5'18.1"W88∞11'43.3"
    # S42∞5.18'W88∞11.43'
    # 42∞5.18'S88∞11.43'W
    # S42.18∞W88.34∞
    # 42.18∞S88.43∞W
    # -12.263, 49.398
    #
    # 42:5:18.1N
    # 88:11:43.3W
    #
    # no limit test, unless there is a ordinal letter included
    #
    # @param [String] dms_in
    # @return [Float] decimal degrees
    def self.degrees_minutes_seconds_to_decimal_degrees(dms_in) # rubocop:disable Metrics/PerceivedComplexity !! But this is too complex :)
      match_string = nil
      # no_point     = false
      degrees      = 0.0
      minutes      = 0.0
      seconds      = 0.0

      # make SURE it is a string! Watch out for dms_in == -10
      dms_in       = dms_in.to_s
      dms          = dms_in.dup.upcase
      dms          = dms.gsub('DEG', 'º').gsub('DG', 'º')
      dms =~ /[NSEW]/i
      ordinal = $LAST_MATCH_INFO.to_s
      # return "#{dms}: Too many letters (#{ordinal})" if ordinal.length > 1
      # return nil if ordinal.length > 1
      dms     = dms.gsub!(ordinal, '').strip.downcase

      if dms.include? '.'
        no_point = false
        if dms.include? ':' # might be '42:5.1'
          /(?<degrees>-*\d+):(?<minutes>\d+\.*\d*)(:(?<seconds>\d+\.*\d*))*/ =~ dms
          match_string = $& # rubocop:disable Style/IdenticalConditionalBranches
        else
          # this will get over-ridden if the next regex matches
          /(?<degrees>-*\d+\.\d+)/ =~ dms
          match_string = $& # rubocop:disable Style/IdenticalConditionalBranches
        end
      else
        no_point = true
      end

      # >40°26′46″< >40°26′46″<
      dms.each_char {|c|
        next unless SPECIAL_LATLONG_SYMBOLS.include?(c)
        /^(?<degrees>-*\d{0,3}(\.\d+)*) # + or - three-digit number with optional '.' and additional decimal digits
        [do*\u00b0\u00ba\u02DA\u030a\u221e\u222b\uc2ba]*\s* # optional special degrees symbol, optional space
        (?<minutes>\d+\.*\d*)* # optional number, integer or floating-point
        ['\u00a5\u00b4\u02b9\u02bb\u02bc\u02ca\u2032\uc2ba\u2019]*\s* # optional special minutes symbol, optional space
        ((?<seconds>\d+\.*\d*) # optional number, integer or floating-point
        ['\u00a5\u00b4\u02b9\u02ba\u02bb\u02bc\u02ca\u02ee\u2032\u2033\uc2ba"\u201D]+)* # optional special seconds symbol, optional space
        /x =~ dms # '/(regexp)/x' modifier permits inline comments for regexp
        match_string = $&
          break # bail on the first character match
      }
      degrees = dms.to_f if match_string.nil? && no_point

      # @match_string = $&
      degrees = degrees.to_f
      case ordinal
      when 'W', 'S'
        sign = -1.0
      else
        sign = 1.0
      end
      if degrees < 0
        sign    *= -1
        degrees *= -1.0
      end
      frac = ((minutes.to_f * 60.0) + seconds.to_f) / 3600.0
      dd   = (degrees + frac) * sign
      case ordinal
      when 'N', 'S'
        limit = 90.0
      else
        limit = 180.0
      end
      # return "#{dms}: Out of range (#{dd})" if dd.abs > limit
      return nil if dd.abs > limit || dd == 0.0
      dd.round(6).to_s
    end
    # rubocop:enable Metrics/MethodLength

    # @param [String] char as single character
    # @return [String]
    def uni_string(char)
      format('\\u%04X', char.ord)
      # "\\#{sprintf('u%04X', char.ord)}"
      # '\\u%04X' % [char.ord]
    end

    # rubocop:disable Metrics/MethodLength
    # @param [ActionController::Parameters] params
    # @return [Integer]
    def self.nearby_from_params(params)
      nearby_distance = params['nearby_distance'].to_i
      nearby_distance = CollectingEvent::NEARBY_DISTANCE if nearby_distance == 0

      decade = case nearby_distance.to_s.length
               when 1..2
                 10
               when 3
                 100
               when 4
                 1_000
               when 5
                 10_000
               when 6
                 100_000
               when 7
                 1_000_000
               when 8
                 10_000_000
               else
                 10
               end
      digit  = (nearby_distance.to_f / decade.to_f).round

      case digit
      when 0..1
        digit = 1
      when 2
        digit = 2
      when 3..5
        digit = 5
      when 6..10
        decade *= 10
        digit = 1
      end

      params['digit1'] = digit.to_s
      params['digit2'] = decade.to_s
      digit * decade
    end
    # rubocop:enable Metrics/MethodLength

    # confirm that this says that the error radius is one degree or smaller
    # @param [RGeo::Point] geo_object
    # @param [Integer] error_radius
    # @return [RGeo::Polygon]
    def self.error_box_for_point(geo_object, error_radius)
      # this limits the actual error_box to 10k FOR THIS TEST ONLY!
      error_radius = 10_000 if error_radius > 10_000
      # ensure radius is not too close to zero to avoid creating invalid box
      error_radius = [error_radius, 2.0**-16].max

      p0      = geo_object
      delta_x = (error_radius / ONE_WEST) / Math.cos(p0.y * Math::PI / 180)
      delta_y = error_radius / ONE_NORTH

      Gis::FACTORY.polygon(
        Gis::FACTORY.line_string(
          [
            Gis::FACTORY.point(p0.x - delta_x, p0.y + delta_y), # northwest
            Gis::FACTORY.point(p0.x + delta_x, p0.y + delta_y), # northeast
            Gis::FACTORY.point(p0.x + delta_x, p0.y - delta_y), # southeast
            Gis::FACTORY.point(p0.x - delta_x, p0.y - delta_y) # southwest
          ]
        )
      )
    end

    # make a diamond 2 * radius tall and 2 * radius wide, with the reference point as center
    # NOT TESTED/USED
    # @return [RGeo::Polygon]
    def diamond_error_box
      p0      = geo_object
      delta_x = (error_radius / ONE_WEST) / Math.cos(p0.y * Math::PI / 180)
      delta_y = error_radius / ONE_NORTH

      retval = Gis::FACTORY.polygon(Gis::FACTORY.line_string(
        [Gis::FACTORY.point(p0.x, p0.y + delta_y), # north
         Gis::FACTORY.point(p0.x + delta_x, p0.y), # east
         Gis::FACTORY.point(p0.x, p0.y - delta_y), # south
         Gis::FACTORY.point(p0.x - delta_x, p0.y) # west
        ]))
      box    = RGeo::Cartesian::BoundingBox.new(Gis::FACTORY)
      box.add(retval)
      box.to_geometry
    end

    # determine number of significant digits in string input argument
    # @param [String] number_string
    # @return [Array] [<string with only significant digits>, count, <left of decimal>, decimal point string, <right of decimal lead zeros string>, <mantissa string>]
    def self.significant_digits(number_string)
      # is there a decimal point?
      intg = ''
      decimal_point_zeros = ''
      mantissa = ''
      decimal_lead_zeros = 0
      decimal_point = ''
      /(?<num>([0-9]*)(\.?)([0-9]*))/ =~ number_string
      if num.nil?
        raise
      end
      dp = num.index('.')
      if dp.nil?
        intg = num
        intgl = intg.sub(/^[0]+/,'')  # strip lead zeros
        if intgl.nil?
          intg = num
        end
        intgt = intg.sub(/0+$/, '')    # strip trailing zeros
        if intgt.nil?
          intg = intgl
        end
        # sig = intg.length
      else
        # make sure truly numeric
        decimal_point = '.'
        digits = num.split('.')
        if digits.length > 2
          raise   # or just ignore extra decimal point and beyond?
        else
          if digits[0].length > 0 # left of decimal ?
            intg = digits[0].sub(/^[0]+/,'')
            if intg.nil?
              intg = digits[0]
            end
          else
            intg = ''
          end
          mantissa = digits[1]
          unless digits[1].nil?
            if intg.length > 0  # have full case nn.mm
              sig = intg.length + mantissa.length
            else  # mantissa might have "leading" zeros
              decimal_lead_zeros = digits[1].length
              mantissa = digits[1].sub(/^[0]+/, '')
              if mantissa.nil?
                mantissa = digits[1]
              end
              decimal_lead_zeros = decimal_lead_zeros - mantissa.length
              decimal_point_zeros = decimal_point_zeros.rjust(decimal_lead_zeros, '0')
            end
          else
            mantissa = ''
          end
        end
      end
      sig = intg + decimal_point + decimal_point_zeros + mantissa
      [sig, intg.length + mantissa.length, intg, decimal_point, decimal_point_zeros, mantissa]
    end

    # conform number to significant digits as string
    # @param [String] number to be conformed
    # @param [Integer] sig_digits (desired number of significant digits)
    # @return [String] number limited to specified significant digits
    def self.conform_significant(number, sig_digits)
      input = significant_digits(number.to_s)
      input_string = input[0]
      intg = input[2]
      decimal_point = input[3]
      decimal_position = input_string.index('.')
      decimal_point_zeros = input[4]   # decimal_point_zeros length > 0 implies mantissa only case
      mantissa = input[5]     # mantissa complete iff no decimal_point_zeros
      digit_string = intg + decimal_point_zeros + mantissa
      reduction = input[1] - sig_digits
      result = digit_string   # failsafe result
      if reduction > 0    # need to reduce significant digits
        result = ''
        for index in (0...sig_digits)       # collect ONLY significant digits
          digit = digit_string[index]   # if number is "0", digit is nil
          result += digit
          next
        end
        if digit_string.length > sig_digits     # clean up integer least significant digits
          if sig_digits > 0     # test degenerate case of 0 input
            if digit_string[index + 1].to_i >= 5
              result = (result.to_i + 1).to_s # round if necessary
            end
          end
          for ndex in (0...(intg.length - sig_digits))
            result += '0'
            next
          end
        end
      else        # no reduction or add digits
        for ndex in (0...(sig_digits - digit_string.length))
          result += '0'
          next
        end
      end
      unless decimal_position.nil?
        # this devolves to if decimal_position = 0, prepend "0."   OR
        # if decimal_position == end of string, don't add decimal_point
        # otherwise add decimal_point at decimal_position
        if result.length <= sig_digits
          if decimal_position == 0
            result.insert(decimal_position, '0' + decimal_point)  # make a valid Ruby number
          else
            if decimal_position < sig_digits
              result.insert(decimal_position, decimal_point)
            end
          end
        end
      end
      result
    end



    # @return [Hash]
    # coordinates from the label parsed to elements
    def self.coordinates_regex_from_verbatim_label(text)
      return nil if text.blank?
      text = text.gsub("''", '"')
        .gsub('´´', '"')
        .gsub('ʹʹ', '"')
        .gsub('ʼʼ', '"')
        .gsub('ˊˊ', '"')
        .squish
      text = ' ' + text + ' '

      coordinates = {}

      #  pattern: 42°5'18.1"S88°11'43.3"W
      if matchdata1 = text.match(/\D(\d+) ?[\*°ººod˚ ] ?(\d+) ?[ '´ʹ΄′ʼ’ˊ‘] ?(\d+[\.|,]\d+|\d+) ?[ "ʺ″”ˮ'´ʹ′΄ʼ’ˊ‘]['´ʹ′΄ʼ’ˊ‘]? ?([nN]|[sS])[\.,–;]? ?(\d+) ?[\*°ººod˚ ] ?(\d+) ?[ '´ʹ′΄ʼ’ˊ‘]\ ?(\d+[\.|,]\d+|\d+) ?[ "ʺ″”ˮ'´ʹ′΄ʼ’ˊ‘]['´ʹ′΄ʼ’ˊ‘]? ?([wW]|[eE])\W/)
        coordinates[:lat_deg] = matchdata1[1]
        coordinates[:lat_min] = matchdata1[2]
        coordinates[:lat_sec] = matchdata1[3]
        coordinates[:lat_ns]  = matchdata1[4]
        coordinates[:long_deg] = matchdata1[5]
        coordinates[:long_min] = matchdata1[6]
        coordinates[:long_sec] = matchdata1[7]
        coordinates[:long_we]  = matchdata1[8]
        # pattern: S42°5'18.1"W88°11'43.3"
      elsif matchdata2 = text.match(/\W([nN]|[sS])\.? ?(\d+) ?[\*°ººod˚ ] ?(\d+) ?[ '´ʹ′΄ʼ’ˊ‘] ?(\d+[\.|,]\d+|\d+) ?[ "ʺ″”ˮ'´ʹ′΄ʼ’ˊ‘]['´ʹ′΄ʼ’ˊ‘]?[\.,–;]? ?([wW]|[eE])\.? ?(\d+) ?[\*°ººod˚ ] ?(\d+) ?[ '´ʹ′΄ʼ’ˊ‘] ?(\d+[\.|,]\d+|\d+) ?[ "ʺ″”ˮ'´ʹ′΄ʼ’ˊ‘]?['´ʹ′΄ʼ’ˊ‘]?\D/)
        coordinates[:lat_deg] = matchdata2[2]
        coordinates[:lat_min] = matchdata2[3]
        coordinates[:lat_sec] = matchdata2[4]
        coordinates[:lat_ns]  = matchdata2[1]
        coordinates[:long_deg] = matchdata2[6]
        coordinates[:long_min] = matchdata2[7]
        coordinates[:long_sec] = matchdata2[8]
        coordinates[:long_we]  = matchdata2[5]
        # pattern: S42°5.18'W88°11.43'
      elsif matchdata3 = text.match(/\W([nN]|[sS])\.? ?(\d+) ?[\*°ººod˚ ] ?(\d+[\.|,]\d+|\d+) ?[ '´ʹ′΄ʼ’ˊ‘][\.,;]? ?([wW]|[eE])\.? ?(\d+) ?[\*°ººod˚ ] ?(\d+[\.|,]\d+|\d+) ?[ '´ʹ′΄ʼ’ˊ‘]?\D/)
        coordinates[:lat_deg] = matchdata3[2]
        coordinates[:lat_min] = matchdata3[3]
        coordinates[:lat_ns]  = matchdata3[1]
        coordinates[:long_deg] = matchdata3[5]
        coordinates[:long_min] = matchdata3[6]
        coordinates[:long_we]  = matchdata3[4]
        # pattern: 42°5.18'S88°11.43'W
      elsif matchdata4 = text.match(/\D(\d+) ?[\*°ººod˚ ] ?(\d+[\.|,]\d+|\d+) ?[ '´ʹ′΄ʼ’ˊ‘]? ?([nN]|[sS])[\.,;]? ?(\d+) ?[\*°ººod˚ ] ?(\d+[\.|,]\d+|\d+) ?[ '´ʹ′΄ʼ’ˊ‘]? ?([wW]|[eE])\W/)
        coordinates[:lat_deg] = matchdata4[1]
        coordinates[:lat_min] = matchdata4[2]
        coordinates[:lat_ns]  = matchdata4[3]
        coordinates[:long_deg] = matchdata4[4]
        coordinates[:long_min] = matchdata4[5]
        coordinates[:long_we]  = matchdata4[6]
        # pattern: 42.18°S88.43°W
      elsif matchdata6 = text.match(/\D(\d+[\.|,]\d+|\d+) ?[\*°ººod˚ ] ?([nN]|[sS])[\.,;]? ?(\d+[\.|,]\d+|\d+) ?[\*°ººod˚ ] ?([wW]|[eE])\W/)
        coordinates[:lat_deg] = matchdata6[1]
        coordinates[:lat_ns]  = matchdata6[2]
        coordinates[:long_deg] = matchdata6[3]
        coordinates[:long_we]  = matchdata6[4]
        # pattern: S42.18°W88.34°
      elsif matchdata5 = text.match(/\W([nN]|[sS])\.? ?(\d+[\.|,]\d+|\d+) ?[\*°ººod˚ ][\.,;]? ?([wW]|[eE])\.? ?(\d+[\.|,]\d+|\d+) ?[\*°ººod˚ ]?\D/)
        coordinates[:lat_deg] = matchdata5[2]
        coordinates[:lat_ns]  = matchdata5[1]
        coordinates[:long_deg] = matchdata5[4]
        coordinates[:long_we]  = matchdata5[3]
        # pattern: -12.263, 49.398
      elsif matchdata7 = text.match(/\D(-?\d+[\.|,]\d+|\-?\d+),.*?(-?\d+[\.|,]\d+|\-?\d+)\D/)
        coordinates[:lat_deg] = matchdata7[1]
        coordinates[:long_deg] = matchdata7[2]
      end
      coordinates[:lat_deg] = coordinates[:lat_deg].gsub(',', '.') if coordinates[:lat_deg]
      coordinates[:lat_min] = coordinates[:lat_min].gsub(',', '.') if coordinates[:lat_min]
      coordinates[:lat_sec] = coordinates[:lat_sec].gsub(',', '.') if coordinates[:lat_sec]
      coordinates[:lat_ns] = coordinates[:lat_ns].capitalize if coordinates[:lat_ns]
      coordinates[:long_deg] = coordinates[:long_deg].gsub(',', '.') if coordinates[:long_deg]
      coordinates[:long_min] = coordinates[:long_min].gsub(',', '.') if coordinates[:long_min]
      coordinates[:long_sec] = coordinates[:long_sec].gsub(',', '.') if coordinates[:long_sec]
      coordinates[:lat_we] = coordinates[:lat_we].capitalize if coordinates[:lat_we]

      return {} if !coordinates[:lat_deg] || !coordinates[:long_deg]
      return {} if coordinates[:lat_deg].to_f > 90 || coordinates[:lat_deg].to_f < -90
      return {} if coordinates[:lat_min].to_f >= 60
      return {} if coordinates[:lat_sec].to_f >= 60
      return {} if coordinates[:long_deg].to_f > 180 || coordinates[:long_deg].to_f < -180
      return {} if coordinates[:long_min].to_f >= 60
      return {} if coordinates[:long_sec].to_f >= 60


      if coordinates[:lat_ns].nil?
        lat_string = coordinates[:lat_deg]
        if coordinates[:lat_deg].to_f < 0 # -5° S; 5° N
          coordinates[:lat_ns] = 'S'
          coordinates[:lat_deg] = coordinates[:lat_deg].gsub('-', '')
        else
          coordinates[:lat_ns] = 'N' # -5° W; 5° E
        end
      else
        lat_string = coordinates[:lat_deg] + '°'
        lat_string += coordinates[:lat_min] + "'" if coordinates[:lat_min]
        lat_string += coordinates[:lat_sec] + '"' if coordinates[:lat_sec]
        lat_string += coordinates[:lat_ns]
      end
      if coordinates[:long_we].nil?
        long_string = coordinates[:long_deg]
        if coordinates[:long_deg].to_f < 0
          coordinates[:long_we] = 'W'
          coordinates[:long_deg] = coordinates[:long_deg].gsub('-', '')
        else
          coordinates[:long_we] = 'E'
        end
      else
        long_string = coordinates[:long_deg] + '°'
        long_string += coordinates[:long_min] + "'" if coordinates[:long_min]
        long_string += coordinates[:long_sec] + '"' if coordinates[:long_sec]
        long_string += coordinates[:long_we]
      end

      lat_dec = (coordinates[:lat_deg].to_f + (coordinates[:lat_min].to_f / 60) + (coordinates[:lat_sec].to_f / 3600)).round(6).to_s
      lat_dec = '-' + lat_dec if coordinates[:lat_ns] == 'S'
      long_dec = (coordinates[:long_deg].to_f + (coordinates[:long_min].to_f / 60) + (coordinates[:long_sec].to_f / 3600)).round(6).to_s
      long_dec = '-' + long_dec if coordinates[:long_we] == 'W'

      c = {
        verbatim: {verbatim_latitude: lat_string, verbatim_longitude: long_string},
        decimal: {decimal_latitude: lat_dec, decimal_longitude: long_dec},
        parsed: coordinates
      }
      return c
    end

  end

  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end


end