File Coverage

File:blib/lib/Geo/Coder/Free/Local.pm
Coverage:74.8%

linestmtbrancondsubpodtimecode
1package Geo::Coder::Free::Local;
2
3
17
17
17
132775
14
177
use strict;
4
17
17
17
22
9
341
use warnings;
5
17
17
17
365
11947
41
use autodie qw(:all);
6
7
17
17
17
44662
17
520
use Carp;
8
17
17
17
3458
107881
255
use Geo::Location::Point 0.14;
9
17
17
17
3091
30
568
use Geo::Coder::Free::Utils qw(_abbreviate _normalize);
10
17
17
17
4591
472859
764
use Geo::StreetAddress::US;
11
17
17
17
6308
1012509
415
use Lingua::EN::AddressParse;
12
17
17
17
3755
52263
246
use Locale::CA;
13
17
17
17
3238
2723
185
use Locale::US;
14
17
17
17
5113
834783
382
use Object::Configure;
15
17
17
17
59
17
305
use Params::Get;
16
17
17
17
34
18
293
use Readonly;
17
17
17
17
4072
160807
732
use Text::xSV::Slurp;
18
19=encoding utf-8
20
21 - 29
=head1 NAME

Geo::Coder::Free::Local - Geocode using user-curated local data

=head1 VERSION

Version 0.42

=cut
30
31our $VERSION = '0.43';
32
33 - 80
=head1 SYNOPSIS

    use Geo::Coder::Free::Local;

    my $geocoder = Geo::Coder::Free::Local->new();
    my $location = $geocoder->geocode(location => 'Ramsgate, Kent, UK');
    printf "lat=%.6f lon=%.6f\n", $location->lat(), $location->long();

=head1 DESCRIPTION

Provides geocoding via a user-curated CSV dataset embedded in the module's
C<__DATA__> section.  Locations in the data were verified by GPS and by
inspecting geotagged photographs.  The data is read once at construction time
and indexed for fast lookup.

This is the highest-priority backend tried by C<Geo::Coder::Free>.

=head1 LIMITATIONS

=over 4

=item * The embedded C<__DATA__> dataset covers only a small set of hand-picked locations.
There is no mechanism for non-authors to contribute data without patching the module.

=item * Canadian and Australian address parsing is not yet implemented; those queries return C<undef>.

=item * C<_search> performs an O(n) linear scan through all rows.  The hash-based
index in C<new()> handles exact string matches; no index exists for partial field matches.

=item * C<our %alternatives> duplicates mappings in C<Geo::Coder::Free::__DATA__>.
Both should be consolidated into a shared external config file.

=item * C<$libpostal_is_installed> is a module-level (effectively global) flag.
Not thread-safe in a forking or threaded Perl deployment.

=item * The C<__DATA__> filehandle is a one-shot resource.  The first call to
C<new()> exhausts it with C<my @data = E<lt>DATAE<gt>>; every subsequent
C<new()> in the same process reads zero rows and builds an empty index.
Construct exactly one C<Local> object per process and share it.

=item * The hash-index key is C<lc(Geo::Location::Point-E<gt>new($row)-E<gt>as_string())>,
which B<includes the C<name> field>.  To get a direct index hit the caller must
supply the full string with the venue/place name as the leading component;
an address that omits the name falls through to the slower O(n) C<_search> path.

=back

=cut
81
82# Libpostal detection states — avoid magic numbers
83
17
17
17
56
17
439
use constant LIBPOSTAL_UNKNOWN       => 0;
84
17
17
17
30
14
290
use constant LIBPOSTAL_INSTALLED     => 1;
85
17
17
17
26
15
39966
use constant LIBPOSTAL_NOT_INSTALLED => -1;
86
87# Module-level singleton flags — see LIMITATIONS re: thread safety.
88our $libpostal_is_installed = LIBPOSTAL_UNKNOWN;
89
90# Lazily initialised Locale singletons to avoid repeated object construction
91# (Locale::US->new() and Locale::CA->new() are called multiple times per geocode;
92# caching them reduces object churn on busy lookups).
93my $_locale_us;
94my $_locale_ca;
95
96# Confidence thresholds for _search results, aligned with OpenAddresses backend
97Readonly::Scalar my $CONF_EXACT  => 1.0;
98Readonly::Scalar my $CONF_HIGH   => 0.7;
99Readonly::Scalar my $CONF_MEDIUM => 0.5;
100
101# Hard-coded place-name aliases for ambiguous or database-inconsistent locations.
102# MAINTENANCE NOTE: the same mappings appear in Geo::Coder::Free::__DATA__.
103# Both should eventually be replaced by a shared external config file.
104our %alternatives = (
105        'ST LAWRENCE, THANET, KENT' => 'RAMSGATE, KENT',
106        'ST PETERS, THANET, KENT'   => 'ST PETERS, KENT',
107        'MINSTER, THANET, KENT'     => 'RAMSGATE, KENT',
108        'TYNE AND WEAR'             => 'BOROUGH OF NORTH TYNESIDE',
109);
110
111 - 155
=head1 METHODS

=head2 new

=head3 SYNOPSIS

    my $geocoder = Geo::Coder::Free::Local->new();
    my $geocoder = Geo::Coder::Free::Local->new(cache => $chi_cache);

=head3 DESCRIPTION

Constructor.  Reads the C<__DATA__> CSV block, builds a hash-based lookup
index, and derives the geographic centre of any city/state/country cluster
containing three or more data points.

B<One-shot filehandle>: the C<DATA> handle is consumed on the first call to
C<new()>.  Any subsequent call to C<new()> returns an object whose dataset and
index are both empty.  In code that constructs multiple C<Local> objects (e.g.
in tests) construct B<exactly one> instance and reuse it across all callers.

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict)
    cache => { type => 'object', optional => 1, can => ['get', 'set'] }  # CHI-compatible cache object

=head4 output

    # Output schema (Return::Set)
    { type => 'object', isa => 'Geo::Coder::Free::Local' }

=head3 FORMAL SPECIFICATION

    LocalState ::= ⟨⟨ data  : seq Row;
                       index : Map[STRING → Row];
                       cache : Map[STRING → Point] ⟩⟩

    Init : Params → LocalState
    âˆ€ p : Params •
      let rows    == parse_csv(__DATA__) ∪ geographic_centres(__DATA__) •
      let idx_key == λ r • lc(Point(r).as_string()) •
      LocalState.index = { idx_key(r) ↦ r | r ∈ rows }

=cut
156
157sub new {
158
18
1
376378
        my $class = shift;
159
18
46
        my $params = Params::Get::get_params(undef, @_) // {};
160
161
18
257
        if (!defined($class)) {
162
1
1
                $class = __PACKAGE__;   # FIXME: only works when no arguments are given
163        } elsif (ref($class)) {
164
3
3
3
5
5
12
                return bless { %{$class}, %{$params} }, ref($class);
165        }
166
167
15
45
        $params = Object::Configure::configure($class, $params);
168
169
15
116682
        my @data = <DATA>;
170
171        my $self = bless {
172                data => xsv_slurp(
173                        shape    => 'aoh',
174                        text_csv => {
175                                allow_loose_quotes => 1,
176                                blank_is_undef     => 1,
177                                empty_is_undef     => 1,
178                                binary             => 1,
179                                escape_char        => '\\',
180                        },
181
979
750
                        string => \join('', grep { !/^\s*(#|$)/ } @data),
182                ),
183
15
15
91
16603
                %{$params},
184        }, $class;
185
186        # Derive geographic centres from clusters of 3+ co-located data points,
187        # adding implicit town/city records to the dataset.
188
15
79
        my $towns = _find_geographic_centres($self->{'data'});
189
15
11
11
30
18
15
        push @{$self->{'data'}}, @{$towns} if $towns;
190
191        # Build a hash index on the stringified Geo::Location::Point representation
192        # so that exact-match lookups avoid the O(n) scan in _search.
193
15
15
19
26
        for my $row (@{$self->{'data'}}) {
194
1034
777
                my $key = lc(Geo::Location::Point->new($row)->as_string());
195
1034
72522
                $self->{'index'}{$key} = $row;
196        }
197
198        # Build an O(1) region existence index used by geocode() to skip the O(n)
199        # grep before _search when no rows for the requested state/country exist.
200        # Major: every (state|country) pair in the dataset is represented here.
201        # Minor: if geocode's target pair is absent, _search can never match.
202        # Conclusion: early return is safe — eliminates the O(n) grep per call.
203
15
15
17
27
        for my $row (@{$self->{'data'}}) {
204
1034
851
                my $rk = uc($row->{'state'} // '') . '|' . uc($row->{'country'} // '');
205
1034
567
                $self->{'region_index'}{$rk} = 1;
206        }
207
208
15
464
        return $self;
209}
210
211 - 263
=head2 geocode

=head3 SYNOPSIS

    my $pt = $geocoder->geocode(location => '203 E Chatsworth Rd, Reisterstown, Baltimore, MD, US');
    print $pt->lat(), "\n";

    # All calling forms are accepted:
    $geocoder->geocode('203 E Chatsworth Rd, Reisterstown, MD, US');
    $geocoder->geocode({ location => '203 E Chatsworth Rd, Reisterstown, MD, US' });

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict)
    location => { type => 'scalar' }  # address string; must contain at least two commas

=head4 output

    # Output schema (Return::Set)
    { type => 'object', isa => 'Geo::Location::Point', optional => 1 }

=head3 MESSAGES

    Usage: ...::geocode(...)   No location argument given.

=head3 FORMAL SPECIFICATION

    Geocode : STRING → Point?
    âˆ€ addr : STRING •
      let norm == lc(replace_usa(addr)) •
      (norm ∈ cache ⟹ result = cache[norm]) ∧
      (norm ∈ index ⟹ result = index[norm]) ∧
      (¬ result ⟹ result = parse_and_search(addr))

=head3 PSEUDOCODE

    normalise @_ into %params
    reject if location does not contain two or more commas (not a full address)
    check cache; return hit
    check hash index; return hit and cache it
    # index key includes the 'name' field; supply the venue/place name as the
    # leading address component to guarantee an index hit
    attempt country-specific parser (US, GB; skip CA/AU pending implementation)
    attempt Geo::StreetAddress::US for "..., USA" addresses
    attempt Geo::Address::Parser
    attempt Geo::libpostal (large memory footprint; loaded lazily)
    attempt 4-part regex decomposition (name/road/city/state/country)
    attempt %alternatives mapping
    return undef

=cut
264
265sub geocode {
266
112
1
63083
        my $self = shift;
267
268        # Handle being called as a function rather than a method
269
112
257
        if (!ref($self)) {
270
4
12
                if (scalar @_) {
271
1
4
                        return __PACKAGE__->new()->geocode(@_);
272                } elsif (!defined($self)) {
273
1
6
                        Carp::croak('Usage: ', __PACKAGE__, '::geocode(location => $location)');
274                } elsif ($self eq __PACKAGE__) {
275
1
5
                        Carp::croak("Usage: $self", '::geocode(location => $location)');
276                }
277
1
2
                return __PACKAGE__->new()->geocode($self);
278        } elsif (ref($self) eq 'HASH') {
279
1
3
                return __PACKAGE__->new()->geocode($self);
280        }
281
282
107
213
        my %params = _normalize_args(
283                'Usage: ' . __PACKAGE__ . '::geocode(location => $location)',
284                'location', @_
285        );
286
287
103
189
        my $location = $params{'location'}
288                or Carp::croak('Usage: geocode(location => $location)');
289
290        # This backend only handles full addresses (at least "road, city, country")
291
99
314
        return if $location !~ /,.+,/;
292
293        # Normalise the lookup key — "USA" is treated the same as "US"
294
82
122
        my $lc = lc($location);
295
82
239
        $lc =~ s/,\s*usa$/, us/i;
296
297
82
233
        return $self->{'cache'}{$lc}  if exists $self->{'cache'}{$lc};
298        return $self->_cache_and_return($lc, $self->{'index'}{$lc})
299
47
145
                if exists $self->{'index'}{$lc};
300
301
29
42
        my $ap;
302
29
244
        if ($location =~ /USA?$/ || $location =~ /United States$/) {
303
3
20
                $ap = $self->{'ap'}{'us'} //= Lingua::EN::AddressParse->new(
304                        country => 'US', auto_clean => 1, force_case => 1, force_post_code => 0
305                );
306        } elsif ($location =~ /(England|Scotland|Wales|Northern Ireland|UK|GB)$/i) {
307
17
86
                $ap = $self->{'ap'}{'gb'} //= Lingua::EN::AddressParse->new(
308                        country => 'GB', auto_clean => 1, force_case => 1, force_post_code => 0
309                );
310        } elsif ($location =~ /Canada$/) {
311                # TODO: Canadian address parsing not yet implemented
312
1
3
                return;
313        } elsif ($location =~ /Australia$/) {
314                # TODO: Australian address parsing not yet implemented
315
1
2
                return;
316        }
317
318
27
3731009
        if ($ap) {
319
20
32
                my $l = $location;
320
20
209
                $l =~ s/(.+), (England|UK)$/$1, GB/i;
321
322
20
69
                if ($ap->parse($l) == 0) {
323
7
198856
                        my %c = $ap->components();
324
7
1374
                        my %addr = (location => $l);
325
326
7
19
                        if (my $street = $c{'street_name'}) {
327
7
17
                                if (my $type = $c{'street_type'}) {
328
7
31
                                        my $abbrev = _abbreviate($type);
329
7
82
                                        $street .= ' ' . ($abbrev || $type);
330                                        $street .= ' ' . $c{'street_direction_suffix'}
331
7
12
                                                if $c{'street_direction_suffix'};
332
7
14
                                        $street =~ s/^0+//;
333
7
15
                                        $addr{'road'} = $street;
334                                }
335                        }
336
337
7
18
                        if (length($c{'subcountry'}) == 2) {
338
1
2
                                $addr{'state'} = $c{'subcountry'};
339                        } else {
340
6
13
                                my $country_raw = $c{'country'} // '';
341
6
31
                                if ($country_raw =~ /Canada/i) {
342
0
0
                                        $addr{'country'} = 'CA';
343
0
0
                                        $addr{'state'}   = _to_two_letter_state('Canada', $c{'subcountry'});
344                                } elsif ($country_raw =~ /^(United States|USA|US)$/i) {
345
0
0
                                        $addr{'country'} = 'US';
346
0
0
                                        $addr{'state'}   = _to_two_letter_state('US', $c{'subcountry'});
347                                } elsif ($country_raw) {
348
6
9
                                        $addr{'country'} = $country_raw;
349
6
23
                                        $addr{'state'}   = $c{'subcountry'} if $c{'subcountry'};
350                                }
351                        }
352
7
18
                        $addr{'number'} = $c{'property_identifier'};
353
7
17
                        $addr{'city'}   = $c{'suburb'};
354
355
7
31
                        if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
356
1
54
                                return $self->_cache_and_return($lc, $rc);
357                        }
358
6
14
                        if ($addr{'number'}) {
359
2
27
                                if (my $rc = $self->_search(\%addr, qw(road city state country))) {
360
0
0
                                        return $self->_cache_and_return($lc, $rc);
361                                }
362                        }
363
364                        # Abort early if no data at all matches this state/country —
365                        # saves traversing the full dataset for every remaining strategy.
366
6
42
                        if (!defined($addr{'country'})) {
367
1
8
                                $addr{'country'} = $l =~ /(United States|USA|US)$/i ? 'US'
368                                        : Carp::croak("TODO: extract country from $l");
369                        }
370                        # O(1) region check via index built in new() — replaces the former
371                        # O(n) grep across all rows.
372                        # Major: region_index maps every (state|country) pair in the dataset.
373                        # Minor: if target pair is absent, no _search call can match.
374                        # Conclusion: early return is equivalent and faster.
375
6
23
                        my $rk = uc($addr{'state'} // '') . '|' . uc($addr{'country'} // '');
376
6
37
                        return unless $self->{'region_index'}{$rk};
377                }
378        }
379
380
25
73255
        if ($location =~ /^(.+?)[,\s]+(United States|USA|US)$/i) {
381
3
7
                my $l = $1;
382
3
6
                $l =~ tr/,/ /;
383
3
16
                $l =~ s/\s{2,}/ /g;
384
385                # Geo::StreetAddress::US is buggy (RT#122617) — skip county-style addresses
386
3
11
                if ($location !~ /\sCounty,/i) {
387
3
25
                        my $href = Geo::StreetAddress::US->parse_location($l)
388                                // Geo::StreetAddress::US->parse_address($l);
389
390
3
494
                        if ($href) {
391
3
6
                                if (my $state = $href->{'state'}) {
392
3
6
                                        $state = _to_two_letter_state('US', $state) if length($state) > 2;
393
3
9
                                        my $city   = uc($href->{'city'} // '');
394
3
5
                                        if (my $street = $href->{'street'}) {
395
3
5
                                                if ($href->{'type'}) {
396
1
3
                                                        $street .= ' ' . _abbreviate($href->{'type'});
397                                                }
398
3
10
                                                $street .= ' ' . $href->{'suffix'}  if $href->{'suffix'};
399
3
14
                                                $street  = $href->{'prefix'} . " $street" if $href->{'prefix'};
400                                                my %addr = (
401
3
12
                                                        number  => $href->{'number'},
402                                                        road    => $street,
403                                                        city    => $city,
404                                                        state   => $state,
405                                                        country => 'US',
406                                                );
407
3
4
                                                if ($href->{'number'}) {
408
0
0
                                                        if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
409
0
0
                                                                $rc->{'country'} = 'US';
410
0
0
                                                                return $self->_cache_and_return($lc, $rc);
411                                                        }
412                                                }
413
3
12
                                                if (my $rc = $self->_search(\%addr, qw(road city state country))) {
414
1
39
                                                        $rc->{'country'} = 'US';
415
1
3
                                                        return $self->_cache_and_return($lc, $rc);
416                                                }
417                                                # G:S:US puts building name into street when number is absent
418
2
8
                                                if ($street && !$href->{'number'}) {
419
2
5
                                                        $addr{'name'} = delete $addr{'road'};
420
2
5
                                                        if (my $rc = $self->_search(\%addr, qw(name city state country))) {
421
0
0
                                                                $rc->{'country'} = 'US';
422
0
0
                                                                return $self->_cache_and_return($lc, $rc);
423                                                        }
424                                                }
425                                        }
426                                }
427                        }
428                }
429
430                # Fallback: try "name, street, town, state, US" split
431
2
11
                my @parts = split(/,\s*/, $location);
432
2
4
                if (scalar(@parts) == 5) {
433
1
3
                        my $state = _to_two_letter_state('US', $parts[3]);
434
1
3
                        if (length($state) == 2) {
435
1
2
                                my %addr = (city => $parts[2], state => $state, country => 'US');
436
1
2
                                if ($parts[0] !~ /^\d/) {
437
1
2
                                        $addr{'name'} = $parts[0];
438
1
2
                                        if ($parts[1] =~ /^(\d+)\s+(.+)/) {
439
1
2
                                                $addr{'number'} = $1;
440
1
5
                                                $addr{'road'}   = _normalize($2);
441
1
4
                                                if (my $rc = $self->_search(\%addr, qw(name number road city state country))) {
442
1
54
                                                        $rc->{'country'} = 'US';
443
1
3
                                                        return $self->_cache_and_return($lc, $rc);
444                                                }
445                                        } else {
446
0
0
                                                $addr{'road'} = _normalize($parts[1]);
447
0
0
                                                if (my $rc = $self->_search(\%addr, qw(name road city state country))) {
448
0
0
                                                        $rc->{'country'} = 'US';
449
0
0
                                                        return $self->_cache_and_return($lc, $rc);
450                                                }
451                                        }
452                                } else {
453
0
0
                                        $addr{'number'} = $parts[0];
454
0
0
                                        $addr{'road'}   = _normalize($parts[1]);
455
0
0
                                        if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
456
0
0
                                                $rc->{'country'} = 'US';
457
0
0
                                                return $self->_cache_and_return($lc, $rc);
458                                        }
459                                }
460                        }
461                }
462        }
463
464        # Simple "Town, County, England" — no further sub-parsers will help
465
23
102
        if (($location =~ /[^,]+,[^,]+,.*England$/) && ($location !~ /[^,]+,[^,]+,[^,]+,.*England$/)) {
466
2
8
                return;
467        }
468
469        # Geo::Address::Parser — optional; load lazily, skip if not installed
470
21
156
        unless (Geo::Address::Parser->can('parse')) {
471
21
21
0
24
1660
0
                eval { require Geo::Address::Parser; Geo::Address::Parser->import() };
472        }
473
21
58
        if (Geo::Address::Parser->can('parse')) {
474
0
0
                my $addr_parser = Geo::Address::Parser->new(country => 'UK');
475
0
0
                if (my $fields = $addr_parser->parse($location)) {
476                        # Remove undef fields so _search only matches defined columns
477
0
0
0
0
0
0
                        delete $fields->{$_} for grep { !defined $fields->{$_} } keys %{$fields};
478
0
0
0
0
                        if (my $rc = $self->_search($fields, keys %{$fields})) {
479
0
0
                                $rc->{'country'} = 'UK';
480
0
0
                                return $self->_cache_and_return($lc, $rc);
481                        }
482                }
483        }
484
485        # Geo::libpostal — accurate but uses enormous RAM; initialise at most once
486
21
48
        if ($libpostal_is_installed == LIBPOSTAL_UNKNOWN) {
487
5
5
0
5
284
0
                if (eval { require Geo::libpostal; 1 }) {
488
0
0
                        Geo::libpostal->import();
489
0
0
                        $libpostal_is_installed = LIBPOSTAL_INSTALLED;
490                } else {
491
5
6
                        $libpostal_is_installed = LIBPOSTAL_NOT_INSTALLED;
492                }
493        }
494
495
21
41
        if ($libpostal_is_installed == LIBPOSTAL_INSTALLED) {
496
0
0
                my %addr = Geo::libpostal::parse_address($location);
497
0
0
                if (%addr) {
498                        # Normalise field names to match our data schema
499
0
0
                        $addr{'number'} = delete $addr{'house_number'} if $addr{'house_number'} && !$addr{'number'};
500
0
0
                        $addr{'name'}   = delete $addr{'house'}        if $addr{'house'}        && !$addr{'name'};
501
0
0
                        $addr{'location'} = $location;
502
503
0
0
                        if (my $street = $addr{'road'}) {
504
0
0
                                $addr{'road'} = _normalize($street);
505                        }
506
507                        # libpostal returns "england" as a state — map to country code
508
0
0
                        if (defined($addr{'state'}) && !defined($addr{'country'})
509                            && $addr{'state'} eq 'england') {
510
0
0
                                delete $addr{'state'};
511
0
0
                                $addr{'country'} = 'GB';
512                        }
513
514
0
0
                        if ($addr{'country'} && ($addr{'state'} || $addr{'state_district'})) {
515
0
0
                                if ($addr{'country'} =~ /Canada/i) {
516
0
0
                                        $addr{'country'} = 'Canada';
517                                        $addr{'state'}   = _to_two_letter_state('Canada', $addr{'state'})
518
0
0
                                                if $addr{'state'} && length($addr{'state'}) > 2;
519                                } elsif ($addr{'country'} =~ /^(United States|USA|US)$/i) {
520
0
0
                                        $addr{'country'} = 'US';
521                                        $addr{'state'}   = _to_two_letter_state('US', $addr{'state'})
522
0
0
                                                if $addr{'state'} && length($addr{'state'}) > 2;
523                                }
524
525
0
0
                                if ($addr{'state_district'}) {
526
0
0
                                        $addr{'state_district'} =~ s/^(.+)\s+COUNTY\s*$/$1/i;
527
0
0
                                        if (my $rc = $self->_search(\%addr, qw(number road city state_district state country))) {
528
0
0
                                                return $self->_cache_and_return($lc, $rc);
529                                        }
530                                }
531
0
0
                                if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
532
0
0
                                        return $self->_cache_and_return($lc, $rc);
533                                }
534
0
0
                                if ($addr{'number'}) {
535
0
0
                                        if (my $rc = $self->_search(\%addr, qw(road city state country))) {
536
0
0
                                                return $self->_cache_and_return($lc, $rc);
537                                        }
538                                }
539                        }
540                }
541        }
542
543        # Last resort: decompose "road, city, state, country" with a regex
544
21
132
        if ($location =~ /^(.+?),\s*([\s\w]+),\s*([\s\w]+),\s*([\w\s]+)$/) {
545
14
85
                my %addr = (
546                        road    => $1,
547                        city    => $2,
548                        state   => $3,
549                        country => $4,
550                );
551
14
56
                $addr{'state'}   =~ s/\s+$//g;
552
14
23
                $addr{'country'} =~ s/\s+$//g;
553
554
14
50
                if ($addr{'road'} =~ /([\w\s]+),*\s+(.+)/) {
555
13
25
                        $addr{'name'} = $1;
556
13
25
                        $addr{'road'} = $2;
557                }
558
14
75
                if ($addr{'road'} =~ /^(\d+)\s+(.+)/) {
559
3
6
                        $addr{'number'} = $1;
560
3
4
                        $addr{'road'}   = $2;
561
3
7
                        if (my $rc = $self->_search(\%addr, qw(name number road city state country))) {
562
0
0
                                return $self->_cache_and_return($lc, $rc);
563                        }
564                } elsif (my $rc = $self->_search(\%addr, qw(name road city state country))) {
565
2
115
                        return $self->_cache_and_return($lc, $rc);
566                }
567
12
55
                if ($addr{'name'} && !defined($addr{'number'})) {
568
9
19
                        if (my $rc = $self->_search(\%addr, qw(name road city state country))) {
569
0
0
                                return $self->_cache_and_return($lc, $rc);
570                        }
571                }
572        }
573
574        # Try the alternatives table — curated mappings for inconsistent names
575
19
39
        $location = uc($location);
576
19
47
        for my $left (keys %alternatives) {
577
76
378
                next unless $location =~ $left;
578
8
38
                (my $mapped = $location) =~ s/$left/$alternatives{$left}/;
579
8
16
                $params{'location'} = $mapped;
580
8
48
                if (my $rc = $self->geocode(\%params)) {
581
1
4
                        return $self->_cache_and_return($lc, $rc);
582                }
583
7
35
                if ($mapped =~ /(.+), (England|UK)$/i) {
584
4
9
                        $params{'location'} = "$1, GB";
585
4
9
                        if (my $rc = $self->geocode(\%params)) {
586
3
12
                                return $self->_cache_and_return($lc, $rc);
587                        }
588                }
589
4
12
                $params{'location'} = $location;
590        }
591
592
15
54
        return;
593}
594
595 - 618
=head2 reverse_geocode

=head3 SYNOPSIS

    my $loc = $geocoder->reverse_geocode(latlng => '51.3341,-1.4159');
    # Returns the location string(s) for that lat/lon pair.

=head3 API SPECIFICATION

=head4 input

    # Input schema (Params::Validate::Strict) — latlng or lat+lon required
    latlng => { type => 'scalar', optional => 1 }  # "$lat,$long" comma-separated decimal degrees
    lat    => { type => 'scalar', optional => 1 }  # latitude  (alternative to latlng)
    lon    => { type => 'scalar', optional => 1 }  # longitude (alternative to latlng)
    long   => { type => 'scalar', optional => 1 }  # alias for lon

=head4 output

    # Output schema (Return::Set)
    # scalar context: { type => 'scalar',   optional => 1 }  # location string
    # list context:   { type => 'arrayref', of => { type => 'scalar' } }

=cut
619
620sub reverse_geocode {
621
64
1
35055
        my $self = shift;
622
623
64
134
        if (!ref($self)) {
624
3
7
                if (scalar @_) {
625
1
3
                        return __PACKAGE__->new()->reverse_geocode(@_);
626                } elsif (!defined($self)) {
627
1
11
                        Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")');
628                } elsif ($self eq __PACKAGE__) {
629
1
4
                        Carp::croak("Usage: $self", '::reverse_geocode(latlng => "$lat,$long")');
630                }
631
0
0
                return __PACKAGE__->new()->reverse_geocode($self);
632        } elsif (ref($self) eq 'HASH') {
633
0
0
                return __PACKAGE__->new()->reverse_geocode($self);
634        }
635
636
61
87
        my %params = _normalize_args(
637                'Usage: ' . __PACKAGE__ . '::reverse_geocode(latlng => "$lat,$long")',
638                'latlng', @_
639        );
640
641
59
65
        my ($latitude, $longitude);
642
59
88
        if (my $latlng = $params{'latlng'}) {
643
36
74
                ($latitude, $longitude) = split /,/, $latlng;
644        } else {
645
23
26
                $latitude  = $params{'lat'};
646
23
70
                $longitude = $params{'lon'} // $params{'long'};
647        }
648
649
59
137
        if (!defined($latitude) || !defined($longitude)) {
650
5
27
                Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")');
651        }
652
653
54
97
        my @rc;
654
54
54
47
81
        for my $row (@{$self->{'data'}}) {
655
4408
4939
                next unless defined($row->{'latitude'}) && defined($row->{'longitude'});
656                next unless _equal($row->{'latitude'}, $latitude, 4)
657
4408
2890
                         && _equal($row->{'longitude'}, $longitude, 4);
658
659                # Rows in $self->{'data'} are blessed as Geo::Location::Point by
660                # the index-building loop in new() — calling as_string() is safe here.
661
91
142
                my $location = uc($row->as_string());
662
91
326
                if (wantarray) {
663
83
64
                        push @rc, $location;
664                        # Add any reverse-alternative mappings for this location
665
83
129
                        while (my ($left, $right) = each %alternatives) {
666
332
1259
                                if ($location =~ $right) {
667
102
212
                                        (my $l = $location) =~ s/$right/$left/;
668
102
202
                                        push @rc, $l;
669                                }
670                        }
671                } else {
672
8
18
                        return $location;
673                }
674        }
675
46
138
        return @rc;
676}
677
678 - 682
=head2 ua

Does nothing — present for drop-in compatibility with other Geo::Coder::* modules.

=cut
683
684
1
sub ua { }
685
686# -----------------------------------------------------------------------
687# Private helpers
688# -----------------------------------------------------------------------
689
690# Purpose:  Normalise the four calling conventions used by geocode/reverse_geocode:
691#           hashref, even-length key-val list, single bare string.
692# Entry:    $error_msg  â€” croak message if an unsupported ref type is passed.
693#           $bare_key   â€” hash key to use when a single bare string is given
694#                         ('location' for geocode, 'latlng' for reverse_geocode).
695#           @args       â€” raw @_ after $self has been shifted.
696# Exit:     Flat %params hash.
697# Side Effects: None; croaks on non-hash reference arguments.
698sub _normalize_args {
699
175
5322
        my ($error_msg, $bare_key, @args) = @_;
700
175
46
255
88
        return %{$args[0]}               if ref($args[0]) eq 'HASH';
701
129
168
        Carp::croak($error_msg)          if ref($args[0]);
702
124
419
        return @args                     if @args && @args % 2 == 0;
703
42
92
        return ($bare_key => $args[0])   if @args == 1;
704        # M7 (logic-gap closure): odd-count > 1 is not a valid calling convention.
705        # Fail fast rather than silently discarding trailing arguments.
706
9
33
        Carp::croak($error_msg)          if @args;
707
5
8
        return ();
708}
709
710# Purpose:  Store $rc in the lookup cache under $key and return it.
711#           Reduces the repeated "$self->{cache}{$k} = $r; return $r;" pattern
712#           that appeared ~12 times across geocode.
713# Entry:    $self, $key (lc string), $rc (Geo::Location::Point or undef).
714# Exit:     $rc (returned transparently).
715# Side Effects: Writes to $self->{cache}.
716sub _cache_and_return {
717
29
2437
        my ($self, $key, $rc) = @_;
718
29
52
        $self->{'cache'}{$key} = $rc;
719
29
92
        return $rc;
720}
721
722# Purpose:  Resolve a full state/province name to its two-letter code using
723#           the appropriate Locale:: module.  Returns the original string if
724#           no mapping is found or the input is already two characters.
725#           Locale::US and Locale::CA objects are cached as module-level
726#           singletons to avoid repeated construction overhead.
727# Entry:    $country — country name or ISO code; $state — state/province name.
728# Exit:     Two-letter code or $state unchanged.
729# Side Effects: May initialise $_locale_us or $_locale_ca singletons.
730sub _to_two_letter_state {
731
19
14413
        my ($country, $state) = @_;
732
19
64
        return $state // '' if !defined($state) || length($state) <= 2;
733
734
14
56
        if ($country =~ /^(United States|USA|US)$/i) {
735
8
39
                $_locale_us //= Locale::US->new();
736
8
967
                return $_locale_us->{'state2code'}{uc($state)} // $state;
737        } elsif ($country =~ /Canada/i) {
738
4
22
                $_locale_ca //= Locale::CA->new();
739
4
966
                return $_locale_ca->{'province2code'}{uc($state)} // $state;
740        }
741
2
4
        return $state;
742}
743
744# Purpose:  Match parsed address components against all data rows.
745#           Each named column must match (case-insensitively) the corresponding
746#           data field; a minimum of 3 columns must match for a result.
747# Entry:    $self, $data — hashref of address components,
748#           @columns — ordered list of keys to compare.
749# Exit:     Geo::Location::Point on match; undef otherwise.
750# Side Effects: May delete undef entries from $data (for undefined optional fields).
751sub _search {
752
50
11755
        my ($self, $data, @columns) = @_;
753
754        # M5 (boolean reduction): the $failed flag was set-and-break in the inner loop
755        # then tested in the outer loop — eliminated by using a labeled 'next ROW'
756        # that short-circuits directly, removing one boolean gate per iteration.
757
50
50
39
73
        ROW: for my $row (@{$self->{'data'}}) {
758
3140
1568
                my $matched = 0;
759
760
3140
1621
                for my $column (@columns) {
761
3163
1818
                        if (defined($data->{$column})) {
762
3120
2489
                                next ROW unless defined($row->{$column});
763
1938
1759
                                next ROW if uc($row->{$column}) ne uc($data->{$column});
764
87
58
                                $matched++;
765                        } elsif (exists $data->{$column}) {
766
2
2
                                delete $data->{$column};
767                        }
768                }
769
770
107
64
                next if $matched < 3;
771
772                # Assign confidence based on how many columns contributed to the match
773
12
32
                my $confidence = $matched == scalar(@columns) ? $CONF_EXACT
774                               : $matched >= 4                ? $CONF_HIGH
775                               :                               $CONF_MEDIUM;
776
777                return Geo::Location::Point->new(
778                        location   => $data->{'location'},
779                        confidence => $confidence,
780                        database   => __PACKAGE__,
781
12
12
28
64
                        %{$row},
782                );
783        }
784
38
84
        return;
785}
786
787# Purpose:  Calculate the geographic centres (centroids) of all city/state/country
788#           clusters in the parsed dataset that contain three or more data points.
789#           Clusters with fewer than 3 entries are too sparse to be meaningful.
790# Entry:    $rows — arrayref of hashrefs with latitude/longitude/city/state/country.
791# Exit:     Arrayref of hashrefs (one per qualifying cluster), or undef if none.
792# Side Effects: None.
793sub _find_geographic_centres {
794
24
11290
        my $rows = $_[0];
795
796        # Group rows by normalised city|state|country key
797
24
24
        my %groups;
798
24
24
23
37
        for my $row (@{$rows}) {
799
994
987
                next unless defined($row->{'latitude'})  && defined($row->{'longitude'});
800
993
965
                next unless $row->{'latitude'}  =~ /^-?\d+\.?\d*$/;
801
991
865
                next unless $row->{'longitude'} =~ /^-?\d+\.?\d*$/;
802                my $key = join '|',
803                        $row->{'city'}    // '',
804                        $row->{'state'}   // '',
805
969
1111
                        $row->{'country'} // '';
806
969
969
406
851
                push @{$groups{$key}}, $row;
807        }
808
809
24
33
        my @centres;
810
24
51
        for my $key (keys %groups) {
811
471
213
                my $locs = $groups{$key};
812
471
471
206
299
                next if @{$locs} < 3;
813
814
71
98
                my ($city, $state, $country) = split /\|/, $key;
815
71
67
                my ($lat, $lon) = _calculate_centre($locs);
816
817
71
202
                push @centres, {
818                        city      => $city,
819                        state     => $state,
820                        country   => $country,
821                        lat       => $lat,
822                        latitude  => $lat,
823                        longitude => $lon,
824                        long      => $lon,
825                        lng       => $lon,
826                };
827        }
828
829
24
113
        return @centres ? \@centres : undef;
830}
831
832# Purpose:  Compute the arithmetic mean of latitude and longitude for a group
833#           of locations.  Accurate for small geographic areas; for large areas
834#           or locations crossing the antimeridian, a vector-mean is needed.
835# Entry:    $locs — arrayref of hashrefs each with 'latitude' and 'longitude'.
836# Exit:     ($centre_lat, $centre_lon) as decimal degree strings to 6dp.
837# Side Effects: None.
838sub _calculate_centre {
839
74
2804
        my $locs  = $_[0];
840
74
51
        my ($sum_lat, $sum_lon) = (0, 0);
841
74
74
41
384
        $sum_lat += $_->{'latitude'}, $sum_lon += $_->{'longitude'} for @{$locs};
842
74
74
46
43
        my $n = scalar @{$locs};
843
74
280
        return (sprintf('%.6f', $sum_lat / $n), sprintf('%.6f', $sum_lon / $n));
844}
845
846# https://www.oreilly.com/library/view/perl-cookbook/1565922433/ch02s03.html
847# Equal within $dp decimal places — avoids floating-point equality pitfalls.
848sub _equal {
849
4598
4869
        my ($A, $B, $dp) = @_;
850
4598
9388
        return sprintf("%.${dp}g", $A) eq sprintf("%.${dp}g", $B);
851}
852
853 - 876
=head1 AUTHOR

Nigel Horne C<< <njh@nigelhorne.com> >>

=head1 BUGS

The data are stored in the module source and must be maintained by the author.
A future version should load them from an external file to allow community contributions.

See also: L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Geo-Coder-Free>

=head1 SEE ALSO

L<Geo::Coder::Free>, L<Geo::Coder::Free::OpenAddresses>, L<Geo::Coder::Free::MaxMind>

=head1 LICENSE AND COPYRIGHT

Copyright 2020-2026 Nigel Horne.

The program code is released under the following licence: GPL2 for personal use on a single computer.
All other users (including Commercial, Charity, Educational, and Government)
must apply in writing for a licence for use from Nigel Horne at C<< <njh at nigelhorne.com> >>.

=cut
877
8781;
879
880# Use abbreviations in the data: RD not ROAD, ST not STREET, etc.