File Coverage

File:blib/lib/Geo/Location/Point.pm
Coverage:86.6%

linestmtbrancondsubtimecode
1package Geo::Location::Point;
2
3
10
10
665928
18
use 5.10.0;     # For the //= operator
4
10
10
10
19
9
84
use strict;
5
10
10
10
17
6
186
use warnings;
6
7
10
10
10
18
7
240
use Carp;
8
10
10
10
1877
12511
188
use GIS::Distance;
9
10
10
10
1977
167039
219
use Params::Get 0.13;
10
10
10
10
30
7
170
use Readonly;
11
10
10
10
24
11
327
use Scalar::Util;
12
13use overload (
14        '==' => \&equal,
15        '!=' => \&not_equal,
16        '""' => \&as_string,
17
1
95
        bool => sub { 1 },
18
10
9081
        fallback => 1        # So that boolean tests don't cause as_string to be called
19
10
10
18
7
);
20
21 - 29
=head1 NAME

Geo::Location::Point - Location information

=head1 VERSION

Version 0.19

=cut
30
31our $VERSION = '0.19';
32
33 - 53
=head1 SYNOPSIS

Geo::Location::Point encapsulates geographical point data with latitude and longitude.
It supports distance calculations,
comparison between points,
and provides various convenience methods for attributes like latitude, longitude, and related string representations.

    use Geo::Location::Point;

    my $location = Geo::Location::Point->new(latitude => 0.01, longitude => -71);

=head1 SUBROUTINES/METHODS

=head2 new

Initialise a new object, accepting latitude and longitude via a hash or hash reference.
Takes one optional argument 'key' which is an API key for L<https://timezonedb.com> for looking up timezone data.

    $location = Geo::Location::Point->new({ latitude => 0.01, longitude => -71 });

=cut
54
55sub new {
56
41
625418
        my $class = shift;
57
41
94
        my $params = Params::Get::get_params(undef, \@_);
58
59
40
689
        if(!defined($class)) {
60
1
8
                carp(__PACKAGE__, ' use ->new() not ::new() to instantiate');
61
1
113
                return;
62        } elsif(Scalar::Util::blessed($class)) {
63                # If $class is an object, clone it with new arguments
64
1
1
1
1
1
4
                return bless { %{$class}, %{$params} }, ref($class);
65        }
66
67
38
115
        $params->{'lat'} //= $params->{'latitude'} // $params->{'Latitude'};
68
38
40
        if(!defined($params->{'lat'})) {
69
2
8
                Carp::carp(__PACKAGE__, ': latitude not given');
70
2
394
                return;
71        }
72
36
57
        if(abs($params->{'lat'}) > 90) {
73
3
20
                Carp::carp(__PACKAGE__, ': ', $params->{'lat'}, ': invalid latitude');
74
3
459
                return;
75        }
76
77
33
90
        $params->{'long'} //= $params->{'longitude'} // $params->{'Longitude'};
78
33
39
        if(!defined($params->{'long'})) {
79
2
8
                Carp::carp(__PACKAGE__, ': longitude not given');
80
2
220
                return;
81        }
82
31
43
        if(abs($params->{'long'}) > 180) {
83
3
16
                Carp::carp(__PACKAGE__, ': ', $params->{'long'}, ': invalid longitude');
84
3
348
                return;
85        }
86
28
38
        $params->{'lng'} = $params->{'long'};
87
88        # Return the blessed object
89
28
52
        return bless $params, $class;
90}
91
92 - 97
=head2 lat

    print 'Latitude: ', $location->lat(), "\n";
    print 'Longitude: ', $location->long(), "\n";

=cut
98
99sub lat {
100
22
180
        my $self = shift;
101
102
22
44
        return $self->{'lat'};
103}
104
105 - 109
=head2 latitude

Synonym for lat().

=cut
110
111sub latitude {
112
1
3
        my $self = shift;
113
114
1
3
        return $self->{'lat'};
115}
116
117 - 122
=head2 long

    print 'Latitude: ', $location->lat(), "\n";
    print 'Longitude: ', $location->long(), "\n";

=cut
123
124sub long {
125
14
18
        my $self = shift;
126
127
14
28
        return $self->{'long'};
128}
129
130 - 134
=head2  lng

Synonym for long().

=cut
135
136sub lng {
137
0
0
        my $self = shift;
138
139
0
0
        return $self->{'long'};
140}
141
142 - 146
=head2 longitude

Synonym for long().

=cut
147
148sub longitude {
149
1
1
        my $self = shift;
150
151
1
2
        return $self->{'long'};
152}
153
154 - 159
=head2  distance

Determine the distance between two geographical points,
returns a L<Class::Measure::Length> object.

=cut
160
161sub distance {
162
5
969
        my ($self, $location) = @_;
163
164
5
9
        if(!defined($location)) {
165
1
2
                Carp::carp('Usage: ', __PACKAGE__, '->distance($location)');
166
1
142
                return;
167        }
168
169
4
32
        $self->{'gis'} //= GIS::Distance->new();
170
171
4
46577
        return $self->{'gis'}->distance($self->{'lat'}, $self->{'long'}, $location->lat(), $location->long());
172}
173
174 - 182
=head2  equal

Check if two points are identical within a small tolerance.

    my $loc1 = Geo::Location::Point->new(lat => 2, long => 2);
    my $loc2 = Geo::Location::Point->new(lat => 2, long => 2);
    print ($loc1 == $loc2), "\n";     # Prints 1

=cut
183
184sub equal {
185
8
165
        my $self = shift;
186
8
6
        my $other = shift;
187
188        # return ($self->distance($other) <= 1e-5);
189        # 1e-5 is about 1m, that's tolerant enough
190
8
9
        return((abs($self->lat() - $other->lat()) <= 1e-5) && (abs(($self->long() - $other->long())) <= 1e-5));
191}
192
193 - 201
=head2  not_equal

Are two points different?

    my $loc1 = Geo::Location::Point->new(lat => 2, long => 2);
    my $loc2 = Geo::Location::Point->new(lat => 2, long => 2);
    print ($loc1 != $loc2), "\n";     # Prints 0

=cut
202
203sub not_equal {
204
4
4
        my $self = shift;
205
206
4
5
        return(!$self->equal(shift));
207}
208
209 - 214
=head2  tz

Returns the timezone of the location.
Needs L<TimeZone::TimeZoneDB>.

=cut
215
216sub tz {
217
0
0
        my $self = shift;
218
219
0
0
        if(defined($self->{'key'})) {
220
0
0
                return $self->{'tz'} if(defined($self->{'tz'}));
221
222
0
0
                if(!defined($self->{'timezonedb'})) {
223
0
0
                        unless(TimeZone::TimeZoneDB->can('get_time_zone')) {
224
0
0
                                require TimeZone::TimeZoneDB;
225
0
0
                                TimeZone::TimeZoneDB->import();
226                        }
227
228
0
0
                        $self->{'timezonedb'} = TimeZone::TimeZoneDB->new(key => $self->{'key'});
229                }
230
0
0
                $self->{'tz'} = $self->{'timezonedb'}->get_time_zone($self)->{'zoneName'};
231
232
0
0
                return $self->{'tz'};
233        }
234}
235
236 - 240
=head2  timezone

Synonym for tz().

=cut
241
242sub timezone {
243
0
0
        my $self = shift;
244
245
0
0
        return $self->tz();
246}
247
248 - 253
=head2  as_string

Generate a human-readable string describing the point,
incorporating additional attributes like city or country if available.

=cut
254
255sub as_string {
256
8
582
        my $self = shift;
257
258
8
22
        if($self->{'location'}) {
259
1
2
                return $self->{'location'};
260        }
261
262
7
8
        my $rc = $self->{'name'};
263
7
10
        if($rc) {
264
0
0
                $rc = ucfirst(lc($rc));
265        }
266
267        # TODO: make this order configurable
268        # foreach my $field('house_number', 'number', 'road', 'street', 'AccentCity', 'city', 'county', 'region', 'state_district', 'state', 'country') {
269
7
11
        foreach my $field('house_number', 'number', 'road', 'street', 'city', 'county', 'region', 'state_district', 'state', 'country') {
270
70
107
                if(my $value = ($self->{$field} || $self->{ucfirst($field)})) {
271
18
13
                        if($rc) {
272
12
18
                                if(($field eq 'street') || ($field eq 'road')) {
273
2
10
                                        if($self->{'number'} || $self->{'house_number'}) {
274
2
2
                                                $rc .= ' ';
275                                        } else {
276
0
0
                                                $rc .= ', '
277                                        }
278                                } else {
279
10
9
                                        $rc .= ', ';
280                                }
281                        }
282
18
12
                        my $leave_case = 0;
283
18
24
                        if(my $country = $self->{'country'} // $self->{'Country'}) {
284
17
20
                                if(uc($country) eq 'US') {
285
14
23
                                        if(($field eq 'state') || ($field eq 'region') || ($field eq 'country')) {
286
6
5
                                                $leave_case = 1;
287
6
7
                                                if(lc($field) eq 'country') {
288
4
10
                                                        $value = 'US';
289                                                }
290                                        }
291                                } elsif(($country eq 'Canada') || ($country eq 'Australia')) {
292
0
0
                                        if($field eq 'state') {
293
0
0
                                                $leave_case = 1;
294                                        }
295                                } elsif(uc($country) eq 'GB') {
296
3
4
                                        if($field eq 'country') {
297
1
1
                                                $leave_case = 1;
298
1
1
                                                $value = 'GB';
299                                        }
300                                }
301                        }
302
18
16
                        if($leave_case) {
303
7
9
                                $rc .= $value;
304                        } else {
305
11
13
                                $rc .= $self->_sortoutcase($value);
306
11
28
                                if((($field eq 'street') || ($field eq 'road')) &&
307                                   ($rc =~ /(.+)\s([NS][ew])$/)) {
308                                        # e.g South Street NW
309
2
6
                                        $rc = "$1 " . uc($2);
310                                }
311                        }
312                }
313        }
314
315
7
19
        return $self->{'location'} = $rc;
316}
317
318sub _sortoutcase
319{
320        # Use lc to ensure the input string is in lowercase before capitalisation,
321        #       split to break the string into words,
322        #       map to capitalise each word and
323        #       join to concatenate the capitalised words back into a single string with spaces
324
11
16
18
25
        return join ' ', map { ucfirst } split ' ', lc($_[1]);
325}
326
327 - 333
=head2  as_uri

Convert the point to a Geo URI scheme string (geo:latitude,longitude).
See L<https://en.wikipedia.org/wiki/Geo_URI_scheme>.
Arguably it should return a L<URI> object instead.

=cut
334
335sub as_uri
336{
337
2
241
        my $self = shift;
338
339
2
20
        return 'geo:' . $self->{'lat'} . ',' . $self->{'long'};
340}
341
342 - 364
=head2  attr

Get or set arbitrary attributes, such as city or country.
A call with an argument sets the attribute and returns the value that was set;
a call without one reads it back and returns C<undef> if it was never set.

    $location->city('London');
    $location->country('UK');
    print $location->as_string(), "\n";
    print "$location\n";      # Calls as_string

It is the argument list, not the value, that decides whether a call is a read or a write,
so false values are attributes like any other and are stored and returned unchanged:

    $location->confidence(0);
    print 'matched: ', defined($location->confidence()) && $location->confidence(), "\n";

The upper case variant of the key is consulted
(but not overwritten) when the key as given has never been set,
which is how the MaxMind field names are read. An attribute that has been set,
however false, takes precedence over it.

=cut
365
366sub AUTOLOAD {
367
36
4199
        our $AUTOLOAD;
368
36
35
        my $key = $AUTOLOAD;
369
370
36
106
        $key =~ s/.*:://;
371
372
36
323
        return if($key eq 'DESTROY');
373
374
7
6
        my $self = shift;
375
376        # The argument list, not the value, decides read or write. Testing the value here
377        # (my $value = shift) meant a false value could neither be stored nor read back,
378        # so e.g. a confidence of 0 looked like no data at all.
379
7
8
        if(@_) {
380
1
1
                my $value = shift;
381
1
1
                delete $self->{'location'};  # Invalidate the cache
382
1
1
                delete $self->{'tz'};        # Invalidate the cache
383
1
6
                return $self->{$key} = $value;
384        }
385
386        # Defined, not true: a stored 0 or empty string is the caller's data, so return it
387
6
16
        return $self->{$key} if(defined($self->{$key}));
388
389
1
3
        return $self->{ucfirst($key)};
390}
391
392# The accuracy levels a point may claim, most precise first.  This is the one
393# place the vocabulary is written down: Geo::Coder::Free::Provenance reads it
394# from here instead of keeping a copy of its own, so a level added to one of
395# them cannot go missing from the other.  It is defined here because
396# Geo::Coder::Free depends on this module, and not the other way round.
397#
398# It is read-only because it is shared.  %IS_ACCURACY_LEVEL below is taken from
399# this list when the module loads, so a caller that pushed a level into the list
400# would publish a vocabulary that accuracy() still refuses: the module would
401# describe one set of levels and accept another, and every module that reads the
402# list - Provenance's name for it included - would be speaking the same language
403# as the list and not as the points.  A level that belongs in the vocabulary is
404# added here, beside the validator it is checked against.
405Readonly::Array my @ACCURACY_LEVELS => qw(address street postcode locality city region country);
406
407# The list itself, rather than a copy of it: every module that speaks this
408# vocabulary shares this one list, so the levels it reports are the levels a point
409# accepts.
410sub ACCURACY_LEVELS {
411
3
35
        return \@ACCURACY_LEVELS;
412}
413
414my %IS_ACCURACY_LEVEL = map { $_ => 1 } @ACCURACY_LEVELS;
415
416# The fields a backend can match on, and the level each one justifies.  Keyed by
417# level, because accuracy_for() walks ACCURACY_LEVELS in order: the most precise
418# level with a matched field wins, and a level that is not in the vocabulary
419# cannot come out of here.  'city' has no entry of its own - a city match is a
420# locality, the most precise claim the centre of a city supports.
421#
422# This is the other half of the vocabulary, in the same place: the levels a
423# point accepts and the rule that turns a backend's matched fields into one of
424# them are defined together, so a rule cannot name a level accuracy() refuses,
425# and a backend does not have to keep two modules in step to describe a match.
426Readonly::Hash my %FIELDS_FOR_ACCURACY => (
427        address  => [qw(house_number number)],
428        street   => [qw(road street)],
429        postcode => [qw(postcode post_code zip)],
430        locality => [qw(city town name)],
431        region   => [qw(state state_district county region)],
432        country  => [qw(country)],
433);
434
435sub accuracy_for {
436
35
37
4315
79
        my %field = map { lc($_) => 1 } @_;
437
438
35
41
        for my $level (@ACCURACY_LEVELS) {
439
135
650
                next unless(my $fields = $FIELDS_FOR_ACCURACY{$level});
440
441
123
123
555
110
                for my $field (@{$fields}) {
442
277
883
                        return $level if($field{$field});
443                }
444        }
445
446        # A match on a row with none of these fields is still a named place, and
447        # every result promises an accuracy, so it is reported as a locality rather
448        # than left unsaid.
449
2
12
        return 'locality';
450}
451
452# Read a slot the way AUTOLOAD reads it: a lower case key that was never set
453# falls back to its upper case spelling, so a field the caller stored under the
454# upper case name is still found.
455sub _attribute {
456
20
16
        my ($self, $key) = @_;
457
458
20
57
        return $self->{$key} if(defined($self->{$key}));
459
460
5
13
        return $self->{ucfirst($key)};
461}
462
463# Write a slot the way AUTOLOAD writes it, invalidating the caches derived from
464# the attributes: a validated accessor has to keep that promise too, or setting
465# a city after reading as_string() would be invisible.
466sub _set_attribute {
467
28
25
        my ($self, $key, $value) = @_;
468
469
28
24
        delete $self->{'location'};
470
28
16
        delete $self->{'tz'};
471
472
28
50
        return $self->{$key} = $value;
473}
474
475# Passing undef clears an attribute, so every validated accessor accepts it;
476# anything else has to be the type the field promises.
477sub _check_string {
478
12
12
        my ($field, $value) = @_;
479
480
12
13
        return if(!defined($value));
481
12
16
        Carp::croak(__PACKAGE__, ": $field must be a string, not a ", ref($value)) if(ref($value));
482
483
11
14
        return $value;
484}
485
486sub _check_confidence {
487
9
9
        my ($value) = @_;
488
489
9
8
        return if(!defined($value));
490
8
50
        Carp::croak(__PACKAGE__, ': confidence must be a number from 0 to 1, not ',
491                ref($value) ? ref($value) : $value)
492                if(ref($value) || !Scalar::Util::looks_like_number($value) || ($value < 0) || ($value > 1));
493
494
5
7
        return $value;
495}
496
497sub _check_accuracy {
498
13
9
        my ($value) = @_;
499
500
13
12
        return if(!defined($value));
501        Carp::croak(__PACKAGE__, ': unknown accuracy: ', ref($value) ? ref($value) : $value)
502
12
29
                if(ref($value) || !$IS_ACCURACY_LEVEL{$value});
503
504
10
9
        return $value;
505}
506
507 - 513
=head2  geocoder

The distribution that produced the point, such as C<Geo::Coder::Free>.

    $location->geocoder('Geo::Coder::Free');

=cut
514
515sub geocoder {
516
2
152
        my $self = shift;
517
518
2
5
        return $self->_attribute('geocoder') unless(@_);
519
520
1
2
        return $self->_set_attribute('geocoder', _check_string('geocoder', shift));
521}
522
523 - 530
=head2  database

The database the distribution answered from, such as C<OpenAddresses>, C<Local>
or C<MaxMind>.

    $location->database('OpenAddresses');

=cut
531
532sub database {
533
1
1
        my $self = shift;
534
535
1
2
        return $self->_attribute('database') unless(@_);
536
537
1
7
        return $self->_set_attribute('database', _check_string('database', shift));
538}
539
540 - 549
=head2  confidence

How good the match is, from C<0> to C<1> inclusive, where C<1> is an exact
match. A confidence that is not a number, or that falls outside that range, is
rejected rather than stored.

    $location->confidence(0);                # Tried and rejected
    $location->confidence(0.8);              # A good match

=cut
550
551sub confidence {
552
18
1718
        my $self = shift;
553
554
18
24
        return $self->_attribute('confidence') unless(@_);
555
556
9
11
        return $self->_set_attribute('confidence', _check_confidence(shift));
557}
558
559 - 580
=head2  ACCURACY_LEVELS

The accuracy levels a point may claim, most precise first: C<address> (a house
number), C<street>, C<postcode>, C<locality>, C<city>, C<region> and
C<country>.

This is the vocabulary, written down once here and read from here by
L<Geo::Coder::Free::Provenance> rather than copied, so the levels a point
accepts and the levels a backend reports cannot drift apart.  C<accuracy()>
accepts these and refuses anything else.

The rule that turns the fields a backend matched into one of these levels is
L</accuracy_for>, beside the list it returns a level from.

The list is read-only, and a caller that tries to add to it is refused, because
it is the list C<accuracy()> checks against: were it writable, a level pushed
into it would be published to every reader of this list while the module went on
refusing it.

    my $levels = Geo::Location::Point::ACCURACY_LEVELS();

=cut
581
582 - 601
=head2  accuracy_for

The accuracy a match can claim, from the fields that made it.  The most precise
field present decides: a house number makes it an C<address>, a road a
C<street>, a postcode a C<postcode>, a name or a city a C<locality>, a state or
region a C<region>, and a country a C<country>.  So a dataset row carrying only
a city, state and country is a C<locality>: a named place, not an address.

    my $level = Geo::Location::Point::accuracy_for(qw(house_number road city));

Always returns one of the L</ACCURACY_LEVELS>: a match on a row with none of the
fields above is still a named place, so it is reported as a C<locality> rather
than left unsaid, because every result promises an accuracy.

It is a function, not a method - the fields are what a backend matched, not
something a point carries - and it lives beside the vocabulary it returns a
level from, so the levels a point accepts and the rules that choose one of them
have a single home.

=cut
602
603 - 612
=head2  accuracy

How precise the coordinates behind a match can claim to be.  The levels are
those in L</ACCURACY_LEVELS>; anything else is rejected, because a reader
deciding what a point may claim about itself has to be able to trust the
value.

    $location->accuracy('city');

=cut
613
614sub accuracy {
615
17
942
        my $self = shift;
616
617
17
22
        return $self->_attribute('accuracy') unless(@_);
618
619
13
12
        return $self->_set_attribute('accuracy', _check_accuracy(shift));
620}
621
622 - 630
=head2  country

The country of the point, as the data spells it: C<GB>, C<US>, C<Canada>. It
is stored as given rather than normalised, so it is whatever the backend
matched.

    $location->country('GB');

=cut
631
632sub country {
633
6
184
        my $self = shift;
634
635
6
11
        return $self->_attribute('country') unless(@_);
636
637
2
6
        return $self->_set_attribute('country', _check_string('country', shift));
638}
639
640 - 646
=head2  city

The city, town or similar locality of the point.

    $location->city('London');

=cut
647
648sub city {
649
5
345
        my $self = shift;
650
651
5
7
        return $self->_attribute('city') unless(@_);
652
653
4
6
        return $self->_set_attribute('city', _check_string('city', shift));
654}
655
656 - 662
=head2  state

The state, province or region of the point.

    $location->state('KY');

=cut
663
664sub state {
665
5
5
        my $self = shift;
666
667
5
9
        return $self->_attribute('state') unless(@_);
668
669
4
6
        return $self->_set_attribute('state', _check_string('state', shift));
670}
671
672 - 711
=head1 SUPPORT

This module is provided as-is without any warranty.

=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

=head1 BUGS

There is no validation on the attribute in the AUTOLOAD method,
so typos such as "citty" will not be caught.
C<geocoder>, C<database>, C<confidence>, C<accuracy>, C<country>, C<city> and
C<state> have documented accessors of their own that do check what they are
given,
but they are the only attributes that do,
and C<new()> stores whatever it is handed without checking it,
so a provenance field set through the constructor can still be any value.

=head1 SEE ALSO

=over 4

=item * L<Test Dashboard|https://nigelhorne.github.io/Geo-Location-Point/coverage/>

=back

L<GIS::Distance>,
L<Geo::Point>,
L<TimeZone::TimeZoneDB>.

=head1 LICENSE AND COPYRIGHT

Copyright 2019-2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.

=cut
712
7131;