open-location-code 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 378b33bb5f5bdf0ec82c286db7e7b54b762f2723
4
+ data.tar.gz: b2f0ded83b4ed9c914a0220c177ae6bb3e43adf5
5
+ SHA512:
6
+ metadata.gz: 052d9fb0aeeab2fd7969ae25b00824813e9f63b4877d7e73d5a5aa75d5d51c17fc1eeb51482c1f08f8cfa3d50ca3793ca2e44263c225c9244b9e5fa541f94c51
7
+ data.tar.gz: 34f7c50ad9dd924e7afaa441ab3cdefe80b32de655b97cdb17f5c06d3c698738855e5c80af8d3c08f1cdce82bb494e23b22580933893d8a77cfb686fe4fa5f47
data/lib/plus_codes.rb ADDED
@@ -0,0 +1,26 @@
1
+ # Plus+Codes is a Ruby implementation of Google Open Location Code(Plus+Codes).
2
+ #
3
+ # @author We-Ming Wu
4
+ module PlusCodes
5
+
6
+ # A separator used to separate the code into two parts.
7
+ SEPARATOR = '+'.freeze
8
+
9
+ # The max number of characters can be placed before the separator.
10
+ SEPARATOR_POSITION = 8
11
+
12
+ # The character used to pad a code
13
+ PADDING = '0'.freeze
14
+
15
+ # The character set used to encode coordinates.
16
+ CODE_ALPHABET = '23456789CFGHJMPQRVWX'.freeze
17
+
18
+ # ASCII lookup table.
19
+ DECODE = (CODE_ALPHABET.chars + [PADDING, SEPARATOR]).reduce([]) do |ary, c|
20
+ ary[c.ord] = CODE_ALPHABET.index(c)
21
+ ary[c.downcase.ord] = CODE_ALPHABET.index(c)
22
+ ary[c.ord] ||= -1
23
+ ary
24
+ end.freeze
25
+
26
+ end
@@ -0,0 +1,38 @@
1
+ module PlusCodes
2
+
3
+ # [CodeArea] contains coordinates of a decoded Open Location Code(Plus+Codes).
4
+ # The coordinates include the latitude and longitude of the lower left and
5
+ # upper right corners and the center of the bounding box for the area the
6
+ # code represents.
7
+ #
8
+ # @author We-Ming Wu
9
+ class CodeArea
10
+ attr_accessor :south_latitude, :west_longitude, :latitude_height,
11
+ :longitude_width, :latitude_center, :longitude_center
12
+
13
+ # Creates a [CodeArea].
14
+ #
15
+ # @param south_latitude [Numeric] the latitude of the SW corner in degrees
16
+ # @param west_longitude [Numeric] the longitude of the SW corner in degrees
17
+ # @param latitude_height [Numeric] the height from the SW corner in degrees
18
+ # @param longitude_width [Numeric] the width from the SW corner in degrees
19
+ # @return [CodeArea] a code area which contains the coordinates
20
+ def initialize(south_latitude, west_longitude, latitude_height, longitude_width)
21
+ @south_latitude = south_latitude
22
+ @west_longitude = west_longitude
23
+ @latitude_height = latitude_height
24
+ @longitude_width = longitude_width
25
+ @latitude_center = south_latitude + latitude_height / 2.0
26
+ @longitude_center = west_longitude + longitude_width / 2.0
27
+ end
28
+
29
+ def north_latitude
30
+ @south_latitude + @latitude_height
31
+ end
32
+
33
+ def east_longitude
34
+ @west_longitude + @longitude_width
35
+ end
36
+ end
37
+
38
+ end
@@ -0,0 +1,264 @@
1
+ require_relative '../plus_codes'
2
+ require_relative '../plus_codes/code_area'
3
+
4
+ module PlusCodes
5
+
6
+ # [OpenLocationCode] implements the Google Open Location Code(Plus+Codes) algorithm.
7
+ #
8
+ # @author We-Ming Wu
9
+ class OpenLocationCode
10
+
11
+ # Determines if a string is a valid sequence of Open Location Code(Plus+Codes) characters.
12
+ #
13
+ # @param code [String] a plus+codes
14
+ # @return [TrueClass, FalseClass] true if the code is valid, false otherwise
15
+ def valid?(code)
16
+ valid_length?(code) &&
17
+ valid_separator?(code) &&
18
+ valid_padding?(code) &&
19
+ valid_character?(code)
20
+ end
21
+
22
+ # Determines if a string is a valid short Open Location Code(Plus+Codes).
23
+ #
24
+ # @param code [String] a plus+codes
25
+ # @return [TrueClass, FalseClass] true if the code is short, false otherwise
26
+ def short?(code)
27
+ valid?(code) && code.index(SEPARATOR) < SEPARATOR_POSITION
28
+ end
29
+
30
+ # Determines if a string is a valid full Open Location Code(Plus+Codes).
31
+ #
32
+ # @param code [String] a plus+codes
33
+ # @return [TrueClass, FalseClass] true if the code is full, false otherwise
34
+ def full?(code)
35
+ valid?(code) && !short?(code)
36
+ end
37
+
38
+ # Converts a latitude and longitude into a Open Location Code(Plus+Codes).
39
+ #
40
+ # @param latitude [Numeric] a latitude in degrees
41
+ # @param longitude [Numeric] a longitude in degrees
42
+ # @param code_length [Integer] the number of characters in the code, this excludes the separator
43
+ # @return [String] a plus+codes
44
+ def encode(latitude, longitude, code_length = 10)
45
+ raise ArgumentError,
46
+ "Invalid Open Location Code(Plus+Codes) length: #{code_length}" if invalid_length?(code_length)
47
+
48
+ latitude = clip_latitude(latitude)
49
+ longitude = normalize_longitude(longitude)
50
+ latitude -= precision_by_length(code_length) if latitude == 90
51
+
52
+ lat = (latitude + 90).to_r
53
+ lng = (longitude + 180).to_r
54
+
55
+ digit = 0
56
+ code = ''
57
+ while digit < code_length
58
+ lat, lng = narrow_region(digit, lat, lng)
59
+ digit, lat, lng = build_code(digit, code, lat, lng)
60
+ code << SEPARATOR if (digit == SEPARATOR_POSITION)
61
+ end
62
+
63
+ digit < SEPARATOR_POSITION ? padded(code) : code
64
+ end
65
+
66
+ # Decodes an Open Location Code(Plus+Codes) into a [CodeArea].
67
+ #
68
+ # @param code [String] a plus+codes
69
+ # @return [CodeArea] a code area which contains the coordinates
70
+ def decode(code)
71
+ raise ArgumentError,
72
+ "Open Location Code(Plus+Codes) is not a valid full code: #{code}" unless full?(code)
73
+
74
+ code = code.gsub(SEPARATOR, '')
75
+ code = code.gsub(/#{PADDING}+/, '')
76
+ code = code.upcase
77
+
78
+ south_latitude = -90.0
79
+ west_longitude = -180.0
80
+
81
+ lat_resolution = 400.to_r
82
+ lng_resolution = 400.to_r
83
+
84
+ digit = 0
85
+ while digit < code.length
86
+ if digit < 10
87
+ lat_resolution /= 20
88
+ lng_resolution /= 20
89
+ south_latitude += lat_resolution * DECODE[code[digit].ord]
90
+ west_longitude += lng_resolution * DECODE[code[digit + 1].ord]
91
+ digit += 2
92
+ else
93
+ lat_resolution /= 5
94
+ lng_resolution /= 4
95
+ row = DECODE[code[digit].ord] / 4
96
+ column = DECODE[code[digit].ord] % 4
97
+ south_latitude += lat_resolution * row
98
+ west_longitude += lng_resolution * column
99
+ digit += 1
100
+ end
101
+ end
102
+
103
+ CodeArea.new(south_latitude, west_longitude, lat_resolution, lng_resolution)
104
+ end
105
+
106
+ # Recovers a full Open Location Code(Plus+Codes) from a short code and a reference location.
107
+ #
108
+ # @param short_code [String] a plus+codes
109
+ # @param reference_latitude [Numeric] a reference latitude in degrees
110
+ # @param reference_longitude [Numeric] a reference longitude in degrees
111
+ # @return [String] a plus+codes
112
+ def recover_nearest(short_code, reference_latitude, reference_longitude)
113
+ return short_code if full?(short_code)
114
+ raise ArgumentError,
115
+ "Open Location Code(Plus+Codes) is not valid: #{short_code}" unless short?(short_code)
116
+
117
+ ref_lat = clip_latitude(reference_latitude)
118
+ ref_lng = normalize_longitude(reference_longitude)
119
+
120
+ prefix_len = SEPARATOR_POSITION - short_code.index(SEPARATOR)
121
+ code = prefix_by_reference(ref_lat, ref_lng, prefix_len) << short_code
122
+ code_area = decode(code)
123
+
124
+ area_range = precision_by_length(prefix_len)
125
+ area_edge = area_range / 2
126
+
127
+ latitude = code_area.latitude_center
128
+ latitude_diff = latitude - ref_lat
129
+ if (latitude_diff > area_edge)
130
+ latitude -= area_range
131
+ elsif (latitude_diff < -area_edge)
132
+ latitude += area_range
133
+ end
134
+
135
+ longitude = code_area.longitude_center
136
+ longitude_diff = longitude - ref_lng
137
+ if (longitude_diff > area_edge)
138
+ longitude -= area_range
139
+ elsif (longitude_diff < -area_edge)
140
+ longitude += area_range
141
+ end
142
+
143
+ encode(latitude, longitude, code.length - SEPARATOR.length)
144
+ end
145
+
146
+ # Removes four, six or eight digits from the front of an Open Location Code(Plus+Codes) given a reference location.
147
+ #
148
+ # @param code [String] a plus+codes
149
+ # @param latitude [Numeric] a latitude in degrees
150
+ # @param longitude [Numeric] a longitude in degrees
151
+ # @return [String] a short plus+codes
152
+ def shorten(code, latitude, longitude)
153
+ raise ArgumentError,
154
+ "Open Location Code(Plus+Codes) is a valid full code: #{code}" unless full?(code)
155
+ raise ArgumentError,
156
+ "Cannot shorten padded codes: #{code}" unless code.index(PADDING).nil?
157
+
158
+ code_area = decode(code)
159
+ lat_diff = (latitude - code_area.latitude_center).abs
160
+ lng_diff = (longitude - code_area.longitude_center).abs
161
+ max_diff = [lat_diff, lng_diff].max
162
+ [8, 6, 4].each do |removal_len|
163
+ area_edge = precision_by_length(removal_len + 2) / 2
164
+ return code[removal_len..-1] if max_diff < area_edge
165
+ end
166
+
167
+ code.upcase
168
+ end
169
+
170
+ private
171
+
172
+ def prefix_by_reference(latitude, longitude, prefix_len)
173
+ precision = precision_by_length(prefix_len)
174
+ rounded_latitude = (latitude / precision).floor * precision
175
+ rounded_longitude = (longitude / precision).floor * precision
176
+ encode(rounded_latitude, rounded_longitude)[0...prefix_len]
177
+ end
178
+
179
+ def narrow_region(digit, latitude, longitude)
180
+ if digit == 0
181
+ latitude /= 20
182
+ longitude /= 20
183
+ elsif digit < 10
184
+ latitude *= 20
185
+ longitude *= 20
186
+ else
187
+ latitude *= 5
188
+ longitude *= 4
189
+ end
190
+ [latitude, longitude]
191
+ end
192
+
193
+ def build_code(digit_count, code, latitude, longitude)
194
+ lat_digit = latitude.to_i
195
+ lng_digit = longitude.to_i
196
+ if digit_count < 10
197
+ code << CODE_ALPHABET[lat_digit]
198
+ code << CODE_ALPHABET[lng_digit]
199
+ [digit_count + 2, latitude - lat_digit, longitude - lng_digit]
200
+ else
201
+ code << CODE_ALPHABET[4 * lat_digit + lng_digit]
202
+ [digit_count + 1, latitude - lat_digit, longitude - lng_digit]
203
+ end
204
+ end
205
+
206
+ def valid_length?(code)
207
+ !code.nil? && code.length >= 2 + SEPARATOR.length && code.split(SEPARATOR).last.length != 1
208
+ end
209
+
210
+ def valid_separator?(code)
211
+ separator_idx = code.index(SEPARATOR)
212
+ code.count(SEPARATOR) == 1 && separator_idx <= SEPARATOR_POSITION && separator_idx.even?
213
+ end
214
+
215
+ def valid_padding?(code)
216
+ if code.include?(PADDING)
217
+ return false if code.start_with?(PADDING)
218
+ return false if code[-2..-1] != PADDING + SEPARATOR
219
+
220
+ paddings = code.scan(/#{PADDING}+/)
221
+ return false if !paddings.one? || paddings[0].length.odd?
222
+ return false if paddings[0].length > SEPARATOR_POSITION - 2
223
+ end
224
+ true
225
+ end
226
+
227
+ def valid_character?(code)
228
+ code.chars.each { |ch| return false if DECODE[ch.ord].nil? }
229
+ true
230
+ end
231
+
232
+ def invalid_length?(code_length)
233
+ code_length < 2 || (code_length < SEPARATOR_POSITION && code_length.odd?)
234
+ end
235
+
236
+ def padded(code)
237
+ code << PADDING * (SEPARATOR_POSITION - code.length) << SEPARATOR
238
+ end
239
+
240
+ def precision_by_length(code_length)
241
+ if code_length <= 10
242
+ precision = 20 ** ((code_length / -2).to_i + 2)
243
+ else
244
+ precision = (20 ** -3) / (5 ** (code_length - 10))
245
+ end
246
+ precision.to_r
247
+ end
248
+
249
+ def clip_latitude(latitude)
250
+ [90.0, [-90.0, latitude].max].min
251
+ end
252
+
253
+ def normalize_longitude(longitude)
254
+ until longitude < 180
255
+ longitude -= 360
256
+ end
257
+ until longitude >= -180
258
+ longitude += 360
259
+ end
260
+ longitude
261
+ end
262
+ end
263
+
264
+ end
@@ -0,0 +1,90 @@
1
+ require 'test/unit'
2
+ require_relative '../lib/plus_codes/open_location_code'
3
+
4
+ class PlusCodesTest < Test::Unit::TestCase
5
+
6
+ def setup
7
+ @test_data_folder_path = File.join(File.dirname(__FILE__), '..', '..', 'test_data')
8
+ @olc = PlusCodes::OpenLocationCode.new
9
+ end
10
+
11
+ def test_validity
12
+ read_csv_lines('validityTests.csv').each do |line|
13
+ cols = line.split(',')
14
+ code = cols[0]
15
+ is_valid = cols[1] == 'true'
16
+ is_short = cols[2] == 'true'
17
+ is_full = cols[3] == 'true'
18
+ is_valid_olc = @olc.valid?(code)
19
+ is_short_olc = @olc.short?(code)
20
+ is_full_olc = @olc.full?(code)
21
+ result = is_valid_olc == is_valid && is_short_olc == is_short && is_full_olc == is_full
22
+ assert_true(result)
23
+ end
24
+ end
25
+
26
+ def test_encode_decode
27
+ read_csv_lines('encodingTests.csv').each do |line|
28
+ cols = line.split(',')
29
+ code_area = @olc.decode(cols[0])
30
+ if cols[0].index('0')
31
+ code = @olc.encode(cols[1].to_f, cols[2].to_f, cols[0].index('0'))
32
+ else
33
+ code = @olc.encode(cols[1].to_f, cols[2].to_f, cols[0].length - 1)
34
+ end
35
+ assert_equal(cols[0], code)
36
+ assert_true((code_area.south_latitude - cols[3].to_f).abs < 0.001)
37
+ assert_true((code_area.west_longitude - cols[4].to_f).abs < 0.001)
38
+ assert_true((code_area.north_latitude - cols[5].to_f).abs < 0.001)
39
+ assert_true((code_area.east_longitude - cols[6].to_f).abs < 0.001)
40
+ end
41
+ end
42
+
43
+ def test_shorten
44
+ read_csv_lines('shortCodeTests.csv').each do |line|
45
+ cols = line.split(',')
46
+ code = cols[0]
47
+ lat = cols[1].to_f
48
+ lng = cols[2].to_f
49
+ short_code = cols[3]
50
+ short = @olc.shorten(code, lat, lng)
51
+ assert_equal(short_code, short)
52
+ expanded = @olc.recover_nearest(short, lat, lng)
53
+ assert_equal(code, expanded)
54
+ end
55
+ @olc.shorten('9C3W9QCJ+2VX', 60.3701125, 10.202665625)
56
+ end
57
+
58
+ def test_longer_encoding_with_special_case
59
+ assert_equal('CFX3X2X2+X2RRRRJ', @olc.encode(90.0, 1.0, 15));
60
+ end
61
+
62
+ def test_exceptions
63
+ assert_raise ArgumentError do
64
+ @olc.encode(20, 30, 1)
65
+ end
66
+ assert_raise ArgumentError do
67
+ @olc.recover_nearest('9C3W9QCJ-2VX', 51.3708675, -1.217765625)
68
+ end
69
+ @olc.recover_nearest('9C3W9QCJ+2VX', 51.3708675, -1.217765625)
70
+ assert_raise ArgumentError do
71
+ @olc.decode('sfdg')
72
+ end
73
+ assert_raise ArgumentError do
74
+ @olc.shorten('9C3W9Q+', 1, 2)
75
+ end
76
+ assert_raise ArgumentError do
77
+ @olc.shorten('9C3W9Q00+', 1, 2)
78
+ end
79
+ end
80
+
81
+ def test_valid_with_special_case
82
+ assert_false(@olc.valid?('3W00CJJJ+'))
83
+ end
84
+
85
+ def read_csv_lines(csv_file)
86
+ f = File.open(File.join(@test_data_folder_path, csv_file), 'r')
87
+ f.each_line.lazy.select { |line| line !~ /^\s*#/ }.map { |line| line.chop }
88
+ end
89
+
90
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: open-location-code
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Wei-Ming Wu
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-10-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.7'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: test-unit
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: yard
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: Ruby implementation of Google Open Location Code(Plus+Codes)
84
+ email:
85
+ - wnameless@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - lib/plus_codes.rb
91
+ - lib/plus_codes/code_area.rb
92
+ - lib/plus_codes/open_location_code.rb
93
+ - test/plus_codes_test.rb
94
+ homepage: https://github.com/google/open-location-code
95
+ licenses:
96
+ - Apache License, Version 2.0
97
+ metadata: {}
98
+ post_install_message:
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubyforge_project:
114
+ rubygems_version: 2.4.5
115
+ signing_key:
116
+ specification_version: 4
117
+ summary: Ruby implementation of Google Open Location Code(Plus+Codes)
118
+ test_files:
119
+ - test/plus_codes_test.rb