| File: | blib/lib/Geo/Coder/Free/Local.pm |
| Coverage: | 74.8% |
| line | stmt | bran | cond | sub | pod | time | code |
|---|---|---|---|---|---|---|---|
| 1 | package 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 | |||||||
| 31 | our $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. | ||||||
| 88 | our $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). | ||||||
| 93 | my $_locale_us; | ||||||
| 94 | my $_locale_ca; | ||||||
| 95 | |||||||
| 96 | # Confidence thresholds for _search results, aligned with OpenAddresses backend | ||||||
| 97 | Readonly::Scalar my $CONF_EXACT => 1.0; | ||||||
| 98 | Readonly::Scalar my $CONF_HIGH => 0.7; | ||||||
| 99 | Readonly::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. | ||||||
| 104 | our %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 | |||||||
| 157 | sub 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 | |||||||
| 265 | sub 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 | |||||||
| 620 | sub 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. | ||||||
| 698 | sub _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}. | ||||||
| 716 | sub _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. | ||||||
| 730 | sub _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). | ||||||
| 751 | sub _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. | ||||||
| 793 | sub _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. | ||||||
| 838 | sub _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. | ||||||
| 848 | sub _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 | |||||||
| 878 | 1; | ||||||
| 879 | |||||||
| 880 | # Use abbreviations in the data: RD not ROAD, ST not STREET, etc. | ||||||