lib/Geo/Coder/Free/Local.pm — Duplicate Code Regions
◆ Amber lines duplicate code in another file — hover for location.
◆ Purple: duplicate of another region within this same file.
1: package Geo::Coder::Free::Local;
2:
3: use strict;
4: use warnings;
5: use autodie qw(:all);
6:
7: use Carp;
8: use Geo::Location::Point 0.14;
9: use Geo::Coder::Free::Utils qw(_abbreviate _normalize);
10: use Geo::StreetAddress::US;
11: use Lingua::EN::AddressParse;
12: use Locale::CA;
13: use Locale::US;
14: use Object::Configure;
15: use Params::Get;
16: use Readonly;
17: use Text::xSV::Slurp;
18:
19: =encoding utf-8
20:
21: =head1 NAME
22:
23: Geo::Coder::Free::Local - Geocode using user-curated local data
24:
25: =head1 VERSION
26:
27: Version 0.42
28:
29: =cut
30:
31: our $VERSION = '0.43';
32:
33: =head1 SYNOPSIS
34:
35: use Geo::Coder::Free::Local;
36:
37: my $geocoder = Geo::Coder::Free::Local->new();
38: my $location = $geocoder->geocode(location => 'Ramsgate, Kent, UK');
39: printf "lat=%.6f lon=%.6f\n", $location->lat(), $location->long();
40:
41: =head1 DESCRIPTION
42:
43: Provides geocoding via a user-curated CSV dataset embedded in the module's
44: C<__DATA__> section. Locations in the data were verified by GPS and by
45: inspecting geotagged photographs. The data is read once at construction time
46: and indexed for fast lookup.
47:
48: This is the highest-priority backend tried by C<Geo::Coder::Free>.
49:
50: =head1 LIMITATIONS
51:
52: =over 4
53:
54: =item * The embedded C<__DATA__> dataset covers only a small set of hand-picked locations.
55: There is no mechanism for non-authors to contribute data without patching the module.
56:
57: =item * Canadian and Australian address parsing is not yet implemented; those queries return C<undef>.
58:
59: =item * C<_search> performs an O(n) linear scan through all rows. The hash-based
60: index in C<new()> handles exact string matches; no index exists for partial field matches.
61:
62: =item * C<our %alternatives> duplicates mappings in C<Geo::Coder::Free::__DATA__>.
63: Both should be consolidated into a shared external config file.
64:
65: =item * C<$libpostal_is_installed> is a module-level (effectively global) flag.
66: Not thread-safe in a forking or threaded Perl deployment.
67:
68: =item * The C<__DATA__> filehandle is a one-shot resource. The first call to
69: C<new()> exhausts it with C<my @data = E<lt>DATAE<gt>>; every subsequent
70: C<new()> in the same process reads zero rows and builds an empty index.
71: Construct exactly one C<Local> object per process and share it.
72:
73: =item * The hash-index key is C<lc(Geo::Location::Point-E<gt>new($row)-E<gt>as_string())>,
74: which B<includes the C<name> field>. To get a direct index hit the caller must
75: supply the full string with the venue/place name as the leading component;
76: an address that omits the name falls through to the slower O(n) C<_search> path.
77:
78: =back
79:
80: =cut
81:
82: # Libpostal detection states — avoid magic numbers
83: use constant LIBPOSTAL_UNKNOWN => 0;
84: use constant LIBPOSTAL_INSTALLED => 1;
85: 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: =head1 METHODS
112:
113: =head2 new
114:
115: =head3 SYNOPSIS
116:
117: my $geocoder = Geo::Coder::Free::Local->new();
118: my $geocoder = Geo::Coder::Free::Local->new(cache => $chi_cache);
119:
120: =head3 DESCRIPTION
121:
122: Constructor. Reads the C<__DATA__> CSV block, builds a hash-based lookup
123: index, and derives the geographic centre of any city/state/country cluster
124: containing three or more data points.
125:
126: B<One-shot filehandle>: the C<DATA> handle is consumed on the first call to
127: C<new()>. Any subsequent call to C<new()> returns an object whose dataset and
128: index are both empty. In code that constructs multiple C<Local> objects (e.g.
129: in tests) construct B<exactly one> instance and reuse it across all callers.
130:
131: =head3 API SPECIFICATION
132:
133: =head4 input
134:
135: # Input schema (Params::Validate::Strict)
136: cache => { type => 'object', optional => 1, can => ['get', 'set'] } # CHI-compatible cache object
137:
138: =head4 output
139:
140: # Output schema (Return::Set)
141: { type => 'object', isa => 'Geo::Coder::Free::Local' }
142:
143: =head3 FORMAL SPECIFICATION
144:
145: LocalState ::= ⟨⟨ data : seq Row;
146: index : Map[STRING → Row];
147: cache : Map[STRING → Point] ⟩⟩
148:
149: Init : Params → LocalState
150: ∀ p : Params •
151: let rows == parse_csv(__DATA__) ∪ geographic_centres(__DATA__) •
152: let idx_key == λ r • lc(Point(r).as_string()) •
153: LocalState.index = { idx_key(r) ↦ r | r ∈ rows }
154:
155: =cut
156:
157: sub new {
158: my $class = shift;
159: my $params = Params::Get::get_params(undef, @_) // {};
160:
161: if (!defined($class)) {
162: $class = __PACKAGE__; # FIXME: only works when no arguments are given
163: } elsif (ref($class)) {
164: return bless { %{$class}, %{$params} }, ref($class);
165: }
166:
167: $params = Object::Configure::configure($class, $params);
168:
169: 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: string => \join('', grep { !/^\s*(#|$)/ } @data),
182: ),
183: %{$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: my $towns = _find_geographic_centres($self->{'data'});
189: 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: for my $row (@{$self->{'data'}}) {
194: my $key = lc(Geo::Location::Point->new($row)->as_string());
195: $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: for my $row (@{$self->{'data'}}) {
204: my $rk = uc($row->{'state'} // '') . '|' . uc($row->{'country'} // '');
205: $self->{'region_index'}{$rk} = 1;
206: }
207:
208: return $self;
209: }
210:
211: =head2 geocode
212:
213: =head3 SYNOPSIS
214:
215: my $pt = $geocoder->geocode(location => '203 E Chatsworth Rd, Reisterstown, Baltimore, MD, US');
216: print $pt->lat(), "\n";
217:
218: # All calling forms are accepted:
219: $geocoder->geocode('203 E Chatsworth Rd, Reisterstown, MD, US');
220: $geocoder->geocode({ location => '203 E Chatsworth Rd, Reisterstown, MD, US' });
221:
222: =head3 API SPECIFICATION
223:
224: =head4 input
225:
226: # Input schema (Params::Validate::Strict)
227: location => { type => 'scalar' } # address string; must contain at least two commas
228:
229: =head4 output
230:
231: # Output schema (Return::Set)
232: { type => 'object', isa => 'Geo::Location::Point', optional => 1 }
233:
234: =head3 MESSAGES
235:
236: Usage: ...::geocode(...) No location argument given.
237:
238: =head3 FORMAL SPECIFICATION
239:
240: Geocode : STRING → Point?
241: ∀ addr : STRING •
242: let norm == lc(replace_usa(addr)) •
243: (norm ∈ cache ⟹ result = cache[norm]) ∧
244: (norm ∈ index ⟹ result = index[norm]) ∧
245: (¬ result ⟹ result = parse_and_search(addr))
246:
247: =head3 PSEUDOCODE
248:
249: normalise @_ into %params
250: reject if location does not contain two or more commas (not a full address)
251: check cache; return hit
252: check hash index; return hit and cache it
253: # index key includes the 'name' field; supply the venue/place name as the
254: # leading address component to guarantee an index hit
255: attempt country-specific parser (US, GB; skip CA/AU pending implementation)
256: attempt Geo::StreetAddress::US for "..., USA" addresses
257: attempt Geo::Address::Parser
258: attempt Geo::libpostal (large memory footprint; loaded lazily)
259: attempt 4-part regex decomposition (name/road/city/state/country)
260: attempt %alternatives mapping
261: return undef
262:
263: =cut
264:
265: sub geocode {
266: my $self = shift;
267:
268: # Handle being called as a function rather than a method
269: if (!ref($self)) {
270: if (scalar @_) {
271: return __PACKAGE__->new()->geocode(@_);
272: } elsif (!defined($self)) {
273: Carp::croak('Usage: ', __PACKAGE__, '::geocode(location => $location)');
274: } elsif ($self eq __PACKAGE__) {
275: Carp::croak("Usage: $self", '::geocode(location => $location)');
276: }
277: return __PACKAGE__->new()->geocode($self);
278: } elsif (ref($self) eq 'HASH') {
279: return __PACKAGE__->new()->geocode($self);
280: }
281:
282: my %params = _normalize_args(
283: 'Usage: ' . __PACKAGE__ . '::geocode(location => $location)',
284: 'location', @_
285: );
286:
287: 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: return if $location !~ /,.+,/;
292:
293: # Normalise the lookup key — "USA" is treated the same as "US"
294: my $lc = lc($location);
295: $lc =~ s/,\s*usa$/, us/i;
296:
297: return $self->{'cache'}{$lc} if exists $self->{'cache'}{$lc};
298: return $self->_cache_and_return($lc, $self->{'index'}{$lc})
299: if exists $self->{'index'}{$lc};
300:
301: my $ap;
302: if ($location =~ /USA?$/ || $location =~ /United States$/) {
303: $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: $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: return;
313: } elsif ($location =~ /Australia$/) {
314: # TODO: Australian address parsing not yet implemented
315: return;
316: }
317:
318: if ($ap) {
319: my $l = $location;
320: $l =~ s/(.+), (England|UK)$/$1, GB/i;
321:
322: if ($ap->parse($l) == 0) {
323: my %c = $ap->components();
324: my %addr = (location => $l);
325:
326: if (my $street = $c{'street_name'}) {
327: if (my $type = $c{'street_type'}) {
328: my $abbrev = _abbreviate($type);
329: $street .= ' ' . ($abbrev || $type);
330: $street .= ' ' . $c{'street_direction_suffix'}
331: if $c{'street_direction_suffix'};
332: $street =~ s/^0+//;
333: $addr{'road'} = $street;
334: }
335: }
336:
337: if (length($c{'subcountry'}) == 2) {
338: $addr{'state'} = $c{'subcountry'};
339: } else {
340: my $country_raw = $c{'country'} // '';
341: if ($country_raw =~ /Canada/i) {
342: $addr{'country'} = 'CA';
343: $addr{'state'} = _to_two_letter_state('Canada', $c{'subcountry'});
344: } elsif ($country_raw =~ /^(United States|USA|US)$/i) {
345: $addr{'country'} = 'US';
346: $addr{'state'} = _to_two_letter_state('US', $c{'subcountry'});
347: } elsif ($country_raw) {
348: $addr{'country'} = $country_raw;
349: $addr{'state'} = $c{'subcountry'} if $c{'subcountry'};
350: }
351: }
352: $addr{'number'} = $c{'property_identifier'};
353: $addr{'city'} = $c{'suburb'};
354:
355: if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
356: return $self->_cache_and_return($lc, $rc);
357: }
358: if ($addr{'number'}) {
359: if (my $rc = $self->_search(\%addr, qw(road city state country))) {
360: 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: if (!defined($addr{'country'})) {
367: $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: my $rk = uc($addr{'state'} // '') . '|' . uc($addr{'country'} // '');
376: return unless $self->{'region_index'}{$rk};
377: }
378: }
379:
380: if ($location =~ /^(.+?)[,\s]+(United States|USA|US)$/i) {
381: my $l = $1;
382: $l =~ tr/,/ /;
383: $l =~ s/\s{2,}/ /g;
384:
385: # Geo::StreetAddress::US is buggy (RT#122617) — skip county-style addresses
386: if ($location !~ /\sCounty,/i) {
387: my $href = Geo::StreetAddress::US->parse_location($l)
388: // Geo::StreetAddress::US->parse_address($l);
389:
390: if ($href) {
391: if (my $state = $href->{'state'}) {
392: $state = _to_two_letter_state('US', $state) if length($state) > 2;
393: my $city = uc($href->{'city'} // '');
394: if (my $street = $href->{'street'}) {
395: if ($href->{'type'}) {
396: $street .= ' ' . _abbreviate($href->{'type'});
397: }
398: $street .= ' ' . $href->{'suffix'} if $href->{'suffix'};
399: $street = $href->{'prefix'} . " $street" if $href->{'prefix'};
400: my %addr = (
401: number => $href->{'number'},
402: road => $street,
403: city => $city,
404: state => $state,
405: country => 'US',
406: );
407: if ($href->{'number'}) {
408: if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
409: $rc->{'country'} = 'US';
410: return $self->_cache_and_return($lc, $rc);
411: }
412: }
413: if (my $rc = $self->_search(\%addr, qw(road city state country))) {
414: $rc->{'country'} = 'US';
415: return $self->_cache_and_return($lc, $rc);
416: }
417: # G:S:US puts building name into street when number is absent
418: if ($street && !$href->{'number'}) {
419: $addr{'name'} = delete $addr{'road'};
420: if (my $rc = $self->_search(\%addr, qw(name city state country))) {
421: $rc->{'country'} = 'US';
422: return $self->_cache_and_return($lc, $rc);
423: }
424: }
425: }
426: }
427: }
428: }
429:
430: # Fallback: try "name, street, town, state, US" split
431: my @parts = split(/,\s*/, $location);
432: if (scalar(@parts) == 5) {
433: my $state = _to_two_letter_state('US', $parts[3]);
434: if (length($state) == 2) {
435: my %addr = (city => $parts[2], state => $state, country => 'US');
436: if ($parts[0] !~ /^\d/) {
437: $addr{'name'} = $parts[0];
438: if ($parts[1] =~ /^(\d+)\s+(.+)/) {
439: $addr{'number'} = $1;
440: $addr{'road'} = _normalize($2);
441: if (my $rc = $self->_search(\%addr, qw(name number road city state country))) {
442: $rc->{'country'} = 'US';
443: return $self->_cache_and_return($lc, $rc);
444: }
445: } else {
446: $addr{'road'} = _normalize($parts[1]);
447: if (my $rc = $self->_search(\%addr, qw(name road city state country))) {
448: $rc->{'country'} = 'US';
449: return $self->_cache_and_return($lc, $rc);
450: }
451: }
452: } else {
453: $addr{'number'} = $parts[0];
454: $addr{'road'} = _normalize($parts[1]);
455: if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
456: $rc->{'country'} = 'US';
457: 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: if (($location =~ /[^,]+,[^,]+,.*England$/) && ($location !~ /[^,]+,[^,]+,[^,]+,.*England$/)) {
466: return;
467: }
468:
469: # Geo::Address::Parser — optional; load lazily, skip if not installed
470: unless (Geo::Address::Parser->can('parse')) {
471: eval { require Geo::Address::Parser; Geo::Address::Parser->import() };
472: }
473: if (Geo::Address::Parser->can('parse')) {
474: my $addr_parser = Geo::Address::Parser->new(country => 'UK');
475: if (my $fields = $addr_parser->parse($location)) {
476: # Remove undef fields so _search only matches defined columns
477: delete $fields->{$_} for grep { !defined $fields->{$_} } keys %{$fields};
478: if (my $rc = $self->_search($fields, keys %{$fields})) {
479: $rc->{'country'} = 'UK';
480: return $self->_cache_and_return($lc, $rc);
481: }
482: }
483: }
484:
485: # Geo::libpostal — accurate but uses enormous RAM; initialise at most once
486: if ($libpostal_is_installed == LIBPOSTAL_UNKNOWN) {
487: if (eval { require Geo::libpostal; 1 }) {
488: Geo::libpostal->import();
489: $libpostal_is_installed = LIBPOSTAL_INSTALLED;
490: } else {
491: $libpostal_is_installed = LIBPOSTAL_NOT_INSTALLED;
492: }
493: }
494:
495: if ($libpostal_is_installed == LIBPOSTAL_INSTALLED) {
496: my %addr = Geo::libpostal::parse_address($location);
497: if (%addr) {
498: # Normalise field names to match our data schema
499: $addr{'number'} = delete $addr{'house_number'} if $addr{'house_number'} && !$addr{'number'};
500: $addr{'name'} = delete $addr{'house'} if $addr{'house'} && !$addr{'name'};
501: $addr{'location'} = $location;
502:
503: if (my $street = $addr{'road'}) {
504: $addr{'road'} = _normalize($street);
505: }
506:
507: # libpostal returns "england" as a state — map to country code
508: if (defined($addr{'state'}) && !defined($addr{'country'})
509: && $addr{'state'} eq 'england') {
510: delete $addr{'state'};
511: $addr{'country'} = 'GB';
512: }
513:
514: if ($addr{'country'} && ($addr{'state'} || $addr{'state_district'})) {
515: if ($addr{'country'} =~ /Canada/i) {
516: $addr{'country'} = 'Canada';
517: $addr{'state'} = _to_two_letter_state('Canada', $addr{'state'})
518: if $addr{'state'} && length($addr{'state'}) > 2;
519: } elsif ($addr{'country'} =~ /^(United States|USA|US)$/i) {
520: $addr{'country'} = 'US';
521: $addr{'state'} = _to_two_letter_state('US', $addr{'state'})
522: if $addr{'state'} && length($addr{'state'}) > 2;
523: }
524:
525: if ($addr{'state_district'}) {
526: $addr{'state_district'} =~ s/^(.+)\s+COUNTY\s*$/$1/i;
527: if (my $rc = $self->_search(\%addr, qw(number road city state_district state country))) {
528: return $self->_cache_and_return($lc, $rc);
529: }
530: }
531: if (my $rc = $self->_search(\%addr, qw(number road city state country))) {
532: return $self->_cache_and_return($lc, $rc);
533: }
534: if ($addr{'number'}) {
535: if (my $rc = $self->_search(\%addr, qw(road city state country))) {
536: 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: if ($location =~ /^(.+?),\s*([\s\w]+),\s*([\s\w]+),\s*([\w\s]+)$/) {
545: my %addr = (
546: road => $1,
547: city => $2,
548: state => $3,
549: country => $4,
550: );
551: $addr{'state'} =~ s/\s+$//g;
552: $addr{'country'} =~ s/\s+$//g;
553:
554: if ($addr{'road'} =~ /([\w\s]+),*\s+(.+)/) {
555: $addr{'name'} = $1;
556: $addr{'road'} = $2;
557: }
558: if ($addr{'road'} =~ /^(\d+)\s+(.+)/) {
559: $addr{'number'} = $1;
560: $addr{'road'} = $2;
561: if (my $rc = $self->_search(\%addr, qw(name number road city state country))) {
562: return $self->_cache_and_return($lc, $rc);
563: }
564: } elsif (my $rc = $self->_search(\%addr, qw(name road city state country))) {
565: return $self->_cache_and_return($lc, $rc);
566: }
567: if ($addr{'name'} && !defined($addr{'number'})) {
568: if (my $rc = $self->_search(\%addr, qw(name road city state country))) {
569: return $self->_cache_and_return($lc, $rc);
570: }
571: }
572: }
573:
574: # Try the alternatives table — curated mappings for inconsistent names
575: $location = uc($location);
576: for my $left (keys %alternatives) {
577: next unless $location =~ $left;
578: (my $mapped = $location) =~ s/$left/$alternatives{$left}/;
579: $params{'location'} = $mapped;
580: if (my $rc = $self->geocode(\%params)) {
581: return $self->_cache_and_return($lc, $rc);
582: }
583: if ($mapped =~ /(.+), (England|UK)$/i) {
584: $params{'location'} = "$1, GB";
585: if (my $rc = $self->geocode(\%params)) {
586: return $self->_cache_and_return($lc, $rc);
587: }
588: }
589: $params{'location'} = $location;
590: }
591:
592: return;
593: }
594:
595: =head2 reverse_geocode
596:
597: =head3 SYNOPSIS
598:
599: my $loc = $geocoder->reverse_geocode(latlng => '51.3341,-1.4159');
600: # Returns the location string(s) for that lat/lon pair.
601:
602: =head3 API SPECIFICATION
603:
604: =head4 input
605:
606: # Input schema (Params::Validate::Strict) — latlng or lat+lon required
607: latlng => { type => 'scalar', optional => 1 } # "$lat,$long" comma-separated decimal degrees
608: lat => { type => 'scalar', optional => 1 } # latitude (alternative to latlng)
609: lon => { type => 'scalar', optional => 1 } # longitude (alternative to latlng)
610: long => { type => 'scalar', optional => 1 } # alias for lon
611:
612: =head4 output
613:
614: # Output schema (Return::Set)
615: # scalar context: { type => 'scalar', optional => 1 } # location string
616: # list context: { type => 'arrayref', of => { type => 'scalar' } }
617:
618: =cut
619:
620: sub reverse_geocode {
621: my $self = shift;
622:
623: if (!ref($self)) {
624: if (scalar @_) {
625: return __PACKAGE__->new()->reverse_geocode(@_);
626: } elsif (!defined($self)) {
627: Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")');
628: } elsif ($self eq __PACKAGE__) {
629: Carp::croak("Usage: $self", '::reverse_geocode(latlng => "$lat,$long")');
630: }
631: return __PACKAGE__->new()->reverse_geocode($self);
632: } elsif (ref($self) eq 'HASH') {
633: return __PACKAGE__->new()->reverse_geocode($self);
634: }
635:
636: my %params = _normalize_args(
637: 'Usage: ' . __PACKAGE__ . '::reverse_geocode(latlng => "$lat,$long")',
638: 'latlng', @_
639: );
640:
641: my ($latitude, $longitude);
642: if (my $latlng = $params{'latlng'}) {
643: ($latitude, $longitude) = split /,/, $latlng;
644: } else {
645: $latitude = $params{'lat'};
646: $longitude = $params{'lon'} // $params{'long'};
647: }
648:
649: if (!defined($latitude) || !defined($longitude)) {
650: Carp::croak('Usage: ', __PACKAGE__, '::reverse_geocode(latlng => "$lat,$long")');
651: }
652:
653: my @rc;
654: for my $row (@{$self->{'data'}}) {
655: next unless defined($row->{'latitude'}) && defined($row->{'longitude'});
656: next unless _equal($row->{'latitude'}, $latitude, 4)
657: && _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: my $location = uc($row->as_string());
662: if (wantarray) {
663: push @rc, $location;
664: # Add any reverse-alternative mappings for this location
665: while (my ($left, $right) = each %alternatives) {
666: if ($location =~ $right) {
667: (my $l = $location) =~ s/$right/$left/;
668: push @rc, $l;
669: }
670: }
671: } else {
672: return $location;
673: }
674: }
675: return @rc;
676: }
677:
678: =head2 ua
679:
680: Does nothing — present for drop-in compatibility with other Geo::Coder::* modules.
681:
682: =cut
683:
684: ◆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: my ($error_msg, $bare_key, @args) = @_;
700: return %{$args[0]} if ref($args[0]) eq 'HASH';
701: Carp::croak($error_msg) if ref($args[0]);
702: return @args if @args && @args % 2 == 0;
703: 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: Carp::croak($error_msg) if @args;
707: 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: my ($self, $key, $rc) = @_;
718: $self->{'cache'}{$key} = $rc;
719: 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: my ($country, $state) = @_;
732: return $state // '' if !defined($state) || length($state) <= 2;
733:
734: if ($country =~ /^(United States|USA|US)$/i) {
735: $_locale_us //= Locale::US->new();
736: return $_locale_us->{'state2code'}{uc($state)} // $state;
737: } elsif ($country =~ /Canada/i) {
738: $_locale_ca //= Locale::CA->new();
739: return $_locale_ca->{'province2code'}{uc($state)} // $state;
740: }
741: 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: 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: ROW: for my $row (@{$self->{'data'}}) {
758: my $matched = 0;
759:
760: for my $column (@columns) {
761: if (defined($data->{$column})) {
762: next ROW unless defined($row->{$column});
763: next ROW if uc($row->{$column}) ne uc($data->{$column});
764: $matched++;
765: } elsif (exists $data->{$column}) {
766: delete $data->{$column};
767: }
768: }
769:
770: next if $matched < 3;
771:
772: # Assign confidence based on how many columns contributed to the match
773: 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: %{$row},
782: );
783: }
784: 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: my $rows = $_[0];
795:
796: # Group rows by normalised city|state|country key
797: my %groups;
798: for my $row (@{$rows}) {
799: next unless defined($row->{'latitude'}) && defined($row->{'longitude'});
800: next unless $row->{'latitude'} =~ /^-?\d+\.?\d*$/;
801: next unless $row->{'longitude'} =~ /^-?\d+\.?\d*$/;
802: my $key = join '|',
803: $row->{'city'} // '',
804: $row->{'state'} // '',
805: $row->{'country'} // '';
806: push @{$groups{$key}}, $row;
807: }
808:
809: my @centres;
810: for my $key (keys %groups) {
811: my $locs = $groups{$key};
812: next if @{$locs} < 3;
813:
814: my ($city, $state, $country) = split /\|/, $key;
815: my ($lat, $lon) = _calculate_centre($locs);
816:
817: 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: 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: my $locs = $_[0];
840: my ($sum_lat, $sum_lon) = (0, 0);
841: $sum_lat += $_->{'latitude'}, $sum_lon += $_->{'longitude'} for @{$locs};
842: my $n = scalar @{$locs};
843: 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: my ($A, $B, $dp) = @_;
850: return sprintf("%.${dp}g", $A) eq sprintf("%.${dp}g", $B);
851: }
852:
853: =head1 AUTHOR
854:
855: Nigel Horne C<< <njh@nigelhorne.com> >>
856:
857: =head1 BUGS
858:
859: The data are stored in the module source and must be maintained by the author.
860: A future version should load them from an external file to allow community contributions.
861:
862: See also: L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Geo-Coder-Free>
863:
864: =head1 SEE ALSO
865:
866: L<Geo::Coder::Free>, L<Geo::Coder::Free::OpenAddresses>, L<Geo::Coder::Free::MaxMind>
867:
868: =head1 LICENSE AND COPYRIGHT
869:
870: Copyright 2020-2026 Nigel Horne.
871:
872: The program code is released under the following licence: GPL2 for personal use on a single computer.
873: All other users (including Commercial, Charity, Educational, and Government)
874: must apply in writing for a licence for use from Nigel Horne at C<< <njh at nigelhorne.com> >>.
875:
876: =cut
877:
878: 1;
879:
880: # Use abbreviations in the data: RD not ROAD, ST not STREET, etc.
881: __DATA__
882: "name","number","road","city","state_district","state","country","latitude","longitude"
883: "ST ANDREWS CHURCH",,"CHURCH HILL","EARLS COLNE",,"ESSEX","GB",51.926793,0.70408
884: "WESTWOOD CROSS",23,"MARGATE RD","BROADSTAIRS",,"KENT","GB",51.358967,1.391367
885: "RECULVER ABBEY",,"RECULVER","HERNE BAY",,"KENT","GB",51.37875,1.1955
886: "NEW INN",2,"TOTHILL ST","RAMSGATE",,"KENT","GB",51.334522,1.314417
887: "HOLIDAY INN EXPRESS",,"TOTHILL ST","RAMSGATE",,"KENT","GB",51.34320725,1.31680853
888: "",106,"TOTHILL ST","RAMSGATE",,"KENT","GB",51.33995174,1.31570211
889: "",114,"TOTHILL ST","RAMSGATE",,"KENT","GB",51.34015944,1.31580976
890: "MINSTER CEMETERY",116,"TOTHILL ST","RAMSGATE",,"KENT","GB",51.34203083,1.31609075
891: "RAMSGATE STATION",,"STATION APPROACH RD","RAMSGATE","","KENT","GB",51.340826,1.406519
892: "ST MARY THE VIRGIN CHURCH",,"CHURCH ST","RAMSGATE",,"KENT","GB",51.33090893,1.31559716
893: "",20,"MELBOURNE AVE","RAMSGATE",,"KENT","GB",51.34772374,1.39532565
894: "TOBY CARVERY",,"NEW HAINE RD","RAMSGATE",,"KENT","GB",51.357510,1.388894
895: "",,"WESTCLIFF PROMENADE","RAMSGATE",,"KENT","GB",51.32711,1.406806
896: "TOWER OF LONDON",35,"TOWER HILL","LONDON",,"LONDON","GB",51.5082675,-0.0754225
897: "",5350,"CHILLUM PL NE","WASHINGTON",,"DC","US",38.955403,-76.996241
898: "WALTER E. WASHINGTON CONVENTION CENTER",801,"MT VERNON PL NW","WASHINGTON","","DC","US",38.904022,-77.023113
899: "",7,"JORDAN MILL COURT","WHITE HALL","BALTIMORE","MD","US",39.6852333333333,-76.6071166666667
900: "ALL SAINTS EPISCOPAL CHURCH",203,"E CHATSWORTH RD","REISTERSTOWN","BALTIMORE","MD","US",39.467270,-76.823947
901: "BALLPARK RESTAURANT",3418,"CONOWINGO RD","DUBLIN","HARFORD","MD","US",39.633018,-76.272558
902: "NCBI",,"MEDLARS DR","BETHESDA","MONTGOMERY","MD","US",38.99516556,-77.09943963
903: "",,"CENTER DR","BETHESDA","MONTGOMERY","MD","US",38.99698114,-77.10031119
904: "",,"NORFOLK AVE","BETHESDA","MONTGOMERY","MD","US",38.98939358,-77.09819543
905: "ROCK BOTTOM RESTAURANT & BREWERY",,"NORFOLK AVE","BETHESDA","MONTGOMERY","MD","US",38.9890861111111,-77.0975722222222
906: "",3516,"SW MACVICAR AVE","TOPEKA","SHAWNEE","KS","US",39.005175,-95.706681
907: "THE ATRIUM AT ROCK SPRING PARK",6555,"ROCKLEDGE DR","BETHESDA","MONTGOMERY","MD","US",39.028326,-77.136774
908: "","","MOUTH OF MONOCACY RD","DICKERSON","MONTGOMERY","MD","US",39.2244603797302,-77.449615439877
909: "PATAPSCO VALLEY STATE PARK'",8020,"BALTIMORE NATIONAL PK","ELLICOTT CITY","HOWARD","MD","US",39.29491,-76.78051
910: "",,"ANNANDALE RD","EMMITSBURG","FREDERICK","MD","US",39.683529,-77.349405
911: "UTICA DISTRICT PARK",,,"FREDERICK","FREDERICK","MD","US",39.5167883333333,-77.4015166666667
912: "",3923,"SUGARLOAF CT","MONROVIA","FREDERICK","MD","US",39.342986,-77.239770
913: "ALBERT EINSTEIN HIGH SCHOOL",11135,"NEWPORT MILL RD","KENSINGTON","MONTGOMERY","MD","US",39.03869019,-77.0682871
914: "",10540,"METROPOLITAN AVE","KENSINGTON","MONTGOMERY","MD","US",39.028404,-77.073227
915: "POST OFFICE",10325,"KENSINGTON PKWY","KENSINGTON","MONTGOMERY","MD","US",39.02554455,-77.07178215
916: "NEWPORT MILL MIDDLE SCHOOL",11311,"NEWPORT MILL RD","KENSINGTON","MONTGOMERY","MD","US",39.0416107,-77.06884708
917: "SAFEWAY",10541,"HOWARD AVE","KENSINGTON","MONTGOMERY","MD","US",39.02822438,-77.0755196
918: "HAIR CUTTERY",3731,"CONNECTICUT AVE","KENSINGTON","MONTGOMERY","MD","US",39.03323865,-77.07368044
919: "STROSNIDERS",10504,"CONNECTICUT AVE","KENSINGTON","MONTGOMERY","MD","US",39.02781493,-77.07740792
920: "",8616,"SAVANNAH RIVER RD","LAUREL","ANNE ARUNDEL","MD","US",39.100869,-76.812162
921: "DOWNS PARK",,"CHESAPEAKE BAY DRIVE","PASADENA","ANNE ARUNDEL","MD","US",39.110711,-76.434062
922: "",1559,"GUERDON CT","PASADENA","ANNE ARUNDEL","MD","US",39.102637,-76.456384
923: "ARCOLA HEALTH AND REHABILITATION CENTER",901,"ARCOLA AVE","SILVER SPRING","MONTGOMERY","MD","US",39.036439,-77.025502
924: "",9904,"GARDINER AVE","SILVER SPRING","MONTGOMERY","MD","US",39.017633,-77.049551
925: "CVS",9520,"GEORGIA AVE","SILVER SPRING","MONTGOMERY","MD","US",39.010801,-77.041771
926: "FOREST GLEN MEDICAL CENTER",9801,"GEORGIA AVE","SILVER SPRING","MONTGOMERY","MD","US",39.016042,-77.042148
927: "",10009,"GREELEY AVE","SILVER SPRING","MONTGOMERY","MD","US",39.019575,-77.047453
928: "ADVENTIST HOSPITAL",11886,"HEALING WAY","SILVER SPRING","MONTGOMERY","MD","US",39.049570,-76.956882
929: "",2232,"HILDAROSE DR","SILVER SPRING","MONTGOMERY","MD","US",39.019385,-77.049779,
930: "LA CASITA PUPESERIA AND MARKET",8214,"PINEY BRANCH RD","SILVER SPRING","MONTGOMERY","MD","US",38.993369,-77.009501
931: "NOAA LIBRARY",1315,"EAST-WEST HIGHWAY","SILVER SPRING","MONTGOMERY","MD","US",38.991667,-77.030473
932: "SNIDERS",1936,"SEMINARY RD","SILVER SPRING","MONTGOMERY","MD","US",39.0088797,-77.04162824
933: "",1954,"SEMINARY RD","SILVER SPRING","MONTGOMERY","MD","US",39.008961,-77.04303
934: "",1956,"SEMINARY RD","SILVER SPRING","MONTGOMERY","MD","US",39.008845,-77.043317
935: "",9315,"WARREN ST","SILVER SPRING","MONTGOMERY","MD","US",39.00881,-77.048953
936: "",9411,"WARREN ST","SILVER SPRING","MONTGOMERY","MD","US",39.010447,-77.048548
937: "SILVER DINER",12276,"ROCKVILLE PK","ROCKVILLE","MONTGOMERY","MD","US",39.05798753,-77.12165374
938: "",1605,"VIERS MILL RD","ROCKVILLE","MONTGOMERY","MD","US",39.07669788,-77.12306436
939: "",1406,"LANGBROOK PL","ROCKVILLE","MONTGOMERY","MD","US",39.075583,-77.123833
940: "",2225,"FOREST GLEN RD","SILVER SPRING","MONTGOMERY","MD","US",39.015394,-77.048357
941: "BP",2601,"FOREST GLEN RD","SILVER SPRING","MONTGOMERY","MD","US",39.0147541,-77.05466857
942: "OMEGA STUDIOS",12412,,"ROCKVILLE","MONTGOMERY","MD","US",39.06412645,-77.11252263
943: "",10424,"43RD AVE","BELTSVILLE","PRINCE GEORGE","MD","US",39.033075,-76.923859
944: "NASA",,"TIROS RD","GREENBELT","PRINCE GEORGE","MD","US",38.996764,-76.849323
945: "",7001,"CRADLEROCK FARM COURT","COLUMBIA","HOWARD","MD","US",39.190009,-76.841152
946: "BANGOR AIRPORT",,"GODFREY BOULEVARD","BANGOR","PENOBSCOT","ME","US",44.406700,-68.597114
947: "",86,"ALLEN POINT LANE","BLUE HILLS","HANCOCK","ME","US",44.35378018,-68.57383976
948: "TRADEWINDS",15,"SOUTH STREET","BLUE HILLS","HANCOCK","ME","US",44.40670019,-68.59711438
949: "RITE AID",17,"SOUTH STREET","BLUE HILLS","HANCOCK","ME","US",44.40662476,-68.59610059
950: "",880,"SOUTH GREENSFERRY RD","COUER D'ALENE","KOOTENAI","ID","US",47.693615,-116.915357
951: "",898,"SOUTH GREENSFERRY RD","COUER D'ALENE","KOOTENAI","ID","US",47.69556,-116.91564
952: "",,"DOUGLAS AVE","FORT WAYNE","ALLEN","IN","US",41.074247,-85.138531
953: "JOHN GLENN AIRPORT",4600,,"COLUMBUS","FRANKLIN","OH","US",39.997959,-82.88132
954: "MIDDLE RIDGE PLAZA",,,"AMHERST","LOHRAIN","OH","US",41.379695,-82.222877
955: "RESIDENCE INN BY MARRIOTT",6364,"FRANTZ RD","DUBLIN",,"OH","US",40.097097,-83.123745
956: "TOWPATH TRAVEL PLAZA",,,"BROADVIEW HEIGHTS","CUYAHOGA","OH","US",41.291654,-81.675815
957: "NEW STANTON SERVICE PLAZA",,,"HEMPFIELD",,"PA","US",40.206267,-79.565682
958: "",,"","LITITZ","LANCASTER","PA","US",40.154989, -76.304266
959: "SOUTH SOMERSET SERVICE PLAZA",,,"SOMERSET","SOMERSET","PA","US",39.999154,-79.046526
960: "HUNTLEY MEADOWS PARK",3701,"LOCKHEED BLVD","ALEXANDRIA","","VA","US",38.75422, -77.1058666666667
961: "SHENANDOAH COOL SPRINGS BATTLEFIELD",,"","BLUEMONT","CLARKE","VA","US",39.142146,-77.866468
962: "",14900,"CONFERENCE CENTER DR","CHANTILLY","FAIRFAX","VA","US",38.873934,-77.461939
963: "THE PURE PASTY COMPANY",128C,"MAPLE AVE W","VIENNA","FAIRFAX","VA","US",44.40662476,-68.59610059
964: "DIRT FARM BREWERY",18701,"FOGGY BOTTOM RD","BLUEMONT","LOUDON","VA","US",39.099655,-77.836975
965: "",404,"BRINDLEY PL SW","LEESBURG","LOUDOUN","VA","US",39.092207,-77.591987
966: "",818,"FERNDALE TERRACE NE","LEESBURG","LOUDOUN","VA","US",39.124843,-77.535445
967: "",,"OATLANDS PLANTATION LN","OATLANDS","LOUDOUN","VA","US",39.04071,-77.61682
968: "",,"PURCELLVILLE GATEWAY DR","PURCELLVILLE","LOUDOUN","VA","US",39.136193,-77.693198
969: "THE CAPITAL GRILLE RESTAURANT",1861,,"MCLEAN","FAIRFAX","VA","US",38.915635,-77.22573
970: "",,"","COLONIAL BEACH","WESTMORELAND","VA","US",38.25075,-76.9602533333333