use 5.34.0; use Feature::Compat::Class; #================================================================================ class NearestTown; # uses data from GeoNames.org - generated by merge_geonames.pl #================================================================================ use Data::Printer; use IO::All -utf8; # for file handling use POSIX qw(round); use List::Util 'uniq'; use Math::Trig qw(great_circle_distance deg2rad); # --------------------------- # CONFIG # --------------------------- my $cities_file = 'cities_full.txt'; # output from merge_geonames.pl my $oceans_file = 'hydro_features.txt'; # from GeoNames.org my $min_population = 5000; # ignore tiny settlements my $search_radius = 1.0; # degrees for bounding box # --------------------------- # Load cities into memory # --------------------------- field $cities = do { my @csrc = io($cities_file)->chomp->slurp; my @cities; for (@csrc) { # 51.24389 -1.26154 Overton Hampshire England United Kingdom GB 3392 my ($lat,$lon,$name,$admin1,$admin2,$cname,$cc,$pop) = split /\t/; next unless $pop =~ /^\d+$/ && $pop >= $min_population; # will skip header row push @cities, { name => $name, # city/town name lat => $lat + 0, # ensure numeric lon => $lon + 0, # ensure numeric cc => $cc, # abbreviated country code [GB, ES, US etc] country => $cname, admin1 => $admin1, # state/province admin2 => $admin2, # not useful pop => $pop + 0, # ensure numeric }; } # p @cities \@cities; }; # ----------------------------- # Load oceans/seas into memory # ----------------------------- field $oceans = do { my @src = io($oceans_file)->chomp->slurp; my @hydro; for (@src) { next unless $_ =~ /^\d+/; # skip header row my @f = split /\t/; my ($name, $feat_code, $lat, $lon) = @f[1,3,4,5]; push @hydro, { name => $name, lat => $lat, lon => $lon, code => $feat_code }; } \@hydro; }; # --------------------------- # Distance function (Haversine) # --------------------------- method haversine_km { # convert degrees to radians my ($lat1, $lon1, $lat2, $lon2) = map { deg2rad($_) } @_; # compute great-circle distance, multiplying by 6371 converts radians → kilometres # since 6371 km ≈ Earth’s mean radius. return 6371 * great_circle_distance( $lon1, $lat1, $lon2, $lat2 ); } # --------------------------- # Nearest town lookup # --------------------------- method nearest_town ($lat, $lon) { # p $lat; p $lon; # creates a latitude/longitude rectangle centered on your tile ($lat, $lon), # extending $search_radius degrees in each direction. Example: if $lat = 60, # $search_radius = 0.5, then the box covers 59.5–60.5°N. my ($min_lat, $max_lat) = ($lat - $search_radius, $lat + $search_radius); my ($min_lon, $max_lon) = ($lon - $search_radius, $lon + $search_radius); # $best will hold the closest city record found. # $min_dist starts at the maximum possible distance within the tile my $tile_diagonal_km = 0.5 * sqrt( (111)**2 + (111 * cos(deg2rad($lat)))**2 ); my ($best, $min_dist) = (undef, $tile_diagonal_km); for my $c (@$cities) { # This is a fast bounding-box prefilter — saves time by ignoring cities too # far away (no need to compute distance). next if $c->{lat} < $min_lat || $c->{lat} > $max_lat; next if $c->{lon} < $min_lon || $c->{lon} > $max_lon; # p $c->{lon}; # compute great-circle distance using centre of tile - lat + 0.5, lon + 0.5 my $d = $self->haversine_km($lat + 0.5, $lon + 0.5, $c->{lat}, $c->{lon}); # printf "Tile diagonal km: %.1f\n", $tile_diagonal_km; # print what you think you used # printf "Tile centre: %dN %dE\n", $lat + 0.5, $lon + 0.5; # print city coordinates and the computed distance: # printf "Candidate: %s (%s) at %.2f, %.2f -> dist=%.1f km\n", # $c->{name}, $c->{country}, $c->{lat}, $c->{lon}, $d; # If this city is closer than any previous one, store it as the current best match. if ( $d < $min_dist ) { $best = $c; $min_dist = $d; } } # say "No nearby city found for $lat / $lon" unless $best; if ($best) { my $formatted_info = $self->format_city_info($best, $min_dist); return $formatted_info; } else { my $hydro_name = $self->is_ocean_tile($lat, $lon); return "$hydro_name [ocean tile]" if $hydro_name; } return 0; # can't find anything } method format_city_info ($city, $dist) { my $location = join ', ', grep { $_ } uniq( @{$city}{ qw/name admin1 admin2 country/ } ); # not using cc return sprintf '%s [%s km from tile center]', $location, round($dist); } method is_ocean_tile ($lat, $lon, $threshold_km = 50) { # default 50 km for my $o (@$oceans) { my $d = $self->haversine_km($lat, $lon, $o->{lat}, $o->{lon}); if ( $d < $threshold_km ) { my $hydro_name = $o->{name}; # append sea / ocn / bay unless already contains it $hydro_name .= ' ' . ucfirst lc $o->{code} unless $hydro_name =~ /$o->{code}\Z/i; return $hydro_name; } } return 0; # can't find anything } 1;