| File: | blib/lib/Geo/Coder/Free/Utils.pm |
| Coverage: | 66.9% |
| line | stmt | bran | cond | sub | pod | time | code |
|---|---|---|---|---|---|---|---|
| 1 | package Geo::Coder::Free::Utils; | ||||||
| 2 | |||||||
| 3 | # VWF is licensed under GPL2.0 for personal use only | ||||||
| 4 | # njh@nigelhorne.com | ||||||
| 5 | |||||||
| 6 | =encoding utf-8 | ||||||
| 7 | |||||||
| 8 - 21 | =head1 NAME Geo::Coder::Free::Utils - Random subroutines for Geo::Coder::Free =head1 DESCRIPTION Utility module for cache management, geospatial calculations, and shared address-normalization helpers used by multiple backends. =head1 VERSION Version 0.03 =cut | ||||||
| 22 | |||||||
| 23 | our $VERSION = '0.03'; | ||||||
| 24 | |||||||
| 25 | 18 18 | 134895 25 | use v5.20; | ||||
| 26 | 18 18 18 | 26 14 147 | use strict; | ||||
| 27 | 18 18 18 | 24 13 369 | use warnings; | ||||
| 28 | 18 18 18 | 33 25 1373 | use feature qw(signatures); | ||||
| 29 | 18 18 18 | 40 16 238 | no warnings qw(experimental::signatures); | ||||
| 30 | |||||||
| 31 | 18 18 18 | 27 18 451 | use Exporter qw(import); | ||||
| 32 | our @EXPORT = qw(create_disc_cache create_memory_cache distance _abbreviate _normalize); | ||||||
| 33 | our @EXPORT_OK = qw(_abbreviate _normalize); | ||||||
| 34 | |||||||
| 35 | 18 18 18 | 4857 559643 284 | use CHI; | ||||
| 36 | 18 18 18 | 63 16 470 | use Data::Dumper; | ||||
| 37 | 18 18 18 | 5821 807104 317 | use Geo::Coder::Abbreviations; | ||||
| 38 | 18 18 18 | 15706 135452 664 | use DBI; | ||||
| 39 | 18 18 18 | 4701 1123 73 | use Error::Simple; | ||||
| 40 | 18 18 18 | 300 11 99 | use Module::Runtime qw(require_module); # Safe dynamic loading; avoids string eval | ||||
| 41 | 18 18 18 | 886 17040 252 | use Params::Get 0.13; | ||||
| 42 | 18 18 18 | 35 12 387 | use Try::Tiny; | ||||
| 43 | 18 18 18 | 26 15 325 | use Carp qw(croak carp); | ||||
| 44 | 18 18 18 | 45 14 293 | use Scalar::Util qw(looks_like_number); | ||||
| 45 | 18 18 18 | 4179 95734 899 | use Math::Trig qw(deg2rad rad2deg asin great_circle_distance); | ||||
| 46 | |||||||
| 47 | # Constants for distance calculations | ||||||
| 48 | use constant { | ||||||
| 49 | 18 | 26376 | EARTH_RADIUS_MILES => 3959, | ||||
| 50 | EARTH_RADIUS_KM => 6371, | ||||||
| 51 | EARTH_RADIUS_NM => 3440, | ||||||
| 52 | KM_PER_MILE => 1.609344, | ||||||
| 53 | NM_PER_MILE => 0.8684 | ||||||
| 54 | 18 18 | 60 22 | }; | ||||
| 55 | |||||||
| 56 - 109 | =head1 SUBROUTINES/METHODS
=head2 FORMAL SPECIFICATION
[STRING, HASH, LOGGER, CHI_CACHE, COORDINATE]
CacheConfig ::= â¨â¨ driver : STRING;
servers : seq STRING;
root_dir : STRING;
connect : STRING â©â©
CacheArgs ::= â¨â¨ config : HASH;
logger : LOGGER;
namespace : STRING;
root_dir : STRING â©â©
Point ::= â¨â¨ latitude : COORDINATE;
longitude : COORDINATE â©â©
where
latitude â {x : â | -90 ⤠x ⤠90} â§
longitude â {x : â | -180 ⤠x ⤠180}
Unit ::= K | N | M
CreateCache : CacheArgs â CHI_CACHE
â args : CacheArgs â¢
let driver == args.config.driver ⨠default_driver â¢
validate_driver_config(driver, args.config) â§
â cache : CHI_CACHE ⢠cache = CHI.new(build_chi_args(driver, args))
Distance : Point à Point à Unit â ââ
â p1, p2 : Point; u : Unit â¢
let d == great_circle_distance(p1, p2) â¢
d ⥠0 â§
(u = K â¹ result = d à 1.609344) â§
(u = N â¹ result = d à 0.8684) â§
(u = M â¹ result = d)
=head2 create_disc_cache
Initialize a disc-based cache using the CHI module.
Supports multiple cache drivers, including BerkeleyDB, DBI, and Redis.
Parameters:
- config: Configuration hash reference (required)
- logger: Logger object (optional)
- namespace: Cache namespace (optional)
- root_dir: Root directory override (optional)
Returns: CHI cache object
=cut | ||||||
| 110 | |||||||
| 111 | sub create_disc_cache { | ||||||
| 112 | 13 | 1 | 115809 | my $args = Params::Get::get_params(undef, @_); | |||
| 113 | 13 | 244 | return _create_cache('disc_cache', $args); | ||||
| 114 | } | ||||||
| 115 | |||||||
| 116 - 129 | =head2 create_memory_cache Initialize a memory-based cache using the CHI module. Supports multiple cache drivers, including SharedMem, Memory, and Redis. Parameters: - config: Configuration hash reference (required) - logger: Logger object (optional) - namespace: Cache namespace (optional) - root_dir: Root directory override (optional) Returns: CHI cache object =cut | ||||||
| 130 | |||||||
| 131 | sub create_memory_cache { | ||||||
| 132 | 9 | 1 | 10138 | my $args = Params::Get::get_params(undef, @_); | |||
| 133 | 9 | 140 | return _create_cache('memory_cache', $args); | ||||
| 134 | } | ||||||
| 135 | |||||||
| 136 | # Private helper functions | ||||||
| 137 | |||||||
| 138 | 22 22 22 22 | 34 25 20 14 | sub _create_cache($cache_type, $args) { | ||||
| 139 | 22 | 27 | my $config = $args->{'config'}; | ||||
| 140 | 22 | 62 | throw Error::Simple('config is not optional') unless($config); | ||||
| 141 | |||||||
| 142 | 18 | 39 | _validate_cache_config($config, $cache_type); | ||||
| 143 | |||||||
| 144 | 12 | 13 | my $logger = $args->{'logger'}; | ||||
| 145 | 12 | 33 | my $cache_config = $config->{$cache_type} || {}; | ||||
| 146 | 12 | 14 | my $driver = $cache_config->{driver}; | ||||
| 147 | |||||||
| 148 | # Set default driver with fallback strategy | ||||||
| 149 | 12 | 37 | unless (defined $driver) { | ||||
| 150 | 2 | 7 | $driver = _get_default_driver($cache_type, $logger); | ||||
| 151 | 2 | 5 | if ($logger) { | ||||
| 152 | 0 | 0 | $logger->info("No driver specified for $cache_type, using $driver"); | ||||
| 153 | } | ||||||
| 154 | } | ||||||
| 155 | |||||||
| 156 | # Validate driver is available | ||||||
| 157 | 12 | 26 | unless (_is_driver_available($driver)) { | ||||
| 158 | 2 | 6 | my $fallback = _get_fallback_driver($cache_type); | ||||
| 159 | 2 | 3 | if ($logger) { | ||||
| 160 | 0 | 0 | $logger->warn("Driver $driver not available, falling back to $fallback"); | ||||
| 161 | } | ||||||
| 162 | 2 | 3 | $driver = $fallback; | ||||
| 163 | } | ||||||
| 164 | |||||||
| 165 | # Build CHI arguments | ||||||
| 166 | 12 | 49 | my %chi_args = _build_chi_args($driver, $cache_config, $args, $logger, $cache_type); | ||||
| 167 | |||||||
| 168 | # Create cache with error handling | ||||||
| 169 | 8 | 9 | my $cache; | ||||
| 170 | try { | ||||||
| 171 | 8 | 416 | $cache = CHI->new(%chi_args); | ||||
| 172 | } catch { | ||||||
| 173 | 0 | 0 | my $error = "Failed to create $cache_type cache with driver $driver: $_"; | ||||
| 174 | 0 | 0 | $logger->error($error) if $logger; | ||||
| 175 | 0 | 0 | throw Error::Simple($error); | ||||
| 176 | 8 | 53 | }; | ||||
| 177 | |||||||
| 178 | 8 | 99903 | return $cache; | ||||
| 179 | } | ||||||
| 180 | |||||||
| 181 | 18 18 18 18 | 18 15 15 17 | sub _validate_cache_config($config, $cache_type) { | ||||
| 182 | 18 | 29 | return unless exists $config->{$cache_type}; | ||||
| 183 | |||||||
| 184 | 16 | 18 | my $cache_config = $config->{$cache_type}; | ||||
| 185 | 16 | 36 | croak('Cache configuration must be a hash reference') | ||||
| 186 | unless ref($cache_config) eq 'HASH'; | ||||||
| 187 | |||||||
| 188 | # Validate driver if specified | ||||||
| 189 | 14 | 33 | if (exists $cache_config->{driver}) { | ||||
| 190 | 14 | 14 | my $driver = $cache_config->{driver}; | ||||
| 191 | 14 | 37 | my @valid_drivers = qw(Memory BerkeleyDB DBI Redis Memcached SharedMem File Null); | ||||
| 192 | croak "Invalid driver '$driver'. Valid drivers: " . join(', ', @valid_drivers) | ||||||
| 193 | 14 112 | 14 92 | unless grep { $_ eq $driver } @valid_drivers; | ||||
| 194 | } | ||||||
| 195 | |||||||
| 196 | # Validate numeric parameters | ||||||
| 197 | 13 | 20 | for my $param (qw(port shm_size max_size)) { | ||||
| 198 | 37 | 45 | next unless exists $cache_config->{$param}; | ||||
| 199 | 2 | 3 | my $value = $cache_config->{$param}; | ||||
| 200 | 2 | 14 | croak "$param must be a positive integer" | ||||
| 201 | unless defined $value && $value =~ /^\d+$/ && $value > 0; | ||||||
| 202 | } | ||||||
| 203 | |||||||
| 204 | # Validate port range | ||||||
| 205 | 12 | 24 | if (exists $cache_config->{port}) { | ||||
| 206 | 1 | 2 | my $port = $cache_config->{port}; | ||||
| 207 | 1 | 8 | croak('Port must be between 1 and 65535') | ||||
| 208 | unless $port >= 1 && $port <= 65535; | ||||||
| 209 | } | ||||||
| 210 | |||||||
| 211 | # Validate memory parameters | ||||||
| 212 | 11 | 30 | if(exists($cache_config->{'datastore'}) && exists($cache_config->{'global'}) && (my $driver = $cache_config->{'driver'})) { | ||||
| 213 | # CHI catches this as well, but putting it here helps to track the error | ||||||
| 214 | 1 | 4 | croak('Memory cache cannot have both global and datastore'); | ||||
| 215 | } | ||||||
| 216 | } | ||||||
| 217 | |||||||
| 218 | 2 2 2 2 | 53 12 1 1 | sub _get_default_driver($cache_type, $logger) { | ||||
| 219 | # Allow override for testing | ||||||
| 220 | 2 | 4 | my $env_var = 'TEST_' . uc($cache_type) . '_DRIVER'; | ||||
| 221 | 2 | 5 | return $ENV{$env_var} if $ENV{$env_var}; | ||||
| 222 | |||||||
| 223 | # Production defaults | ||||||
| 224 | 2 | 4 | return $cache_type eq 'disc_cache' ? 'BerkeleyDB' : 'Memory'; | ||||
| 225 | } | ||||||
| 226 | |||||||
| 227 | 2 2 2 | 3 2 1 | sub _get_fallback_driver($cache_type) { | ||||
| 228 | 2 | 5 | return $cache_type eq 'disc_cache' ? 'File' : 'Memory'; | ||||
| 229 | } | ||||||
| 230 | |||||||
| 231 | 12 12 12 | 10 11 9 | sub _is_driver_available($driver) { | ||||
| 232 | 12 | 91 | return 1 if $driver =~ /^(Memory|Null)$/; | ||||
| 233 | |||||||
| 234 | 7 | 33 | my %driver_modules = ( | ||||
| 235 | 'DBI' => 'CHI::Driver::DBI', | ||||||
| 236 | 'BerkeleyDB' => 'CHI::Driver::BerkeleyDB', | ||||||
| 237 | 'File' => 'CHI::Driver::File', | ||||||
| 238 | 'Memcached' => 'CHI::Driver::Memcached', | ||||||
| 239 | 'SharedMem' => 'CHI::Driver::SharedMem', | ||||||
| 240 | 'Redis' => 'CHI::Driver::Redis' | ||||||
| 241 | ); | ||||||
| 242 | |||||||
| 243 | 7 | 15 | return 1 unless exists $driver_modules{$driver}; | ||||
| 244 | |||||||
| 245 | 7 | 46 | if($driver_modules{$driver}->can('new')) { | ||||
| 246 | 1 | 3 | return 1; | ||||
| 247 | } | ||||||
| 248 | # SECURITY â string-eval elimination: | ||||||
| 249 | # eval "require $module_name" is a string eval; if $driver ever escaped | ||||||
| 250 | # the hash-key whitelist (e.g. through a future regression in the caller), | ||||||
| 251 | # it could execute arbitrary code. A block eval with require_module() is | ||||||
| 252 | # semantically identical but has no string-eval surface. | ||||||
| 253 | 6 6 | 8 24 | eval { require_module($driver_modules{$driver}) }; | ||||
| 254 | 6 | 54519 | if($@) { | ||||
| 255 | 2 | 4 | return 0; | ||||
| 256 | } | ||||||
| 257 | 4 | 28 | $driver_modules{$driver}->import(); | ||||
| 258 | 4 | 18 | return 1; | ||||
| 259 | } | ||||||
| 260 | |||||||
| 261 | 12 12 12 12 12 12 12 | 13 13 10 10 7 13 8 | sub _build_chi_args($driver, $cache_config, $args, $logger, $cache_type) { | ||||
| 262 | my %chi_args = ( | ||||||
| 263 | driver => $driver, | ||||||
| 264 | 12 | 43 | namespace => $args->{'namespace'} || 'default' | ||||
| 265 | ); | ||||||
| 266 | |||||||
| 267 | # Configure error handling | ||||||
| 268 | 12 | 27 | if ($logger && $logger->can('error')) { | ||||
| 269 | 0 0 | 0 0 | $chi_args{on_get_error} = sub { $logger->warn("Cache get error: $_[0]") }; | ||||
| 270 | 0 0 | 0 0 | $chi_args{on_set_error} = sub { $logger->error("Cache set error: $_[0]") }; | ||||
| 271 | } else { | ||||||
| 272 | 12 | 22 | $chi_args{on_get_error} = 'warn'; | ||||
| 273 | 12 | 18 | $chi_args{on_set_error} = 'die'; | ||||
| 274 | } | ||||||
| 275 | |||||||
| 276 | # Driver-specific configuration | ||||||
| 277 | 12 | 70 | if ($driver eq 'Redis') { | ||||
| 278 | 0 | 0 | _configure_redis(\%chi_args, $cache_config, $args, $logger); | ||||
| 279 | } elsif ($driver eq 'DBI') { | ||||||
| 280 | 0 | 0 | _configure_dbi(\%chi_args, $cache_config, $args, $logger); | ||||
| 281 | } elsif ($driver eq 'SharedMem') { | ||||||
| 282 | 0 | 0 | _configure_shared_memory(\%chi_args, $cache_config, $args); | ||||
| 283 | } elsif ($driver eq 'Memory') { | ||||||
| 284 | 1 | 3 | _configure_memory(\%chi_args, $cache_config); | ||||
| 285 | } elsif ($driver eq 'Memcached') { | ||||||
| 286 | 0 | 0 | _configure_memcached(\%chi_args, $cache_config, $args, $logger); | ||||
| 287 | } elsif ($driver !~ /^(Null)$/) { | ||||||
| 288 | 7 | 15 | _configure_file_based(\%chi_args, $cache_config, $args); | ||||
| 289 | } | ||||||
| 290 | |||||||
| 291 | 8 | 21 | return %chi_args; | ||||
| 292 | } | ||||||
| 293 | |||||||
| 294 | 0 0 0 0 0 0 | 0 0 0 0 0 0 | sub _configure_redis($chi_args, $config, $args, $logger) { | ||||
| 295 | # Parse server configuration | ||||||
| 296 | 0 | 0 | my @servers = _parse_server_config($config, $logger); | ||||
| 297 | 0 | 0 | if (@servers) { | ||||
| 298 | 0 | 0 | $chi_args->{servers} = \@servers; | ||||
| 299 | 0 | 0 | $chi_args->{server} = $servers[0]; # Primary server | ||||
| 300 | } | ||||||
| 301 | |||||||
| 302 | # Redis-specific options with sensible defaults | ||||||
| 303 | $chi_args->{redis_options} = { | ||||||
| 304 | reconnect => $config->{reconnect} || 60, | ||||||
| 305 | every => $config->{every} || 1_000_000, | ||||||
| 306 | encoding => $config->{encoding} || 'utf8', | ||||||
| 307 | 0 0 | 0 0 | %{$config->{redis_options} || {}} | ||||
| 308 | }; | ||||||
| 309 | } | ||||||
| 310 | |||||||
| 311 | 0 0 0 0 0 0 | 0 0 0 0 0 0 | sub _configure_dbi($chi_args, $config, $args, $logger) { | ||||
| 312 | 0 | 0 | my $connect_string = $config->{connect}; | ||||
| 313 | 0 | 0 | croak "DBI driver requires 'connect' parameter" unless $connect_string; | ||||
| 314 | |||||||
| 315 | 0 | 0 | my $dbh; | ||||
| 316 | try { | ||||||
| 317 | 0 | 0 | $dbh = DBI->connect($connect_string, '', '', { | ||||
| 318 | RaiseError => 1, | ||||||
| 319 | PrintError => 0, | ||||||
| 320 | AutoCommit => 1 | ||||||
| 321 | }); | ||||||
| 322 | } catch { | ||||||
| 323 | 0 | 0 | my $error = "Failed to connect to database: $_"; | ||||
| 324 | 0 | 0 | $logger->error($error) if $logger; | ||||
| 325 | 0 | 0 | croak $error; | ||||
| 326 | 0 | 0 | }; | ||||
| 327 | |||||||
| 328 | 0 | 0 | $chi_args->{dbh} = $dbh; | ||||
| 329 | 0 | 0 | $chi_args->{create_table} = $config->{create_table} // 1; | ||||
| 330 | } | ||||||
| 331 | |||||||
| 332 | 0 0 0 0 0 | 0 0 0 0 0 | sub _configure_shared_memory($chi_args, $config, $args) { | ||||
| 333 | $chi_args->{shm_key} = $args->{'shm_key'} || $config->{shm_key} | ||||||
| 334 | 0 | 0 | || croak "SharedMem driver requires 'shm_key' parameter"; | ||||
| 335 | |||||||
| 336 | 0 | 0 | $chi_args->{shm_size} = $args->{'shm_size'} || $config->{shm_size} || 16 * 1024; | ||||
| 337 | 0 | 0 | $chi_args->{max_size} = $args->{'max_size'} || $config->{max_size} || 1024; | ||||
| 338 | } | ||||||
| 339 | |||||||
| 340 | 1 | 1 | sub _configure_memory($chi_args, $config) | ||||
| 341 | 1 1 1 | 1 1 1 | { | ||||
| 342 | 1 | 2 | if(exists $config->{'global'}) { | ||||
| 343 | 0 | 0 | $chi_args->{'global'} = $config->{'global'}; | ||||
| 344 | } else { | ||||||
| 345 | 1 | 2 | $chi_args->{'datastore'} = {}; | ||||
| 346 | } | ||||||
| 347 | } | ||||||
| 348 | |||||||
| 349 | 0 0 0 0 0 0 | 0 0 0 0 0 0 | sub _configure_memcached($chi_args, $config, $args, $logger) { | ||||
| 350 | 0 | 0 | my @servers = _parse_server_config($config, $logger); | ||||
| 351 | 0 | 0 | if (@servers) { | ||||
| 352 | 0 | 0 | $chi_args->{servers} = \@servers; | ||||
| 353 | } else { | ||||||
| 354 | # Default to localhost | ||||||
| 355 | 0 | 0 | $chi_args->{servers} = ['127.0.0.1:11211']; | ||||
| 356 | 0 | 0 | $logger->debug('Using default Memcached server: 127.0.0.1:11211') if $logger; | ||||
| 357 | } | ||||||
| 358 | } | ||||||
| 359 | |||||||
| 360 | 7 7 7 7 7 | 10 5 6 6 7 | sub _configure_file_based($chi_args, $config, $args) { | ||||
| 361 | 7 | 40 | my $root_dir = $ENV{'root_dir'} || $args->{'root_dir'} || $config->{root_dir} || $args->{'config'}->{root_dir}; | ||||
| 362 | |||||||
| 363 | 7 | 77 | croak "File-based cache drivers require 'root_dir' parameter" unless $root_dir; | ||||
| 364 | 4 | 77 | croak "Root directory '$root_dir' does not exist or is not writable" | ||||
| 365 | unless -d $root_dir && -w $root_dir; | ||||||
| 366 | |||||||
| 367 | 3 | 5 | $chi_args->{root_dir} = $root_dir; | ||||
| 368 | } | ||||||
| 369 | |||||||
| 370 | 0 0 0 0 | 0 0 0 0 | sub _parse_server_config($config, $logger) { | ||||
| 371 | # Handle host/server naming inconsistency | ||||||
| 372 | 0 | 0 | my $server_config = $config->{server} || $config->{host}; | ||||
| 373 | 0 | 0 | return () unless $server_config; | ||||
| 374 | |||||||
| 375 | 0 | 0 | my @servers; | ||||
| 376 | 0 | 0 | for my $server_entry (split /,/, $server_config) { | ||||
| 377 | 0 | 0 | $server_entry =~ s/^\s+|\s+$//g; # trim whitespace | ||||
| 378 | |||||||
| 379 | # If no port specified, add default port | ||||||
| 380 | 0 | 0 | unless ($server_entry =~ /:/) { | ||||
| 381 | 0 | 0 | my $port = $config->{port} || croak "Port not specified for server '$server_entry'"; | ||||
| 382 | 0 | 0 | $server_entry .= ":$port"; | ||||
| 383 | } | ||||||
| 384 | |||||||
| 385 | # Validate server format | ||||||
| 386 | 0 | 0 | if ($server_entry =~ /^([a-zA-Z0-9.-]+):(\d+)$/) { | ||||
| 387 | 0 | 0 | my ($host, $port) = ($1, $2); | ||||
| 388 | 0 | 0 | croak "Invalid port number: $port" unless $port > 0 && $port <= 65535; | ||||
| 389 | 0 | 0 | push @servers, $server_entry; | ||||
| 390 | 0 | 0 | $logger->debug("Added server: $server_entry") if $logger; | ||||
| 391 | } else { | ||||||
| 392 | 0 | 0 | croak "Invalid server format: '$server_entry' (expected host:port)"; | ||||
| 393 | } | ||||||
| 394 | } | ||||||
| 395 | |||||||
| 396 | 0 | 0 | return @servers; | ||||
| 397 | } | ||||||
| 398 | |||||||
| 399 - 413 | =head2 distance Calculate the great circle distance between two points on Earth using the Haversine formula. More accurate than the original implementation, especially for short distances. Parameters: - lat1, lon1: Latitude and longitude of first point (decimal degrees) - lat2, lon2: Latitude and longitude of second point (decimal degrees) - unit: 'K' for kilometers, 'N' for nautical miles, 'M' or undef for statute miles Returns: Distance in specified units Throws: Error on invalid input parameters =cut | ||||||
| 414 | |||||||
| 415 | 61 61 61 61 61 61 61 | 1 | 46213 48 31 39 35 52 28 | sub distance($lat1, $lon1, $lat2, $lon2, $unit = 'M') { | |||
| 416 | # Input validation | ||||||
| 417 | 61 | 98 | for my $coord_ref ([\$lat1, 'lat1'], [\$lon1, 'lon1'], [\$lat2, 'lat2'], [\$lon2, 'lon2']) { | ||||
| 418 | 215 | 119 | my ($coord, $name) = @$coord_ref; | ||||
| 419 | 215 | 184 | croak "$name must be defined" unless defined $$coord; | ||||
| 420 | 208 | 215 | croak "$name must be numeric" unless looks_like_number($$coord); | ||||
| 421 | } | ||||||
| 422 | |||||||
| 423 | # Range validation | ||||||
| 424 | 47 | 130 | croak('Latitude must be between -90 and 90 degrees') | ||||
| 425 | if abs($lat1) > 90 || abs($lat2) > 90; | ||||||
| 426 | 38 | 81 | croak('Longitude must be between -180 and 180 degrees') | ||||
| 427 | if abs($lon1) > 180 || abs($lon2) > 180; | ||||||
| 428 | |||||||
| 429 | # Handle identical points | ||||||
| 430 | 30 | 45 | return 0 if $lat1 == $lat2 && $lon1 == $lon2; | ||||
| 431 | |||||||
| 432 | # Validate unit | ||||||
| 433 | 25 | 40 | $unit = uc($unit || 'M'); | ||||
| 434 | 25 | 50 | croak "Unknown unit '$unit'. Use 'K', 'N', or 'M'" | ||||
| 435 | unless $unit =~ /^[KNM]$/; | ||||||
| 436 | |||||||
| 437 | # Use optimized calculation with appropriate radius | ||||||
| 438 | 22 | 41 | my $radius = $unit eq 'K' ? EARTH_RADIUS_KM : | ||||
| 439 | $unit eq 'N' ? EARTH_RADIUS_NM : | ||||||
| 440 | EARTH_RADIUS_MILES; | ||||||
| 441 | |||||||
| 442 | # Haversine formula | ||||||
| 443 | 22 | 40 | my $dlat = deg2rad($lat2 - $lat1); | ||||
| 444 | 22 | 135 | my $dlon = deg2rad($lon2 - $lon1); | ||||
| 445 | 22 | 97 | my $a = sin($dlat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dlon/2)**2; | ||||
| 446 | 22 | 162 | my $c = 2 * asin(sqrt($a)); | ||||
| 447 | |||||||
| 448 | 22 | 93 | return $radius * $c; | ||||
| 449 | } | ||||||
| 450 | |||||||
| 451 | # Singleton shared across all callers; lazy-initialised on first use. | ||||||
| 452 | my $abbreviations; | ||||||
| 453 | |||||||
| 454 - 464 | =head2 _normalize Normalise a street name to its abbreviated canonical form. Uppercases the input, then abbreviates the second-to-last or last word (whichever is a recognised street type) using C<Geo::Coder::Abbreviations>. Leading zeros are also stripped (C<"04th St"> â C<"4th St">). Exported so that C<Local.pm> and C<OpenAddresses.pm> can call it without importing C<Geo::Coder::Free>. =cut | ||||||
| 465 | |||||||
| 466 | sub _normalize { | ||||||
| 467 | 19 | 17782 | my $street = uc(shift); | ||||
| 468 | 19 | 78 | $abbreviations ||= Geo::Coder::Abbreviations->new(); | ||||
| 469 | |||||||
| 470 | 19 | 219859 | my @words = split /\s+/, $street; | ||||
| 471 | 19 | 48 | if (@words >= 3) { | ||||
| 472 | 8 | 5 | my $a; | ||||
| 473 | 8 | 41 | if (lc($words[-2]) ne 'cross' && ($a = $abbreviations->abbreviate($words[-2]))) { | ||||
| 474 | 2 | 10 | $words[-2] = $a; | ||||
| 475 | } elsif ($a = $abbreviations->abbreviate($words[-1])) { | ||||||
| 476 | 4 | 29 | $words[-1] = $a; | ||||
| 477 | } | ||||||
| 478 | 8 | 42 | $street = join ' ', @words; | ||||
| 479 | } elsif (@words == 2) { | ||||||
| 480 | 9 | 27 | if (my $a = $abbreviations->abbreviate($words[-1])) { | ||||
| 481 | 8 | 76 | $street = "$words[0] $a"; | ||||
| 482 | } | ||||||
| 483 | } | ||||||
| 484 | 19 | 33 | $street =~ s/^0+//; | ||||
| 485 | 19 | 47 | return $street; | ||||
| 486 | } | ||||||
| 487 | |||||||
| 488 - 496 | =head2 _abbreviate Abbreviate a single street-type word (e.g. C<"Street"> â C<"ST">). Returns the original word uppercased if no abbreviation is found. Exported so that C<Local.pm> and C<OpenAddresses.pm> can call it without importing C<Geo::Coder::Free>. =cut | ||||||
| 497 | |||||||
| 498 | sub _abbreviate { | ||||||
| 499 | 15 | 5922 | my $type = uc(shift); | ||||
| 500 | 15 | 53 | $abbreviations ||= Geo::Coder::Abbreviations->new(); | ||||
| 501 | 15 | 9050 | return $abbreviations->abbreviate($type) || $type; | ||||
| 502 | } | ||||||
| 503 | |||||||
| 504 | 1; | ||||||
| 505 | |||||||