File Coverage

blib/lib/Geo/OLC.pm
Criterion Covered Total %
statement 129 138 93.4
branch 49 58 84.4
condition 4 5 80.0
subroutine 16 17 94.1
pod 8 8 100.0
total 206 226 91.1


line stmt bran cond sub pod time code
1             # Google's Open Location Code
2             # https://github.com/google/open-location-code
3              
4             package Geo::OLC;
5              
6 5     5   68371 use strict;
  5         7  
  5         123  
7 5     5   17 use warnings;
  5         5  
  5         131  
8 5     5   20 use List::Util qw(min max);
  5         11  
  5         757  
9              
10             =head1 NAME
11              
12             Geo::OLC - API for Google's Open Location Codes
13              
14             =head1 SYNOPSIS
15              
16             Open Location Codes are a Google-created method of reducing a
17             Latitude/Longitude pair to a short string. This module implements the
18             recommended API from L
19              
20             use Geo::OLC qw(encode decode shorten recover_nearest);
21             use Geo::OLC qw(:all);
22              
23             $code = encode(34.6681375,135.502765625,11);
24             # '8Q6QMG93+742'
25              
26             $ref = decode('8Q6QMG93+742');
27             # @{$ref->{center}} == (34.6681375,135.502765625)
28              
29             $short = shorten('8Q6QMG93+742',34.6937048,135.5016142);
30             # 'MG93+742' ("...in Osaka")
31             $short = shorten('8Q6QMG93+742',34.6788184,135.4987303);
32             # '93+742' ("...in Chuo Ward, Osaka")
33              
34             $full = recover_nearest('XQP5+',35.0060799,135.6909098);
35             # '8Q6QXQP5+' (Kyoto Station, "XQP5+ in Kyoto")
36              
37             By default, Geo::OLC does not export any functions.
38              
39             =cut
40              
41             BEGIN {
42 5     5   20 require Exporter;
43 5         6 our $VERSION = 1.00;
44 5         32 our @ISA = qw(Exporter);
45 5         9 our @EXPORT = qw();
46 5         11 our @EXPORT_OK = qw(is_valid is_short is_full encode decode
47             shorten shorten46 recover_nearest _code_digits);
48 5         6161 our %EXPORT_TAGS = (all => \@EXPORT_OK);
49             }
50              
51             # set up the radix-20 digits
52             #
53             my $radix = '23456789CFGHJMPQRVWX';
54             my @rchar = split(//,$radix);
55             my $_i=0;
56             my %rval = map(($_=>$_i++),@rchar);
57              
58             # calculate size of grid for each length (lat/lon differ after 10)
59             #
60             my @LAT;
61             my @LON;
62             my $m = 20;
63             foreach my $i (2,4,6,8,10) {
64             $LAT[$i] = $m;
65             $LON[$i] = $m;
66             $m /= 20;
67             }
68             foreach my $i (11..16) {
69             $LAT[$i] = $LAT[$i-1]/5;
70             $LON[$i] = $LON[$i-1]/4;
71             }
72              
73             =head1 FUNCTIONS
74              
75             =head2 is_valid($code)
76              
77             Returns 1 if $code is a valid short, full, or zero-padded OLC.
78              
79             =cut
80              
81             sub is_valid {
82 353     353 1 68995 my ($code) = @_;
83 353         572 my $plus = index($code,'+');
84 353 100       1104 return 0 unless grep($_ == $plus,0,2,4,6,8);
85 341         405 $code =~ tr/a-z/A-Z/;
86 341         723 my ($pre,$post) = split(/\+/,$code,2);
87 341 100       750 if (index($code,'0') > -1) {
88 29 100       90 return 0 if $post ne '';
89 17         29 $pre =~ tr/0//d;
90             }
91 329 100       522 if ($pre) {
92 313 50       596 return 0 if length($pre) % 2;
93 313 100       1086 return 0 if $pre !~ /^[$radix]+$/o;
94             }
95 323 100       508 if ($post) {
96 231 100       440 return 0 if length($post) == 1;
97 219 100       518 return 0 if $post !~ /^[$radix]+$/o;
98             }
99 305 100       602 return 0 if $pre.$post eq '';
100 299         661 return 1;
101             }
102              
103             =head2 is_short($code)
104              
105             Returns 1 if $code is a valid shortened code.
106              
107             =cut
108              
109             sub is_short {
110 34     34 1 64 my ($code) = @_;
111 34 100       40 return 0 unless is_valid($code);
112 16         21 return 1 - is_full($code);
113             }
114              
115             =head2 is_full($code)
116              
117             Returns 1 if $code is a valid full-length code, and has lat < 90
118             and lon < 180.
119              
120             =cut
121              
122             sub is_full {
123 257     257 1 270 my ($code) = @_;
124 257 100       266 return 0 unless is_valid($code);
125 239 100       581 return 0 unless index($code,'+') == 8;
126             # check for lat/lon out of range
127             # CVXXXXXX+XXX == 89.999975,179.99996875
128 206 50       465 return 0 if uc(substr($code,0,1)) gt 'C';
129 206 50       367 return 0 if uc(substr($code,1,1)) gt 'V';
130 206         370 return 1;
131             }
132              
133             =head2 encode($lat,$lon,[$len])
134              
135             Encodes a location as OLC. $len can be 2, 4, 6, 8, 10, or 11-16.
136             The default $len is 10, which is approximately 13.9x13.9 meters
137             at the equator; 11 brings that down to 3.5x2.8 meters, and 12
138             is about 0.9x0.6 meters, so there's not much point in going past
139             that. I only go to 16 because there's a test case for the Ruby
140             API that uses 15.
141              
142             =cut
143              
144             sub encode {
145 169     169 1 201 my ($lat,$lon,$len) = _norm(@_);
146 169         142 my $code;
147 169         225 foreach my $i (0..4) {
148 845 100       1123 $code .= '+' if $i==4;
149 845         1021 my $tmplat = int($lat / $LAT[($i+1)*2]);
150 845         845 $code .= $rchar[$tmplat];
151 845         856 $lat -= $tmplat * $LAT[($i+1)*2];
152 845         926 my $tmplon = int($lon / $LON[($i+1)*2]);
153 845         732 $code .= $rchar[$tmplon];
154 845         1196 $lon -= $tmplon * $LON[($i+1)*2];
155             }
156 169 100       341 if ($len < 10) {
    100          
157 9         27 $code = substr($code,0,$len) . ('0' x (8 - $len)) . '+';
158             }elsif ($len > 10) {
159 3         5 foreach my $i (11..$len) {
160 9         11 my $gridlat = int($lat / $LAT[$i]);
161 9         11 $lat -= $gridlat * $LAT[$i];
162 9         10 my $gridlon = int($lon / $LON[$i]);
163 9         9 $lon -= $gridlon * $LON[$i];
164 9         17 $code .= $rchar[$gridlat*4+$gridlon];
165             }
166             }
167 169         306 return $code;
168             }
169              
170             =head2 decode($code)
171              
172             Decodes a valid OLC into its location, returned as three pairs of
173             lat/lon coordinates, plus the length of the original code. 'lower'
174             and 'upper' are the bounding-box of the OLC grid, and 'center' is
175             the target location.
176              
177             $ref = decode('8Q6QMG93+742');
178              
179             $ref = {
180             lower => [34.668125,135.50275],
181             center=> [34.6681375,135.502765625],
182             upper => [34.66815,135.50278125],
183             length=> 11,
184             };
185              
186             =cut
187              
188             sub decode {
189 179     179 1 3104 my ($code) = @_;
190 179 50       209 if (!is_full($code)) {
191 0         0 warn "decode(): invalid or short code '$code'\n";
192 0         0 return undef;
193             }
194 179         201 $code =~ tr/a-z/A-Z/;
195 179         182 $code =~ tr/+0//d;
196 179         192 my ($lat,$lon) = (0,0);
197 179         186 my $len = length($code);
198 179         152 my $origlen = $len;
199 179         299 while ($len > 10) {
200 112         165 my $n = $rval{chop($code)};
201 112         140 my $latoffset = int($n/4);
202 112         94 my $lonoffset = $n - $latoffset * 4;
203 112         153 $lat += $latoffset * $LAT[$len];
204 112         116 $lon += $lonoffset * $LON[$len];
205 112         226 $len -= 1;
206             }
207 179         188 foreach my $i (2,4,6,8,10) {
208 878 100       1116 last if $i > $len;
209 822         933 my $latchar = substr($code,$i-2,1);
210 822         820 $lat += $rval{$latchar} * $LAT[$i];
211 822         832 my $lonchar = substr($code,$i-1,1);
212 822         1176 $lon += $rval{$lonchar} * $LON[$i];
213             }
214 179         158 my $latsize = $LAT[$origlen];
215 179         117 my $lonsize = $LON[$origlen];
216             return {
217 179         225 lower => [_denorm($lat,$lon)],
218             center => [_denorm($lat+$latsize/2,$lon+$lonsize/2)],
219             upper => [_denorm($lat+$latsize,$lon+$lonsize)],
220             length => $origlen,
221             };
222             }
223              
224             =head2 shorten($code,$latref,$lonref)
225              
226             Shortens a valid full-length code based on the reference location;
227             returns the original code if it can't be shortened.
228              
229             Note that removing 2 or 8 digits is not necessarily practical, since
230             there may not be useful names for the area covered, but it's necessary
231             for API testing. I recommend using shorten46() instead.
232              
233             =cut
234              
235             sub shorten {
236 11     11 1 71870 return _shorten(@_,8,6,4,2);
237             }
238              
239             =head2 shorten46($code,$latref,$lonref)
240              
241             Shortens a valid full-length code by 4 or 6 digits, based on the
242             reference location.
243              
244             =cut
245              
246             sub shorten46 {
247 0     0 1 0 return _shorten(@_,6,4);
248             }
249              
250             # common code used by shorten and shorten46
251             #
252             sub _shorten {
253 11     11   23 my ($code,$lat,$lon,@lengths) = @_;
254 11 50       16 return undef if !is_valid($code);
255 11 50       15 return $code if !is_full($code);
256 11         22 my $ref = decode($code);
257 11         32 ($lat,$lon) = _denorm(_norm($lat,$lon,_code_digits($code)));
258             my $distance = max(abs($lat - $ref->{center}->[0]),
259 11         53 abs($lon - $ref->{center}->[1]));
260 11         20 foreach my $dist (@lengths) {
261 25 100       109 return substr($code,$dist) if $distance < $LON[$dist] * 0.3;
262             }
263 0         0 return $code;
264             }
265              
266              
267             =head2 recover_nearest($shortcode,$latref,$lonref)
268              
269             Converts a shortened OLC back into a full-length code, using the
270             reference location to supply the missing digits. Note that the
271             resulting code will not necessarily have the same leading digits
272             as the reference location, if it's not in the same grid.
273              
274             =cut
275              
276             sub recover_nearest {
277 17     17 1 72338 my ($code,$lat,$lon) = @_;
278 17 50       30 if (!is_valid($code)) {
279 0         0 warn "recover_nearest(): invalid code '$code'\n";
280 0         0 return undef;
281             }
282 17 50       27 return $code if is_full($code);
283              
284 17         31 ($lat,$lon) = _denorm(_norm($lat,$lon));
285 17         44 my $removed = 8 - index($code,'+');
286 17         25 my $size = $LAT[$removed];
287 17         11 my $distance = 9999;
288 17         12 my $closest = '';
289 17         34 foreach my $latoff (-$size, 0, $size) {
290 51         69 foreach my $lonoff (-$size, 0, $size) {
291 153         270 my $refcode = encode($lat + $latoff,$lon + $lonoff);
292 153         290 my $testcode = substr($refcode,0,$removed) . $code;
293 153         172 my $testloc = decode($testcode);
294 153         371 my $latdiff = $testloc->{center}->[0] - $lat;
295 153         155 my $londiff = $testloc->{center}->[1] - $lon;
296 153         279 my $tmpdist = sqrt($latdiff**2 + $londiff**2);
297 153 100       437 if ($tmpdist < $distance) {
298 61         52 $distance = $tmpdist;
299 61         181 $closest = $testcode;
300             }
301             }
302             }
303 17         68 return $closest;
304             }
305              
306             =head2 _code_digits($code)
307              
308             Returns number of non-padded digits in a code; used internally by
309             shorten() and shorten46(), and useful for testing.
310              
311             =cut
312              
313             sub _code_digits {
314 27     27   68770 my ($code) = @_;
315 27 100       80 if ($code =~ /(0+)\+$/) {
316 9         31 return 8 - length($1);
317             }else{
318 18         53 return length($code) - 1;
319             }
320             }
321              
322             # normalize lat/lon for use in encoding
323             #
324             sub _norm {
325 197     197   225 my ($lat,$lon,$len) = @_;
326 197   100     540 $len ||= 10;
327 197 50 66     729 if ($len <= 10 && $len % 2) {
328 0         0 warn "invalid code length '$len', setting to 10\n";
329 0         0 $len = 10;
330             }
331 197         456 $lat = min(90,max(-90,$lat));
332 197 100       352 $lat -= $LAT[$len] if $lat == 90;
333 197         356 while ($lon <= -180) {
334 0         0 $lon += 360;
335             }
336 197         295 while ($lon >= 180) {
337 2         5 $lon -= 360;
338             }
339 197         431 return ($lat+90,$lon+180,$len);
340             }
341              
342             # restore lat/lon to standard range
343             #
344             sub _denorm {
345 565     565   583 my ($lat,$lon) = @_;
346 565         774 return (_defloat($lat-90),_defloat($lon-180));
347             }
348              
349             # hide floating-point artifacts; cheaper than using bignums
350             #
351             sub _defloat {
352 1130     1130   945 my ($n) = @_;
353 1130         3061 $n =~ s/(\.\d+?)(0{4,}[12])$/$1/;
354 1130         3031 return $n;
355             }
356              
357             =head1 AUTHOR
358              
359             J Greely, C<< >>
360              
361             =head1 LOCATIONS
362              
363             8Q6QMG93+742 is Tenka Gyoza in Osaka, home of the best one-bite
364             gyoza you'll ever devour multiple plates of ("+742 Dotonbori Osaka").
365              
366             8FW4V75V+ is the Eiffel Tower ("V75V+ Paris").
367              
368             86HJW8XV+ is Wrigley Field ("W8XV+8Q Chicago").
369              
370             849VCRVJ+CHV is a pair of ATMs at Stanford Mall, Palo Alto, CA
371             ("CRVJ+CHV Palo Alto").
372              
373             =head1 BUGS
374              
375             The off-by-one-cell code in recover_nearest() is largely untested,
376             because there are no test cases for it. The "XQP5+ in Kyoto" case
377             in the synopsis is a simple test I came up with, since most of
378             Kyoto is in 8Q7Q0000+, but the station is just across the border
379             in 8Q6Q0000+.
380              
381             =cut
382              
383             1;