Skip to content

Commit 71aacf8

Browse files
committed
Merge branch 'wnameless-master'
Add Ruby implementation with travis-ci integration.
2 parents 4c4af81 + 674314e commit 71aacf8

File tree

6 files changed

+430
-4
lines changed

6 files changed

+430
-4
lines changed

.travis.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@ language: node_js
55
node_js:
66
- "0.10"
77

8+
89
# Install package dependencies. See
910
# http://docs.travis-ci.com/user/installing-dependencies/
1011
# gulp: required for JS testing
11-
before_script:
12+
before_install:
1213
- npm install -g gulp
1314

1415
# Define the list of directories to execute tests in.
1516
env:
1617
- TEST_DIR=js
1718
- TEST_DIR=go
19+
- TEST_DIR=ruby
1820

1921
# Test script to run. This is called once for each TEST_DIR value above.
2022
script: ./run_tests.sh

ruby/lib/plus_codes.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

ruby/lib/plus_codes/code_area.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)