open-location-code 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 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