| File: | blib/lib/Geo/Coder/Free/Local.pm |
| Coverage: | 76.2% |
| line | stmt | bran | cond | sub | pod | time | code |
|---|---|---|---|---|---|---|---|
| 1 | package Geo::Coder::Free::Local; | ||||||
| 2 | |||||||
| 3 | 18 18 18 | 167196 12 226 | use strict; | ||||
| 4 | 18 18 18 | 28 9 334 | use warnings; | ||||
| 5 | 18 18 18 | 202 5947 42 | use autodie qw(:all); | ||||
| 6 | |||||||
| 7 | 18 18 18 | 43227 12 581 | use Carp; | ||||
| 8 | 18 18 18 | 3479 100833 292 | use Geo::Location::Point 0.19; # 0.16 stores false values; 0.19 derives an accuracy from the fields matched | ||||
| 9 | 18 18 18 | 2652 19 283 | use Geo::Coder::Free::MissCache; | ||||
| 10 | 18 18 18 | 2477 52 418 | use Geo::Coder::Free::Provenance qw(GEOCODER); | ||||
| 11 | 18 18 18 | 2794 29 566 | use Geo::Coder::Free::Utils qw(_abbreviate _normalize); | ||||
| 12 | 18 18 18 | 4062 484483 518 | use Geo::StreetAddress::US; | ||||
| 13 | 18 18 18 | 4410 1046688 357 | use Lingua::EN::AddressParse; | ||||
| 14 | 18 18 18 | 2888 55070 235 | use Locale::CA; | ||||
| 15 | 18 18 18 | 2951 3116 229 | use Locale::US; | ||||
| 16 | 18 18 18 | 4711 1033276 309 | use Object::Configure; | ||||
| 17 | 18 18 18 | 44 15 273 | use Params::Get; | ||||
| 18 | 18 18 18 | 35 15 307 | use Readonly; | ||||
| 19 | 18 18 18 | 3702 175181 547 | use Text::xSV::Slurp; | ||||
| 20 | # Imported functions are not methods: without this, $obj->croak() and the like | ||||||
| 21 | # would resolve. | ||||||
| 22 | 18 18 18 | 50 13 51 | use namespace::clean; | ||||
| 23 | |||||||
| 24 | =encoding utf-8 | ||||||
| 25 | |||||||
| 26 - 34 | =head1 NAME Geo::Coder::Free::Local - Geocode using user-curated local data =head1 VERSION Version 0.51 =cut | ||||||
| 35 | |||||||
| 36 | our $VERSION = '0.51'; | ||||||
| 37 | |||||||
| 38 - 85 | =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 | ||||||
| 86 | |||||||
| 87 | # Libpostal detection states - avoid magic numbers | ||||||
| 88 | 18 18 18 | 7029 18 405 | use constant LIBPOSTAL_UNKNOWN => 0; | ||||
| 89 | 18 18 18 | 30 15 349 | use constant LIBPOSTAL_INSTALLED => 1; | ||||
| 90 | 18 18 18 | 94 65 47812 | use constant LIBPOSTAL_NOT_INSTALLED => -1; | ||||
| 91 | |||||||
| 92 | # Module-level singleton flags - see LIMITATIONS re: thread safety. | ||||||
| 93 | our $libpostal_is_installed = LIBPOSTAL_UNKNOWN; | ||||||
| 94 | |||||||
| 95 | # Lazily initialised Locale singletons to avoid repeated object construction | ||||||
| 96 | # (Locale::US->new() and Locale::CA->new() are called multiple times per geocode; | ||||||
| 97 | # caching them reduces object churn on busy lookups). | ||||||
| 98 | my $_locale_us; | ||||||
| 99 | my $_locale_ca; | ||||||
| 100 | |||||||
| 101 | # Confidence thresholds for _search results, aligned with OpenAddresses backend | ||||||
| 102 | Readonly::Scalar my $CONF_EXACT => 1.0; | ||||||
| 103 | Readonly::Scalar my $CONF_HIGH => 0.7; | ||||||
| 104 | Readonly::Scalar my $CONF_MEDIUM => 0.5; | ||||||
| 105 | |||||||
| 106 | # Hard-coded place-name aliases for ambiguous or database-inconsistent locations. | ||||||
| 107 | # MAINTENANCE NOTE: the same mappings appear in Geo::Coder::Free::__DATA__. | ||||||
| 108 | # Both should eventually be replaced by a shared external config file. | ||||||
| 109 | our %alternatives = ( | ||||||
| 110 | 'ST LAWRENCE, THANET, KENT' => 'RAMSGATE, KENT', | ||||||
| 111 | 'ST PETERS, THANET, KENT' => 'ST PETERS, KENT', | ||||||
| 112 | 'MINSTER, THANET, KENT' => 'RAMSGATE, KENT', | ||||||
| 113 | 'TYNE AND WEAR' => 'BOROUGH OF NORTH TYNESIDE', | ||||||
| 114 | ); | ||||||
| 115 | |||||||
| 116 - 160 | =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 | ||||||
| 161 | |||||||
| 162 | sub new { | ||||||
| 163 | 20 | 1 | 381680 | my $class = shift; | |||
| 164 | 20 | 51 | my $params = Params::Get::get_params(undef, @_) // {}; | ||||
| 165 | |||||||
| 166 | 20 | 261 | if (!defined($class)) { | ||||
| 167 | 1 | 1 | $class = __PACKAGE__; # FIXME: only works when no arguments are given | ||||
| 168 | } elsif (ref($class)) { | ||||||
| 169 | 3 3 3 | 34 6 13 | return bless { %{$class}, %{$params} }, ref($class); | ||||
| 170 | } | ||||||
| 171 | |||||||
| 172 | 17 | 38 | $params = Object::Configure::configure($class, $params); | ||||
| 173 | |||||||
| 174 | 17 | 123077 | my @data = <DATA>; | ||||
| 175 | |||||||
| 176 | my $self = bless { | ||||||
| 177 | data => xsv_slurp( | ||||||
| 178 | shape => 'aoh', | ||||||
| 179 | text_csv => { | ||||||
| 180 | allow_loose_quotes => 1, | ||||||
| 181 | blank_is_undef => 1, | ||||||
| 182 | empty_is_undef => 1, | ||||||
| 183 | binary => 1, | ||||||
| 184 | escape_char => '\\', | ||||||
| 185 | }, | ||||||
| 186 | 1068 | 821 | string => \join('', grep { !/^\s*(#|$)/ } @data), | ||||
| 187 | ), | ||||||
| 188 | 17 17 | 74 17151 | %{$params}, | ||||
| 189 | }, $class; | ||||||
| 190 | |||||||
| 191 | # Derive geographic centres from clusters of 3+ co-located data points, | ||||||
| 192 | # adding implicit town/city records to the dataset. | ||||||
| 193 | 17 | 79 | my $towns = _find_geographic_centres($self->{'data'}); | ||||
| 194 | 17 12 12 | 29 13 17 | push @{$self->{'data'}}, @{$towns} if $towns; | ||||
| 195 | |||||||
| 196 | # Build a hash index on the stringified Geo::Location::Point representation | ||||||
| 197 | # so that exact-match lookups avoid the O(n) scan in _search. | ||||||
| 198 | 17 17 | 11 21 | for my $row (@{$self->{'data'}}) { | ||||
| 199 | 1128 | 891 | my $key = lc(Geo::Location::Point->new($row)->as_string()); | ||||
| 200 | 1128 | 78104 | $self->{'index'}{$key} = $row; | ||||
| 201 | } | ||||||
| 202 | |||||||
| 203 | # Build an O(1) region existence index used by geocode() to skip the O(n) | ||||||
| 204 | # grep before _search when no rows for the requested state/country exist. | ||||||
| 205 | # Major: every (state|country) pair in the dataset is represented here. | ||||||
| 206 | # Minor: if geocode's target pair is absent, _search can never match. | ||||||
| 207 | # Conclusion: early return is safe - eliminates the O(n) grep per call. | ||||||
| 208 | 17 17 | 14 21 | for my $row (@{$self->{'data'}}) { | ||||
| 209 | 1128 | 931 | my $rk = uc($row->{'state'} // '') . '|' . uc($row->{'country'} // ''); | ||||
| 210 | 1128 | 602 | $self->{'region_index'}{$rk} = 1; | ||||
| 211 | } | ||||||
| 212 | |||||||
| 213 | 17 | 90 | return $self; | ||||
| 214 | } | ||||||
| 215 | |||||||
| 216 | # What this backend counts as a miss, and where the whole process remembers | ||||||
| 217 | # it. The data is in __DATA__, so every instance has the same data and a miss | ||||||
| 218 | # found by one is a miss for all of them. | ||||||
| 219 | sub _miss_scope { | ||||||
| 220 | 92 | 179 | return Geo::Coder::Free::MissCache->scope_for('Local'); | ||||
| 221 | } | ||||||
| 222 | |||||||
| 223 | sub _missed { | ||||||
| 224 | 54 | 51 | my ($self, $lc) = @_; | ||||
| 225 | |||||||
| 226 | 54 | 58 | return Geo::Coder::Free::MissCache->missed($self->_miss_scope(), $lc); | ||||
| 227 | } | ||||||
| 228 | |||||||
| 229 | sub _remember_miss { | ||||||
| 230 | 38 | 44 | my ($self, $lc) = @_; | ||||
| 231 | |||||||
| 232 | 38 | 43 | Geo::Coder::Free::MissCache->remember($self->_miss_scope(), $lc); | ||||
| 233 | 38 | 99 | return; | ||||
| 234 | } | ||||||
| 235 | |||||||
| 236 - 258 | =head2 reads
my $count = $geocoder->reads();
How many times this backend has searched its dataset since the object was
created. A lookup answered from the index, from the result cache or from the
shared miss cache does not increase it, so C<geocode_many()> can take the count
before and after a value to tell a lookup that cost a search from one that was
answered from a cache.
=head3 API SPECIFICATION
=head4 input
# Input schema (Params::Validate::Strict)
# none
=head4 output
# Output schema (Return::Set)
{ type => 'integer', min => 0 } # 0 for a new object
=cut | ||||||
| 259 | |||||||
| 260 | sub reads { | ||||||
| 261 | 7 | 1 | 200 | my $self = shift; | |||
| 262 | |||||||
| 263 | 7 | 20 | return $self->{'reads'} // 0; | ||||
| 264 | } | ||||||
| 265 | |||||||
| 266 - 330 | =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 PROVENANCE
Every result carries C<geocoder>, C<database>, C<confidence> and C<accuracy>
(section 2.1 of F<BI-Phase6-Dependency-Specs.md>). This backend reports
C<database> as C<Geo::Coder::Free::Local>, and C<accuracy> is what the matched
columns justify: a house number makes it an C<address>, a road a C<street>, a
name or city a C<locality>, a state a C<region> and a country a C<country>. A
lookup answered from the index, which matches a whole row, is an exact match
(C<confidence> of 1.0) whose accuracy comes from the row's own fields - so a
derived geographic centre, which carries a city, state and country and nothing
narrower, is a C<locality>.
=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 | ||||||
| 331 | |||||||
| 332 | sub geocode { | ||||||
| 333 | 124 | 1 | 65371 | my $self = shift; | |||
| 334 | |||||||
| 335 | # Handle being called as a function rather than a method | ||||||
| 336 | 124 | 205 | if (!ref($self)) { | ||||
| 337 | 4 | 8 | if (scalar @_) { | ||||
| 338 | 1 | 3 | return __PACKAGE__->new()->geocode(@_); | ||||
| 339 | } elsif (!defined($self)) { | ||||||
| 340 | 1 | 4 | Carp::croak('Usage: ', __PACKAGE__, '::geocode(location => $location)'); | ||||
| 341 | } elsif ($self eq __PACKAGE__) { | ||||||
| 342 | 1 | 5 | Carp::croak("Usage: $self", '::geocode(location => $location)'); | ||||
| 343 | } | ||||||
| 344 | 1 | 2 | return __PACKAGE__->new()->geocode($self); | ||||
| 345 | } elsif (ref($self) eq 'HASH') { | ||||||
| 346 | 1 | 2 | return __PACKAGE__->new()->geocode($self); | ||||
| 347 | } | ||||||
| 348 | |||||||
| 349 | 119 | 176 | my %params = _normalize_args( | ||||
| 350 | 'Usage: ' . __PACKAGE__ . '::geocode(location => $location)', | ||||||
| 351 | 'location', @_ | ||||||
| 352 | ); | ||||||
| 353 | |||||||
| 354 | 115 | 167 | my $location = $params{'location'} | ||||
| 355 | or Carp::croak('Usage: geocode(location => $location)'); | ||||||
| 356 | |||||||
| 357 | # Normalise the lookup key - "USA" is treated the same as "US" | ||||||
| 358 | 111 | 135 | my $lc = lc($location); | ||||
| 359 | 111 | 285 | $lc =~ s/,\s*usa$/, us/i; | ||||
| 360 | |||||||
| 361 | 111 | 193 | return $self->{'cache'}{$lc} if exists $self->{'cache'}{$lc}; | ||||
| 362 | return $self->_cache_and_return($lc, $self->_result_for($self->{'index'}{$lc})) | ||||||
| 363 | 74 | 125 | if exists $self->{'index'}{$lc}; | ||||
| 364 | |||||||
| 365 | # A miss found by an earlier instance, or an earlier call through this | ||||||
| 366 | # one, means the same search would find nothing again: the data does not | ||||||
| 367 | # change while the process runs. clear_miss_cache() is for when it has. | ||||||
| 368 | 54 | 78 | return if $self->_missed($lc); | ||||
| 369 | |||||||
| 370 | # This backend only handles full addresses (at least "road, city, country") | ||||||
| 371 | 47 | 93 | return $self->_remember_miss($lc) if $location !~ /,.+,/; | ||||
| 372 | |||||||
| 373 | 32 | 29 | my $ap; | ||||
| 374 | 32 | 167 | if ($location =~ /USA?$/ || $location =~ /United States$/) { | ||||
| 375 | 3 | 16 | $ap = $self->{'ap'}{'us'} //= Lingua::EN::AddressParse->new( | ||||
| 376 | country => 'US', auto_clean => 1, force_case => 1, force_post_code => 0 | ||||||
| 377 | ); | ||||||
| 378 | } elsif ($location =~ /(England|Scotland|Wales|Northern Ireland|UK|GB)$/i) { | ||||||
| 379 | 22 | 78 | $ap = $self->{'ap'}{'gb'} //= Lingua::EN::AddressParse->new( | ||||
| 380 | country => 'GB', auto_clean => 1, force_case => 1, force_post_code => 0 | ||||||
| 381 | ); | ||||||
| 382 | } elsif ($location =~ /Canada$/) { | ||||||
| 383 | # TODO: Canadian address parsing not yet implemented | ||||||
| 384 | 1 | 2 | return $self->_remember_miss($lc); | ||||
| 385 | } elsif ($location =~ /Australia$/) { | ||||||
| 386 | # TODO: Australian address parsing not yet implemented | ||||||
| 387 | 1 | 1 | return $self->_remember_miss($lc); | ||||
| 388 | } | ||||||
| 389 | |||||||
| 390 | 30 | 5808353 | if ($ap) { | ||||
| 391 | 25 | 24 | my $l = $location; | ||||
| 392 | 25 | 200 | $l =~ s/(.+), (England|UK)$/$1, GB/i; | ||||
| 393 | |||||||
| 394 | 25 | 59 | if ($ap->parse($l) == 0) { | ||||
| 395 | 9 | 271411 | my %c = $ap->components(); | ||||
| 396 | 9 | 1489 | my %addr = (location => $l); | ||||
| 397 | |||||||
| 398 | 9 | 18 | if (my $street = $c{'street_name'}) { | ||||
| 399 | 9 | 15 | if (my $type = $c{'street_type'}) { | ||||
| 400 | 9 | 24 | my $abbrev = _abbreviate($type); | ||||
| 401 | 9 | 47 | $street .= ' ' . ($abbrev || $type); | ||||
| 402 | $street .= ' ' . $c{'street_direction_suffix'} | ||||||
| 403 | 9 | 15 | if $c{'street_direction_suffix'}; | ||||
| 404 | 9 | 9 | $street =~ s/^0+//; | ||||
| 405 | 9 | 15 | $addr{'road'} = $street; | ||||
| 406 | } | ||||||
| 407 | } | ||||||
| 408 | |||||||
| 409 | 9 | 12 | if (length($c{'subcountry'}) == 2) { | ||||
| 410 | 1 | 2 | $addr{'state'} = $c{'subcountry'}; | ||||
| 411 | } else { | ||||||
| 412 | 8 | 12 | my $country_raw = $c{'country'} // ''; | ||||
| 413 | 8 | 24 | if ($country_raw =~ /Canada/i) { | ||||
| 414 | 0 | 0 | $addr{'country'} = 'CA'; | ||||
| 415 | 0 | 0 | $addr{'state'} = _to_two_letter_state('Canada', $c{'subcountry'}); | ||||
| 416 | } elsif ($country_raw =~ /^(United States|USA|US)$/i) { | ||||||
| 417 | 0 | 0 | $addr{'country'} = 'US'; | ||||
| 418 | 0 | 0 | $addr{'state'} = _to_two_letter_state('US', $c{'subcountry'}); | ||||
| 419 | } elsif ($country_raw) { | ||||||
| 420 | 8 | 10 | $addr{'country'} = $country_raw; | ||||
| 421 | 8 | 16 | $addr{'state'} = $c{'subcountry'} if $c{'subcountry'}; | ||||
| 422 | } | ||||||
| 423 | } | ||||||
| 424 | 9 | 12 | $addr{'number'} = $c{'property_identifier'}; | ||||
| 425 | 9 | 11 | $addr{'city'} = $c{'suburb'}; | ||||
| 426 | |||||||
| 427 | 9 | 24 | if (my $rc = $self->_search(\%addr, qw(number road city state country))) { | ||||
| 428 | 1 | 55 | return $self->_cache_and_return($lc, $rc); | ||||
| 429 | } | ||||||
| 430 | 8 | 15 | if ($addr{'number'}) { | ||||
| 431 | 2 | 5 | if (my $rc = $self->_search(\%addr, qw(road city state country))) { | ||||
| 432 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 433 | } | ||||||
| 434 | } | ||||||
| 435 | |||||||
| 436 | # Abort early if no data at all matches this state/country - | ||||||
| 437 | # saves traversing the full dataset for every remaining strategy. | ||||||
| 438 | 8 | 12 | if (!defined($addr{'country'})) { | ||||
| 439 | 1 | 6 | $addr{'country'} = $l =~ /(United States|USA|US)$/i ? 'US' | ||||
| 440 | : Carp::croak("TODO: extract country from $l"); | ||||||
| 441 | } | ||||||
| 442 | # O(1) region check via index built in new() - replaces the former | ||||||
| 443 | # O(n) grep across all rows. | ||||||
| 444 | # Major: region_index maps every (state|country) pair in the dataset. | ||||||
| 445 | # Minor: if target pair is absent, no _search call can match. | ||||||
| 446 | # Conclusion: early return is equivalent and faster. | ||||||
| 447 | 8 | 84 | my $rk = uc($addr{'state'} // '') . '|' . uc($addr{'country'} // ''); | ||||
| 448 | 8 | 29 | return $self->_remember_miss($lc) unless $self->{'region_index'}{$rk}; | ||||
| 449 | } | ||||||
| 450 | } | ||||||
| 451 | |||||||
| 452 | 27 | 80263 | if ($location =~ /^(.+?)[,\s]+(United States|USA|US)$/i) { | ||||
| 453 | 3 | 21 | my $l = $1; | ||||
| 454 | 3 | 5 | $l =~ tr/,/ /; | ||||
| 455 | 3 | 11 | $l =~ s/\s{2,}/ /g; | ||||
| 456 | |||||||
| 457 | # Geo::StreetAddress::US is buggy (RT#122617) - skip county-style addresses | ||||||
| 458 | 3 | 7 | if ($location !~ /\sCounty,/i) { | ||||
| 459 | 3 | 16 | my $href = Geo::StreetAddress::US->parse_location($l) | ||||
| 460 | // Geo::StreetAddress::US->parse_address($l); | ||||||
| 461 | |||||||
| 462 | 3 | 426 | if ($href) { | ||||
| 463 | 3 | 5 | if (my $state = $href->{'state'}) { | ||||
| 464 | 3 | 5 | $state = _to_two_letter_state('US', $state) if length($state) > 2; | ||||
| 465 | 3 | 6 | my $city = uc($href->{'city'} // ''); | ||||
| 466 | 3 | 5 | if (my $street = $href->{'street'}) { | ||||
| 467 | 3 | 6 | if ($href->{'type'}) { | ||||
| 468 | 1 | 2 | $street .= ' ' . _abbreviate($href->{'type'}); | ||||
| 469 | } | ||||||
| 470 | 3 | 13 | $street .= ' ' . $href->{'suffix'} if $href->{'suffix'}; | ||||
| 471 | 3 | 9 | $street = $href->{'prefix'} . " $street" if $href->{'prefix'}; | ||||
| 472 | my %addr = ( | ||||||
| 473 | 3 | 11 | number => $href->{'number'}, | ||||
| 474 | road => $street, | ||||||
| 475 | city => $city, | ||||||
| 476 | state => $state, | ||||||
| 477 | country => 'US', | ||||||
| 478 | ); | ||||||
| 479 | 3 | 4 | if ($href->{'number'}) { | ||||
| 480 | 0 | 0 | if (my $rc = $self->_search(\%addr, qw(number road city state country))) { | ||||
| 481 | 0 | 0 | $rc->{'country'} = 'US'; | ||||
| 482 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 483 | } | ||||||
| 484 | } | ||||||
| 485 | 3 | 8 | if (my $rc = $self->_search(\%addr, qw(road city state country))) { | ||||
| 486 | 1 | 73 | $rc->{'country'} = 'US'; | ||||
| 487 | 1 | 2 | return $self->_cache_and_return($lc, $rc); | ||||
| 488 | } | ||||||
| 489 | # G:S:US puts building name into street when number is absent | ||||||
| 490 | 2 | 6 | if ($street && !$href->{'number'}) { | ||||
| 491 | 2 | 4 | $addr{'name'} = delete $addr{'road'}; | ||||
| 492 | 2 | 4 | if (my $rc = $self->_search(\%addr, qw(name city state country))) { | ||||
| 493 | 0 | 0 | $rc->{'country'} = 'US'; | ||||
| 494 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 495 | } | ||||||
| 496 | } | ||||||
| 497 | } | ||||||
| 498 | } | ||||||
| 499 | } | ||||||
| 500 | } | ||||||
| 501 | |||||||
| 502 | # Fallback: try "name, street, town, state, US" split | ||||||
| 503 | 2 | 9 | my @parts = split(/,\s*/, $location); | ||||
| 504 | 2 | 4 | if (scalar(@parts) == 5) { | ||||
| 505 | 1 | 2 | my $state = _to_two_letter_state('US', $parts[3]); | ||||
| 506 | 1 | 2 | if (length($state) == 2) { | ||||
| 507 | 1 | 2 | my %addr = (city => $parts[2], state => $state, country => 'US'); | ||||
| 508 | 1 | 2 | if ($parts[0] !~ /^\d/) { | ||||
| 509 | 1 | 2 | $addr{'name'} = $parts[0]; | ||||
| 510 | 1 | 2 | if ($parts[1] =~ /^(\d+)\s+(.+)/) { | ||||
| 511 | 1 | 2 | $addr{'number'} = $1; | ||||
| 512 | 1 | 3 | $addr{'road'} = _normalize($2); | ||||
| 513 | 1 | 2 | if (my $rc = $self->_search(\%addr, qw(name number road city state country))) { | ||||
| 514 | 1 | 94 | $rc->{'country'} = 'US'; | ||||
| 515 | 1 | 2 | return $self->_cache_and_return($lc, $rc); | ||||
| 516 | } | ||||||
| 517 | } else { | ||||||
| 518 | 0 | 0 | $addr{'road'} = _normalize($parts[1]); | ||||
| 519 | 0 | 0 | if (my $rc = $self->_search(\%addr, qw(name road city state country))) { | ||||
| 520 | 0 | 0 | $rc->{'country'} = 'US'; | ||||
| 521 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 522 | } | ||||||
| 523 | } | ||||||
| 524 | } else { | ||||||
| 525 | 0 | 0 | $addr{'number'} = $parts[0]; | ||||
| 526 | 0 | 0 | $addr{'road'} = _normalize($parts[1]); | ||||
| 527 | 0 | 0 | if (my $rc = $self->_search(\%addr, qw(number road city state country))) { | ||||
| 528 | 0 | 0 | $rc->{'country'} = 'US'; | ||||
| 529 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 530 | } | ||||||
| 531 | } | ||||||
| 532 | } | ||||||
| 533 | } | ||||||
| 534 | } | ||||||
| 535 | |||||||
| 536 | # Simple "Town, County, England" - no further sub-parsers will help | ||||||
| 537 | 25 | 73 | if (($location =~ /[^,]+,[^,]+,.*England$/) && ($location !~ /[^,]+,[^,]+,[^,]+,.*England$/)) { | ||||
| 538 | 5 | 11 | return $self->_remember_miss($lc); | ||||
| 539 | } | ||||||
| 540 | |||||||
| 541 | # Geo::Address::Parser - optional; load lazily, skip if not installed | ||||||
| 542 | 20 | 103 | unless (Geo::Address::Parser->can('parse')) { | ||||
| 543 | 20 20 0 | 16 1267 0 | eval { require Geo::Address::Parser; Geo::Address::Parser->import() }; | ||||
| 544 | } | ||||||
| 545 | 20 | 47 | if (Geo::Address::Parser->can('parse')) { | ||||
| 546 | 0 | 0 | my $addr_parser = Geo::Address::Parser->new(country => 'UK'); | ||||
| 547 | 0 | 0 | if (my $fields = $addr_parser->parse($location)) { | ||||
| 548 | # Remove undef fields so _search only matches defined columns | ||||||
| 549 | 0 0 0 | 0 0 0 | delete $fields->{$_} for grep { !defined $fields->{$_} } keys %{$fields}; | ||||
| 550 | 0 0 | 0 0 | if (my $rc = $self->_search($fields, keys %{$fields})) { | ||||
| 551 | 0 | 0 | $rc->{'country'} = 'UK'; | ||||
| 552 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 553 | } | ||||||
| 554 | } | ||||||
| 555 | } | ||||||
| 556 | |||||||
| 557 | # Geo::libpostal - accurate but uses enormous RAM; initialise at most once | ||||||
| 558 | 20 | 27 | if ($libpostal_is_installed == LIBPOSTAL_UNKNOWN) { | ||||
| 559 | 6 6 0 | 6 296 0 | if (eval { require Geo::libpostal; 1 }) { | ||||
| 560 | 0 | 0 | Geo::libpostal->import(); | ||||
| 561 | 0 | 0 | $libpostal_is_installed = LIBPOSTAL_INSTALLED; | ||||
| 562 | } else { | ||||||
| 563 | 6 | 8 | $libpostal_is_installed = LIBPOSTAL_NOT_INSTALLED; | ||||
| 564 | } | ||||||
| 565 | } | ||||||
| 566 | |||||||
| 567 | 20 | 27 | if ($libpostal_is_installed == LIBPOSTAL_INSTALLED) { | ||||
| 568 | 0 | 0 | my %addr = Geo::libpostal::parse_address($location); | ||||
| 569 | 0 | 0 | if (%addr) { | ||||
| 570 | # Normalise field names to match our data schema | ||||||
| 571 | 0 | 0 | $addr{'number'} = delete $addr{'house_number'} if $addr{'house_number'} && !$addr{'number'}; | ||||
| 572 | 0 | 0 | $addr{'name'} = delete $addr{'house'} if $addr{'house'} && !$addr{'name'}; | ||||
| 573 | 0 | 0 | $addr{'location'} = $location; | ||||
| 574 | |||||||
| 575 | 0 | 0 | if (my $street = $addr{'road'}) { | ||||
| 576 | 0 | 0 | $addr{'road'} = _normalize($street); | ||||
| 577 | } | ||||||
| 578 | |||||||
| 579 | # libpostal returns "england" as a state - map to country code | ||||||
| 580 | 0 | 0 | if (defined($addr{'state'}) && !defined($addr{'country'}) | ||||
| 581 | && $addr{'state'} eq 'england') { | ||||||
| 582 | 0 | 0 | delete $addr{'state'}; | ||||
| 583 | 0 | 0 | $addr{'country'} = 'GB'; | ||||
| 584 | } | ||||||
| 585 | |||||||
| 586 | 0 | 0 | if ($addr{'country'} && ($addr{'state'} || $addr{'state_district'})) { | ||||
| 587 | 0 | 0 | if ($addr{'country'} =~ /Canada/i) { | ||||
| 588 | 0 | 0 | $addr{'country'} = 'Canada'; | ||||
| 589 | $addr{'state'} = _to_two_letter_state('Canada', $addr{'state'}) | ||||||
| 590 | 0 | 0 | if $addr{'state'} && length($addr{'state'}) > 2; | ||||
| 591 | } elsif ($addr{'country'} =~ /^(United States|USA|US)$/i) { | ||||||
| 592 | 0 | 0 | $addr{'country'} = 'US'; | ||||
| 593 | $addr{'state'} = _to_two_letter_state('US', $addr{'state'}) | ||||||
| 594 | 0 | 0 | if $addr{'state'} && length($addr{'state'}) > 2; | ||||
| 595 | } | ||||||
| 596 | |||||||
| 597 | 0 | 0 | if ($addr{'state_district'}) { | ||||
| 598 | 0 | 0 | $addr{'state_district'} =~ s/^(.+)\s+COUNTY\s*$/$1/i; | ||||
| 599 | 0 | 0 | if (my $rc = $self->_search(\%addr, qw(number road city state_district state country))) { | ||||
| 600 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 601 | } | ||||||
| 602 | } | ||||||
| 603 | 0 | 0 | if (my $rc = $self->_search(\%addr, qw(number road city state country))) { | ||||
| 604 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 605 | } | ||||||
| 606 | 0 | 0 | if ($addr{'number'}) { | ||||
| 607 | 0 | 0 | if (my $rc = $self->_search(\%addr, qw(road city state country))) { | ||||
| 608 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 609 | } | ||||||
| 610 | } | ||||||
| 611 | } | ||||||
| 612 | } | ||||||
| 613 | } | ||||||
| 614 | |||||||
| 615 | # Last resort: decompose "road, city, state, country" with a regex | ||||||
| 616 | 20 | 98 | if ($location =~ /^(.+?),\s*([\s\w]+),\s*([\s\w]+),\s*([\w\s]+)$/) { | ||||
| 617 | 15 | 66 | my %addr = ( | ||||
| 618 | road => $1, | ||||||
| 619 | city => $2, | ||||||
| 620 | state => $3, | ||||||
| 621 | country => $4, | ||||||
| 622 | ); | ||||||
| 623 | 15 | 21 | $addr{'state'} =~ s/\s+$//g; | ||||
| 624 | 15 | 18 | $addr{'country'} =~ s/\s+$//g; | ||||
| 625 | |||||||
| 626 | 15 | 38 | if ($addr{'road'} =~ /([\w\s]+),*\s+(.+)/) { | ||||
| 627 | 14 | 21 | $addr{'name'} = $1; | ||||
| 628 | 14 | 18 | $addr{'road'} = $2; | ||||
| 629 | } | ||||||
| 630 | 15 | 38 | if ($addr{'road'} =~ /^(\d+)\s+(.+)/) { | ||||
| 631 | 3 | 4 | $addr{'number'} = $1; | ||||
| 632 | 3 | 4 | $addr{'road'} = $2; | ||||
| 633 | 3 | 6 | if (my $rc = $self->_search(\%addr, qw(name number road city state country))) { | ||||
| 634 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 635 | } | ||||||
| 636 | } elsif (my $rc = $self->_search(\%addr, qw(name road city state country))) { | ||||||
| 637 | 2 | 172 | return $self->_cache_and_return($lc, $rc); | ||||
| 638 | } | ||||||
| 639 | 13 | 50 | if ($addr{'name'} && !defined($addr{'number'})) { | ||||
| 640 | 10 | 13 | if (my $rc = $self->_search(\%addr, qw(name road city state country))) { | ||||
| 641 | 0 | 0 | return $self->_cache_and_return($lc, $rc); | ||||
| 642 | } | ||||||
| 643 | } | ||||||
| 644 | } | ||||||
| 645 | |||||||
| 646 | # Try the alternatives table - curated mappings for inconsistent names | ||||||
| 647 | 18 | 31 | $location = uc($location); | ||||
| 648 | 18 | 28 | for my $left (keys %alternatives) { | ||||
| 649 | 68 | 300 | next unless $location =~ $left; | ||||
| 650 | 7 | 27 | (my $mapped = $location) =~ s/$left/$alternatives{$left}/; | ||||
| 651 | 7 | 10 | $params{'location'} = $mapped; | ||||
| 652 | 7 | 25 | if (my $rc = $self->geocode(\%params)) { | ||||
| 653 | 1 | 3 | return $self->_cache_and_return($lc, $rc); | ||||
| 654 | } | ||||||
| 655 | 6 | 26 | if ($mapped =~ /(.+), (England|UK)$/i) { | ||||
| 656 | 4 | 6 | $params{'location'} = "$1, GB"; | ||||
| 657 | 4 | 4 | if (my $rc = $self->geocode(\%params)) { | ||||
| 658 | 3 | 9 | return $self->_cache_and_return($lc, $rc); | ||||
| 659 | } | ||||||
| 660 | } | ||||||
| 661 | 3 | 4 | $params{'location'} = $location; | ||||
| 662 | } | ||||||
| 663 | |||||||
| 664 | 14 | 29 | return $self->_remember_miss($lc); | ||||
| 665 | } | ||||||
| 666 | |||||||
| 667 - 690 | =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 | ||||||
| 691 | |||||||
| 692 | sub reverse_geocode { | ||||||
| 693 | 64 | 1 | 33205 | my $self = shift; | |||
| 694 | |||||||
| 695 | 64 | 111 | if (!ref($self)) { | ||||
| 696 | 3 | 6 | if (scalar @_) { | ||||
| 697 | 1 | 2 | return __PACKAGE__->new()->reverse_geocode(@_); | ||||
| 698 | } elsif (!defined($self)) { | ||||||
| 699 | 1 | 9 | Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")'); | ||||
| 700 | } elsif ($self eq __PACKAGE__) { | ||||||
| 701 | 1 | 6 | Carp::croak("Usage: $self", '::reverse_geocode(latlng => "$lat,$long")'); | ||||
| 702 | } | ||||||
| 703 | 0 | 0 | return __PACKAGE__->new()->reverse_geocode($self); | ||||
| 704 | } elsif (ref($self) eq 'HASH') { | ||||||
| 705 | 0 | 0 | return __PACKAGE__->new()->reverse_geocode($self); | ||||
| 706 | } | ||||||
| 707 | |||||||
| 708 | 61 | 90 | my %params = _normalize_args( | ||||
| 709 | 'Usage: ' . __PACKAGE__ . '::reverse_geocode(latlng => "$lat,$long")', | ||||||
| 710 | 'latlng', @_ | ||||||
| 711 | ); | ||||||
| 712 | |||||||
| 713 | 59 | 68 | my ($latitude, $longitude); | ||||
| 714 | 59 | 69 | if (my $latlng = $params{'latlng'}) { | ||||
| 715 | 36 | 57 | ($latitude, $longitude) = split /,/, $latlng; | ||||
| 716 | } else { | ||||||
| 717 | 23 | 20 | $latitude = $params{'lat'}; | ||||
| 718 | 23 | 45 | $longitude = $params{'lon'} // $params{'long'}; | ||||
| 719 | } | ||||||
| 720 | |||||||
| 721 | 59 | 104 | if (!defined($latitude) || !defined($longitude)) { | ||||
| 722 | 5 | 20 | Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")'); | ||||
| 723 | } | ||||||
| 724 | |||||||
| 725 | 54 | 27 | my @rc; | ||||
| 726 | 54 54 | 38 64 | for my $row (@{$self->{'data'}}) { | ||||
| 727 | 4408 | 4667 | next unless defined($row->{'latitude'}) && defined($row->{'longitude'}); | ||||
| 728 | next unless _equal($row->{'latitude'}, $latitude, 4) | ||||||
| 729 | 4408 | 2784 | && _equal($row->{'longitude'}, $longitude, 4); | ||||
| 730 | |||||||
| 731 | # Rows in $self->{'data'} are blessed as Geo::Location::Point by | ||||||
| 732 | # the index-building loop in new() - calling as_string() is safe here. | ||||||
| 733 | 91 | 106 | my $location = uc($row->as_string()); | ||||
| 734 | 91 | 282 | if (wantarray) { | ||||
| 735 | 83 | 57 | push @rc, $location; | ||||
| 736 | # Add any reverse-alternative mappings for this location | ||||||
| 737 | # keys, not each(): %alternatives is package-wide, and an each() | ||||||
| 738 | # iterator left part-way would make the next pass skip entries | ||||||
| 739 | 83 | 127 | foreach my $left (sort keys %alternatives) { | ||||
| 740 | 332 | 216 | my $right = $alternatives{$left}; | ||||
| 741 | 332 | 959 | if ($location =~ $right) { | ||||
| 742 | 102 | 149 | (my $l = $location) =~ s/$right/$left/; | ||||
| 743 | 102 | 107 | push @rc, $l; | ||||
| 744 | } | ||||||
| 745 | } | ||||||
| 746 | } else { | ||||||
| 747 | 8 | 18 | return $location; | ||||
| 748 | } | ||||||
| 749 | } | ||||||
| 750 | 46 | 106 | return @rc; | ||||
| 751 | } | ||||||
| 752 | |||||||
| 753 - 769 | =head2 ua Does nothing - present for drop-in compatibility with other Geo::Coder::* modules. =head3 API SPECIFICATION =head4 input # Input schema (Params::Validate::Strict) # any arguments are accepted and ignored =head4 output # Output schema (Return::Set) # nothing: an empty list, or undef in scalar context =cut | ||||||
| 770 | |||||||
| 771 | 1 | sub ua { } | |||||
| 772 | |||||||
| 773 | # ----------------------------------------------------------------------- | ||||||
| 774 | # Private helpers | ||||||
| 775 | # ----------------------------------------------------------------------- | ||||||
| 776 | |||||||
| 777 | # Purpose: Normalise the four calling conventions used by geocode/reverse_geocode: | ||||||
| 778 | # hashref, even-length key-val list, single bare string. | ||||||
| 779 | # Entry: $error_msg - croak message if an unsupported ref type is passed. | ||||||
| 780 | # $bare_key - hash key to use when a single bare string is given | ||||||
| 781 | # ('location' for geocode, 'latlng' for reverse_geocode). | ||||||
| 782 | # @args - raw @_ after $self has been shifted. | ||||||
| 783 | # Exit: Flat %params hash. | ||||||
| 784 | # Side Effects: None; croaks on non-hash reference arguments. | ||||||
| 785 | sub _normalize_args { | ||||||
| 786 | 187 | 6291 | my ($error_msg, $bare_key, @args) = @_; | ||||
| 787 | 187 50 | 217 76 | return %{$args[0]} if ref($args[0]) eq 'HASH'; | ||||
| 788 | 137 | 143 | Carp::croak($error_msg) if ref($args[0]); | ||||
| 789 | 132 | 374 | return @args if @args && @args % 2 == 0; | ||||
| 790 | 42 | 73 | return ($bare_key => $args[0]) if @args == 1; | ||||
| 791 | # M7 (logic-gap closure): odd-count > 1 is not a valid calling convention. | ||||||
| 792 | # Fail fast rather than silently discarding trailing arguments. | ||||||
| 793 | 9 | 27 | Carp::croak($error_msg) if @args; | ||||
| 794 | 5 | 7 | return (); | ||||
| 795 | } | ||||||
| 796 | |||||||
| 797 | # Purpose: Store $rc in the lookup cache under $key and return it. | ||||||
| 798 | # Reduces the repeated "$self->{cache}{$k} = $r; return $r;" pattern | ||||||
| 799 | # that appeared ~12 times across geocode. | ||||||
| 800 | # Entry: $self, $key (lc string), $rc (Geo::Location::Point or undef). | ||||||
| 801 | # Exit: $rc (returned transparently). | ||||||
| 802 | # Side Effects: Writes to $self->{cache}. | ||||||
| 803 | sub _cache_and_return { | ||||||
| 804 | 31 | 2927 | my ($self, $key, $rc) = @_; | ||||
| 805 | 31 | 38 | $self->{'cache'}{$key} = $rc; | ||||
| 806 | 31 | 65 | return $rc; | ||||
| 807 | } | ||||||
| 808 | |||||||
| 809 | # Purpose: Resolve a full state/province name to its two-letter code using | ||||||
| 810 | # the appropriate Locale:: module. Returns the original string if | ||||||
| 811 | # no mapping is found or the input is already two characters. | ||||||
| 812 | # Locale::US and Locale::CA objects are cached as module-level | ||||||
| 813 | # singletons to avoid repeated construction overhead. | ||||||
| 814 | # Entry: $country - country name or ISO code; $state - state/province name. | ||||||
| 815 | # Exit: Two-letter code or $state unchanged. | ||||||
| 816 | # Side Effects: May initialise $_locale_us or $_locale_ca singletons. | ||||||
| 817 | sub _to_two_letter_state { | ||||||
| 818 | 19 | 14940 | my ($country, $state) = @_; | ||||
| 819 | 19 | 68 | return $state // '' if !defined($state) || length($state) <= 2; | ||||
| 820 | |||||||
| 821 | 14 | 41 | if ($country =~ /^(United States|USA|US)$/i) { | ||||
| 822 | 8 | 27 | $_locale_us //= Locale::US->new(); | ||||
| 823 | 8 | 812 | return $_locale_us->{'state2code'}{uc($state)} // $state; | ||||
| 824 | } elsif ($country =~ /Canada/i) { | ||||||
| 825 | 4 | 14 | $_locale_ca //= Locale::CA->new(); | ||||
| 826 | 4 | 762 | return $_locale_ca->{'province2code'}{uc($state)} // $state; | ||||
| 827 | } | ||||||
| 828 | 2 | 2 | return $state; | ||||
| 829 | } | ||||||
| 830 | |||||||
| 831 | # Purpose: Match parsed address components against all data rows. | ||||||
| 832 | # Each named column must match (case-insensitively) the corresponding | ||||||
| 833 | # data field; a minimum of 3 columns must match for a result. | ||||||
| 834 | # Entry: $self, $data - hashref of address components, | ||||||
| 835 | # @columns - ordered list of keys to compare. | ||||||
| 836 | # Exit: Geo::Location::Point on match; undef otherwise. | ||||||
| 837 | # Side Effects: May delete undef entries from $data (for undefined optional fields). | ||||||
| 838 | sub _search { | ||||||
| 839 | 55 | 10644 | my ($self, $data, @columns) = @_; | ||||
| 840 | |||||||
| 841 | 55 | 58 | $self->{'reads'}++; | ||||
| 842 | |||||||
| 843 | # M5 (boolean reduction): the $failed flag was set-and-break in the inner loop | ||||||
| 844 | # then tested in the outer loop - eliminated by using a labeled 'next ROW' | ||||||
| 845 | # that short-circuits directly, removing one boolean gate per iteration. | ||||||
| 846 | 55 55 | 26 77 | ROW: for my $row (@{$self->{'data'}}) { | ||||
| 847 | 3463 | 1669 | my $matched = 0; | ||||
| 848 | 3463 | 1542 | my @matched_columns; | ||||
| 849 | |||||||
| 850 | 3463 | 1779 | for my $column (@columns) { | ||||
| 851 | 3570 | 2116 | if (defined($data->{$column})) { | ||||
| 852 | 3445 | 2550 | next ROW unless defined($row->{$column}); | ||||
| 853 | 2142 | 1959 | next ROW if uc($row->{$column}) ne uc($data->{$column}); | ||||
| 854 | 90 | 58 | $matched++; | ||||
| 855 | # The accuracy is reported from the columns that matched, not | ||||||
| 856 | # every column this call site offered: a caller trying | ||||||
| 857 | # 'number road city...' for a town that has no road must not have | ||||||
| 858 | # its match described as an address. | ||||||
| 859 | 90 | 60 | push @matched_columns, $column; | ||||
| 860 | } elsif (exists $data->{$column}) { | ||||||
| 861 | 2 | 2 | delete $data->{$column}; | ||||
| 862 | } | ||||||
| 863 | } | ||||||
| 864 | |||||||
| 865 | 108 | 57 | next if $matched < 3; | ||||
| 866 | |||||||
| 867 | # Assign confidence based on how many columns contributed to the match | ||||||
| 868 | 13 | 20 | my $confidence = $matched == scalar(@columns) ? $CONF_EXACT | ||||
| 869 | : $matched >= 4 ? $CONF_HIGH | ||||||
| 870 | : $CONF_MEDIUM; | ||||||
| 871 | |||||||
| 872 | # Provenance (Phase 6 dependency specs section 2.1). Set after %{$row} | ||||||
| 873 | # so that a dataset column that happens to be called accuracy cannot stand | ||||||
| 874 | # in for the match this code actually made. | ||||||
| 875 | return Geo::Location::Point->new( | ||||||
| 876 | location => $data->{'location'}, | ||||||
| 877 | confidence => $confidence, | ||||||
| 878 | database => __PACKAGE__, | ||||||
| 879 | 13 13 | 21 29 | %{$row}, | ||||
| 880 | geocoder => GEOCODER, | ||||||
| 881 | accuracy => Geo::Location::Point::accuracy_for(@matched_columns), | ||||||
| 882 | ); | ||||||
| 883 | } | ||||||
| 884 | 42 | 73 | return; | ||||
| 885 | } | ||||||
| 886 | |||||||
| 887 - 899 | =head2 _result_for Build the result for a dataset row that the index matched. The index holds the rows themselves - the same hashrefs C<_search> scans - so the object is built as a copy rather than blessed in place, and the provenance keys every result carries (section 2.1 of F<BI-Phase6-Dependency-Specs.md>) are added where the row does not already record them. An index hit is an exact match on the row's string, so the confidence is C<$CONF_EXACT>, and the accuracy is what the row's own fields justify, through C<Geo::Location::Point::accuracy_for()>. =cut | ||||||
| 900 | |||||||
| 901 | sub _result_for { | ||||||
| 902 | 20 | 21 | my ($self, $row) = @_; | ||||
| 903 | |||||||
| 904 | 20 20 | 14 97 | my %attributes = %{$row}; | ||||
| 905 | 20 | 31 | $attributes{'geocoder'} = GEOCODER; | ||||
| 906 | 20 | 24 | $attributes{'database'} = __PACKAGE__; | ||||
| 907 | 20 | 19 | $attributes{'confidence'} = $CONF_EXACT; | ||||
| 908 | 20 | 49 | $attributes{'accuracy'} = Geo::Location::Point::accuracy_for(keys %attributes); | ||||
| 909 | |||||||
| 910 | 20 | 862 | return Geo::Location::Point->new(\%attributes); | ||||
| 911 | } | ||||||
| 912 | |||||||
| 913 | # Purpose: Calculate the geographic centres (centroids) of all city/state/country | ||||||
| 914 | # clusters in the parsed dataset that contain three or more data points. | ||||||
| 915 | # Clusters with fewer than 3 entries are too sparse to be meaningful. | ||||||
| 916 | # Entry: $rows - arrayref of hashrefs with latitude/longitude/city/state/country. | ||||||
| 917 | # Exit: Arrayref of hashrefs (one per qualifying cluster), or undef if none. | ||||||
| 918 | # Side Effects: None. | ||||||
| 919 | sub _find_geographic_centres { | ||||||
| 920 | 26 | 11209 | my $rows = $_[0]; | ||||
| 921 | |||||||
| 922 | # Group rows by normalised city|state|country key | ||||||
| 923 | 26 | 30 | my %groups; | ||||
| 924 | 26 26 | 16 33 | for my $row (@{$rows}) { | ||||
| 925 | 1082 | 1092 | next unless defined($row->{'latitude'}) && defined($row->{'longitude'}); | ||||
| 926 | 1081 | 1071 | next unless $row->{'latitude'} =~ /^-?\d+\.?\d*$/; | ||||
| 927 | 1079 | 985 | next unless $row->{'longitude'} =~ /^-?\d+\.?\d*$/; | ||||
| 928 | my $key = join '|', | ||||||
| 929 | $row->{'city'} // '', | ||||||
| 930 | $row->{'state'} // '', | ||||||
| 931 | 1055 | 1191 | $row->{'country'} // ''; | ||||
| 932 | 1055 1055 | 452 844 | push @{$groups{$key}}, $row; | ||||
| 933 | } | ||||||
| 934 | |||||||
| 935 | 26 | 23 | my @centres; | ||||
| 936 | 26 | 51 | for my $key (keys %groups) { | ||||
| 937 | 513 | 239 | my $locs = $groups{$key}; | ||||
| 938 | 513 513 | 221 346 | next if @{$locs} < 3; | ||||
| 939 | |||||||
| 940 | 77 | 105 | my ($city, $state, $country) = split /\|/, $key; | ||||
| 941 | 77 | 82 | my ($lat, $lon) = _calculate_centre($locs); | ||||
| 942 | |||||||
| 943 | 77 | 200 | push @centres, { | ||||
| 944 | city => $city, | ||||||
| 945 | state => $state, | ||||||
| 946 | country => $country, | ||||||
| 947 | lat => $lat, | ||||||
| 948 | latitude => $lat, | ||||||
| 949 | longitude => $lon, | ||||||
| 950 | long => $lon, | ||||||
| 951 | lng => $lon, | ||||||
| 952 | }; | ||||||
| 953 | } | ||||||
| 954 | |||||||
| 955 | 26 | 115 | return @centres ? \@centres : undef; | ||||
| 956 | } | ||||||
| 957 | |||||||
| 958 | # Purpose: Compute the arithmetic mean of latitude and longitude for a group | ||||||
| 959 | # of locations. Accurate for small geographic areas; for large areas | ||||||
| 960 | # or locations crossing the antimeridian, a vector-mean is needed. | ||||||
| 961 | # Entry: $locs - arrayref of hashrefs each with 'latitude' and 'longitude'. | ||||||
| 962 | # Exit: ($centre_lat, $centre_lon) as decimal degree strings to 6dp. | ||||||
| 963 | # Side Effects: None. | ||||||
| 964 | sub _calculate_centre { | ||||||
| 965 | 80 | 2367 | my $locs = $_[0]; | ||||
| 966 | 80 | 50 | my ($sum_lat, $sum_lon) = (0, 0); | ||||
| 967 | 80 80 | 34 424 | $sum_lat += $_->{'latitude'}, $sum_lon += $_->{'longitude'} for @{$locs}; | ||||
| 968 | 80 80 | 46 37 | my $n = scalar @{$locs}; | ||||
| 969 | 80 | 244 | return (sprintf('%.6f', $sum_lat / $n), sprintf('%.6f', $sum_lon / $n)); | ||||
| 970 | } | ||||||
| 971 | |||||||
| 972 | # https://www.oreilly.com/library/view/perl-cookbook/1565922433/ch02s03.html | ||||||
| 973 | # Equal within $dp decimal places - avoids floating-point equality pitfalls. | ||||||
| 974 | sub _equal { | ||||||
| 975 | 4598 | 4246 | my ($A, $B, $dp) = @_; | ||||
| 976 | 4598 | 8892 | return sprintf("%.${dp}g", $A) eq sprintf("%.${dp}g", $B); | ||||
| 977 | } | ||||||
| 978 | |||||||
| 979 - 1002 | =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 | ||||||
| 1003 | |||||||
| 1004 | 1; | ||||||
| 1005 | |||||||
| 1006 | # Use abbreviations in the data: RD not ROAD, ST not STREET, etc. | ||||||