File Coverage

blib/lib/Geo/Location/Point.pm
Criterion Covered Total %
statement 109 128 85.1
branch 44 60 73.3
condition 37 48 77.0
subroutine 21 24 87.5
pod 13 13 100.0
total 224 273 82.0


line stmt bran cond sub pod time code
1             package Geo::Location::Point;
2              
3 10     10   2089558 use 5.10.0; # For the //= operator
  10         56  
4 10     10   57 use strict;
  10         18  
  10         237  
5 10     10   84 use warnings;
  10         17  
  10         575  
6              
7 10     10   56 use Carp;
  10         33  
  10         830  
8 10     10   5139 use GIS::Distance;
  10         36693  
  10         481  
9 10     10   4843 use Params::Get 0.13;
  10         138112  
  10         977  
10 10     10   113 use Scalar::Util;
  10         20  
  10         1007  
11              
12             use overload (
13             '==' => \&equal,
14             '!=' => \¬_equal,
15             '""' => \&as_string,
16 1     1   228 bool => sub { 1 },
17 10         114 fallback => 1 # So that boolean tests don't cause as_string to be called
18 10     10   84 );
  10         38  
19              
20             =head1 NAME
21              
22             Geo::Location::Point - Location information
23              
24             =head1 VERSION
25              
26             Version 0.15
27              
28             =cut
29              
30             our $VERSION = '0.15';
31              
32             =head1 SYNOPSIS
33              
34             Geo::Location::Point encapsulates geographical point data with latitude and longitude.
35             It supports distance calculations,
36             comparison between points,
37             and provides various convenience methods for attributes like latitude, longitude, and related string representations.
38              
39             use Geo::Location::Point;
40              
41             my $location = Geo::Location::Point->new(latitude => 0.01, longitude => -71);
42              
43             =head1 SUBROUTINES/METHODS
44              
45             =head2 new
46              
47             Initialise a new object, accepting latitude and longitude via a hash or hash reference.
48             Takes one optional argument 'key' which is an API key for L for looking up timezone data.
49              
50             $location = Geo::Location::Point->new({ latitude => 0.01, longitude => -71 });
51              
52             =cut
53              
54             sub new {
55 35     35 1 1580235 my $class = shift;
56 35         169 my $params = Params::Get::get_params(undef, \@_);
57              
58 34 100       1139 if(!defined($class)) {
    100          
59 1         39 carp(__PACKAGE__, ' use ->new() not ::new() to instantiate');
60 1         1010 return;
61             } elsif(Scalar::Util::blessed($class)) {
62             # If $class is an object, clone it with new arguments
63 1         2 return bless { %{$class}, %{$params} }, ref($class);
  1         3  
  1         6  
64             }
65              
66 32   100     195 $params->{'lat'} //= $params->{'latitude'} // $params->{'Latitude'};
      100        
67 32 100       111 if(!defined($params->{'lat'})) {
68 2         25 Carp::carp(__PACKAGE__, ': latitude not given');
69 2         1776 return;
70             }
71 30 100       101 if(abs($params->{'lat'}) > 90) {
72 3         39 Carp::carp(__PACKAGE__, ': ', $params->{'lat'}, ': invalid latitude');
73 3         3430 return;
74             }
75              
76 27   100     162 $params->{'long'} //= $params->{'longitude'} // $params->{'Longitude'};
      100        
77 27 100       73 if(!defined($params->{'long'})) {
78 2         21 Carp::carp(__PACKAGE__, ': longitude not given');
79 2         1348 return;
80             }
81 25 100       79 if(abs($params->{'long'}) > 180) {
82 3         29 Carp::carp(__PACKAGE__, ': ', $params->{'long'}, ': invalid longitude');
83 3         3220 return;
84             }
85 22         61 $params->{'lng'} = $params->{'long'};
86              
87             # Return the blessed object
88 22         112 return bless $params, $class;
89             }
90              
91             =head2 lat
92              
93             print 'Latitude: ', $location->lat(), "\n";
94             print 'Longitude: ', $location->long(), "\n";
95              
96             =cut
97              
98             sub lat {
99 22     22 1 553 my $self = shift;
100              
101 22         129 return $self->{'lat'};
102             }
103              
104             =head2 latitude
105              
106             Synonym for lat().
107              
108             =cut
109              
110             sub latitude {
111 1     1 1 5 my $self = shift;
112              
113 1         8 return $self->{'lat'};
114             }
115              
116             =head2 long
117              
118             print 'Latitude: ', $location->lat(), "\n";
119             print 'Longitude: ', $location->long(), "\n";
120              
121             =cut
122              
123             sub long {
124 14     14 1 32 my $self = shift;
125              
126 14         82 return $self->{'long'};
127             }
128              
129             =head2 lng
130              
131             Synonym for long().
132              
133             =cut
134              
135             sub lng {
136 0     0 1 0 my $self = shift;
137              
138 0         0 return $self->{'long'};
139             }
140              
141             =head2 longitude
142              
143             Synonym for long().
144              
145             =cut
146              
147             sub longitude {
148 1     1 1 2 my $self = shift;
149              
150 1         6 return $self->{'long'};
151             }
152              
153             =head2 distance
154              
155             Determine the distance between two geographical points,
156             returns a L object.
157              
158             =cut
159              
160             sub distance {
161 5     5 1 2360 my ($self, $location) = @_;
162              
163 5 100       34 if(!defined($location)) {
164 1         6 Carp::carp('Usage: ', __PACKAGE__, '->distance($location)');
165 1         350 return;
166             }
167              
168 4   66     70 $self->{'gis'} //= GIS::Distance->new();
169              
170 4         108443 return $self->{'gis'}->distance($self->{'lat'}, $self->{'long'}, $location->lat(), $location->long());
171             }
172              
173             =head2 equal
174              
175             Check if two points are identical within a small tolerance.
176              
177             my $loc1 = Geo::Location::Point->new(lat => 2, long => 2);
178             my $loc2 = Geo::Location::Point->new(lat => 2, long => 2);
179             print ($loc1 == $loc2), "\n"; # Prints 1
180              
181             =cut
182              
183             sub equal {
184 8     8 1 630 my $self = shift;
185 8         18 my $other = shift;
186              
187             # return ($self->distance($other) <= 1e-5);
188             # 1e-5 is about 1m, that's tolerant enough
189 8   66     54 return((abs($self->lat() - $other->lat()) <= 1e-5) && (abs(($self->long() - $other->long())) <= 1e-5));
190             }
191              
192             =head2 not_equal
193              
194             Are two points different?
195              
196             my $loc1 = Geo::Location::Point->new(lat => 2, long => 2);
197             my $loc2 = Geo::Location::Point->new(lat => 2, long => 2);
198             print ($loc1 != $loc2), "\n"; # Prints 0
199              
200             =cut
201              
202             sub not_equal {
203 4     4 1 14 my $self = shift;
204              
205 4         15 return(!$self->equal(shift));
206             }
207              
208             =head2 tz
209              
210             Returns the timezone of the location.
211             Needs L.
212              
213             =cut
214              
215             sub tz {
216 0     0 1 0 my $self = shift;
217              
218 0 0       0 if(defined($self->{'key'})) {
219 0 0       0 return $self->{'tz'} if(defined($self->{'tz'}));
220              
221 0 0       0 if(!defined($self->{'timezonedb'})) {
222 0 0       0 unless(TimeZone::TimeZoneDB->can('get_time_zone')) {
223 0         0 require TimeZone::TimeZoneDB;
224 0         0 TimeZone::TimeZoneDB->import();
225             }
226              
227 0         0 $self->{'timezonedb'} = TimeZone::TimeZoneDB->new(key => $self->{'key'});
228             }
229 0         0 $self->{'tz'} = $self->{'timezonedb'}->get_time_zone($self)->{'zoneName'};
230              
231 0         0 return $self->{'tz'};
232             }
233             }
234              
235             =head2 timezone
236              
237             Synonym for tz().
238              
239             =cut
240              
241             sub timezone {
242 0     0 1 0 my $self = shift;
243              
244 0         0 return $self->tz();
245             }
246              
247             =head2 as_string
248              
249             Generate a human-readable string describing the point,
250             incorporating additional attributes like city or country if available.
251              
252             =cut
253              
254             sub as_string {
255 6     6 1 944 my $self = shift;
256              
257 6 100       44 if($self->{'location'}) {
258 1         5 return $self->{'location'};
259             }
260              
261 5         8 my $rc = $self->{'name'};
262 5 50       13 if($rc) {
263 0         0 $rc = ucfirst(lc($rc));
264             }
265              
266             # TODO: make this order configurable
267             # foreach my $field('house_number', 'number', 'road', 'street', 'AccentCity', 'city', 'county', 'region', 'state_district', 'state', 'country') {
268 5         12 foreach my $field('house_number', 'number', 'road', 'street', 'city', 'county', 'region', 'state_district', 'state', 'country') {
269 50 100 100     193 if(my $value = ($self->{$field} || $self->{ucfirst($field)})) {
270 17 100       35 if($rc) {
    50          
271 12 100 66     39 if(($field eq 'street') || ($field eq 'road')) {
272 2 50 33     8 if($self->{'number'} || $self->{'house_number'}) {
273 2         5 $rc .= ' ';
274             } else {
275 0         0 $rc .= ', '
276             }
277             } else {
278 10         15 $rc .= ', ';
279             }
280             } elsif($rc) {
281 0         0 $rc .= ', ';
282             }
283 17         24 my $leave_case = 0;
284 17 50 66     53 if(my $country = $self->{'country'} // $self->{'Country'}) {
285 17 100 33     47 if(uc($country) eq 'US') {
    50          
    50          
286 14 100 100     86 if(($field eq 'state') || ($field eq 'region') || ($field eq 'country')) {
      100        
287 6         12 $leave_case = 1;
288 6 100       16 if(lc($field) eq 'country') {
289 4         22 $value = 'US';
290             }
291             }
292             } elsif(($country eq 'Canada') || ($country eq 'Australia')) {
293 0 0       0 if($field eq 'state') {
294 0         0 $leave_case = 1;
295             }
296             } elsif(uc($country) eq 'GB') {
297 3 100       6 if($field eq 'country') {
298 1         2 $leave_case = 1;
299 1         2 $value = 'GB';
300             }
301             }
302             }
303 17 100       29 if($leave_case) {
304 7         20 $rc .= $value;
305             } else {
306 10         26 $rc .= $self->_sortoutcase($value);
307 10 100 66     64 if((($field eq 'street') || ($field eq 'road')) &&
      66        
308             ($rc =~ /(.+)\s([NS][ew])$/)) {
309             # e.g South Street NW
310 2         14 $rc = "$1 " . uc($2);
311             }
312             }
313             }
314             }
315              
316 5         43 return $self->{'location'} = $rc;
317             }
318              
319             sub _sortoutcase
320             {
321             # Use lc to ensure the input string is in lowercase before capitalisation,
322             # split to break the string into words,
323             # map to capitalise each word and
324             # join to concatenate the capitalised words back into a single string with spaces
325 10     10   30 return join ' ', map { ucfirst } split ' ', lc($_[1]);
  15         58  
326             }
327              
328             =head2 as_uri
329              
330             Convert the point to a Geo URI scheme string (geo:latitude,longitude).
331             See L.
332             Arguably it should return a L object instead.
333              
334             =cut
335              
336             sub as_uri
337             {
338 2     2 1 830 my $self = shift;
339              
340 2         62 return 'geo:' . $self->{'lat'} . ',' . $self->{'long'};
341             }
342              
343             =head2 attr
344              
345             Get or set arbitrary attributes, such as city or country.
346              
347             $location->city('London');
348             $location->country('UK');
349             print $location->as_string(), "\n";
350             print "$location\n"; # Calls as_string
351              
352             =cut
353              
354             sub AUTOLOAD {
355 27     27   9865 our $AUTOLOAD;
356 27         53 my $key = $AUTOLOAD;
357              
358 27         160 $key =~ s/.*:://;
359              
360 27 100       911 return if($key eq 'DESTROY');
361              
362 4         5 my $self = shift;
363              
364 4 100       9 if(my $value = shift) {
365 1         3 delete $self->{'location'}; # Invalidate the cache
366 1         1 delete $self->{'tz'}; # Invalidate the cache
367 1         3 $self->{$key} = $value;
368             }
369              
370 4   66     22 return $self->{$key} || $self->{ucfirst($key)}
371             }
372              
373             =head1 SUPPORT
374              
375             This module is provided as-is without any warranty.
376              
377             =head1 AUTHOR
378              
379             Nigel Horne, C<< >>
380              
381             =head1 BUGS
382              
383             There is no validation on the attribute in the AUTOLOAD method,
384             so typos such as "citty" will not be caught.
385              
386             =head1 SEE ALSO
387              
388             L,
389             L,
390             L.
391              
392             =head1 LICENSE AND COPYRIGHT
393              
394             Copyright 2019-2025 Nigel Horne.
395              
396             The program code is released under the following licence: GPL2 for personal use on a single computer.
397             All other users (including Commercial, Charity, Educational, Government)
398             must apply in writing for a licence for use from Nigel Horne at ``.
399              
400             =cut
401              
402             1;