| File: | blib/lib/HTML/OSM.pm |
| Coverage: | 96.7% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | #!/usr/bin/env perl | |||||
| 2 | ||||||
| 3 | package HTML::OSM; | |||||
| 4 | ||||||
| 5 | 12 12 12 | 952564 8 128 | use strict; | |||
| 6 | 12 12 12 | 15 9 232 | use warnings; | |||
| 7 | 12 12 12 | 922 29290 36 | use autodie qw(:all); | |||
| 8 | ||||||
| 9 | 12 12 12 | 49087 15 266 | use Carp qw(carp croak); | |||
| 10 | 12 12 12 | 2535 287712 168 | use CHI; | |||
| 11 | 12 12 12 | 27 9 318 | use JSON::MaybeXS qw(decode_json encode_json); | |||
| 12 | 12 12 12 | 4066 236661 215 | use LWP::UserAgent; | |||
| 13 | 12 12 12 | 3497 617486 223 | use Object::Configure 0.15; | |||
| 14 | 12 12 12 | 30 51 203 | use Params::Get 0.13; | |||
| 15 | 12 12 12 | 19 65 156 | use Params::Validate::Strict 0.28; | |||
| 16 | 12 12 12 | 16 13 179 | use Readonly; | |||
| 17 | 12 12 12 | 15 7 734 | use Scalar::Util qw(blessed); | |||
| 18 | 12 12 12 | 19 14 30 | use Time::HiRes qw(time); | |||
| 19 | 12 12 12 | 495 10 21135 | use URI::Escape qw(uri_escape_utf8); | |||
| 20 | ||||||
| 21 - 29 | =head1 NAME HTML::OSM - Generate an interactive OpenStreetMap with Leaflet.js =head1 VERSION Version 0.10 =cut | |||||
| 30 | ||||||
| 31 | our $VERSION = '0.10'; | |||||
| 32 | ||||||
| 33 | # CDN URLs pinned to tested versions. Override via constructor params to use | |||||
| 34 | # a self-hosted copy or a different version. | |||||
| 35 | Readonly::Scalar my $LEAFLET_CSS_URL => 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; | |||||
| 36 | Readonly::Scalar my $LEAFLET_JS_URL => 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; | |||||
| 37 | Readonly::Scalar my $CLUSTER_JS_URL => 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js'; | |||||
| 38 | Readonly::Scalar my $CLUSTER_CSS_URL => 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css'; | |||||
| 39 | Readonly::Scalar my $CLUSTER_DEFAULT_CSS_URL => 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css'; | |||||
| 40 | Readonly::Scalar my $HEATMAP_JS_URL => 'https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js'; | |||||
| 41 | Readonly::Scalar my $GPX_JS_URL => 'https://cdnjs.cloudflare.com/ajax/libs/leaflet-gpx/1.7.0/gpx.min.js'; | |||||
| 42 | Readonly::Scalar my $NOMINATIM_HOST => 'nominatim.openstreetmap.org/search'; | |||||
| 43 | ||||||
| 44 | # WGS-84 valid ranges | |||||
| 45 | Readonly::Scalar my $LAT_MIN => -90; | |||||
| 46 | Readonly::Scalar my $LAT_MAX => 90; | |||||
| 47 | Readonly::Scalar my $LON_MIN => -180; | |||||
| 48 | Readonly::Scalar my $LON_MAX => 180; | |||||
| 49 | Readonly::Scalar my $ZOOM_MIN => 0; | |||||
| 50 | Readonly::Scalar my $ZOOM_MAX => 19; | |||||
| 51 | ||||||
| 52 - 140 | =head1 SYNOPSIS
use HTML::OSM;
my $map = HTML::OSM->new(
coordinates => [
[37.7749, -122.4194, 'San Francisco'],
[undef, undef, 'Paris'],
],
zoom => 10,
);
my ($head, $body) = $map->onload_render();
=over 4
=item * Caching
Geocode results are cached via L<CHI> (default: in-memory, 1-day TTL).
Supply your own C<cache> object to persist across processes.
=item * Rate-Limiting
Set C<min_interval> (seconds) to throttle outbound Nominatim calls
and comply with the API fair-use policy.
=back
=head1 SUBROUTINES/METHODS
=head2 new
Construct a new C<HTML::OSM> object.
my $map = HTML::OSM->new(%params);
my $map = HTML::OSM->new(\%params);
Both method-style (C<< HTML::OSM->new(...) >>) and function-style
(C<HTML::OSM::new(...)>) calls are supported.
Calling C<< $existing_obj->new(%overrides) >> performs a shallow clone,
merging C<%overrides> onto the existing object's state without re-validating.
=head3 API SPECIFICATION
=head4 INPUT
{
cache => { type => object, can => [get, set], optional },
cluster => { type => boolean, optional },
cluster_css_url => { type => string, optional },
cluster_default_css_url => { type => string, optional },
cluster_js_url => { type => string, optional },
config_file => { type => string, optional },
coordinates => { type => arrayref, optional },
css_url => { type => string, optional },
geocoder => { type => object, can => geocode, optional },
gpx_js_url => { type => string, optional },
heatmap_js_url => { type => string, optional },
height => { type => string, optional },
host => { type => string, optional },
js_url => { type => string, optional },
logger => { type => object, optional },
min_interval => { type => number, min => 0, optional },
ua => { type => object, optional },
width => { type => string, optional },
zoom => { type => integer, min => 0, max => 19, optional },
}
=head4 OUTPUT
{ type => object, isa => 'HTML::OSM' }
=head3 MESSAGES
| Message | Meaning / Resolution |
|------------------------------------------------|-----------------------------------------------|
| (validation error from Params::Validate::Strict) | A param has the wrong type or is out of range |
=head3 PSEUDOCODE
1. If $class is neither a package name nor a blessed ref, treat as
function-style call: prepend $class back onto @_ and use __PACKAGE__.
2. If $class is a blessed ref (clone call): merge override params onto a
shallow copy and return immediately, bypassing schema validation.
3. Validate all supplied args against the declared schema.
4. Merge config-file settings via Object::Configure.
5. Resolve the cache: caller-supplied object, or in-memory CHI instance.
6. Bless and return with Readonly CDN constants as defaults.
=cut | |||||
| 141 | ||||||
| 142 | sub new | |||||
| 143 | { | |||||
| 144 | 4186 | 1334161 | my $class = shift; | |||
| 145 | ||||||
| 146 | # Function-style call: HTML::OSM::new(key => val). | |||||
| 147 | # Perl places the first argument where the class name should be, | |||||
| 148 | # so we put it back and set the class explicitly. | |||||
| 149 | 4186 | 11960 | if(defined($class) && !blessed($class) && !UNIVERSAL::isa($class, __PACKAGE__)) { | |||
| 150 | 3 | 5 | unshift @_, $class; | |||
| 151 | 3 | 4 | $class = __PACKAGE__; | |||
| 152 | } | |||||
| 153 | ||||||
| 154 | # Clone path: $obj->new(%overrides) â shallow-merge onto the existing | |||||
| 155 | # object without re-running schema validation so subclasses can extend. | |||||
| 156 | 4186 | 4044 | if(blessed($class)) { | |||
| 157 | 10 | 17 | my $extra = Params::Get::get_params(undef, \@_) || {}; | |||
| 158 | 10 10 10 | 129 18 42 | return bless { %{$class}, %{$extra} }, ref($class); | |||
| 159 | } | |||||
| 160 | ||||||
| 161 | 4176 | 3359 | $class //= __PACKAGE__; | |||
| 162 | ||||||
| 163 | 4176 | 5442 | my $params = Params::Validate::Strict::validate_strict({ | |||
| 164 | args => Params::Get::get_params(undef, \@_) || {}, | |||||
| 165 | schema => { | |||||
| 166 | cache => { type => 'object', can => [qw(get set)], optional => 1 }, | |||||
| 167 | cluster => { type => 'boolean', optional => 1 }, | |||||
| 168 | cluster_css_url => { type => 'string', optional => 1 }, | |||||
| 169 | cluster_default_css_url => { type => 'string', optional => 1 }, | |||||
| 170 | cluster_js_url => { type => 'string', optional => 1 }, | |||||
| 171 | config_file => { type => 'string', optional => 1 }, | |||||
| 172 | coordinates => { type => 'arrayref', optional => 1 }, | |||||
| 173 | css_url => { type => 'string', optional => 1 }, | |||||
| 174 | geocoder => { type => 'object', can => 'geocode', optional => 1 }, | |||||
| 175 | gpx_js_url => { type => 'string', optional => 1 }, | |||||
| 176 | heatmap_js_url => { type => 'string', optional => 1 }, | |||||
| 177 | height => { type => 'string', optional => 1 }, | |||||
| 178 | host => { type => 'string', optional => 1 }, | |||||
| 179 | js_url => { type => 'string', optional => 1 }, | |||||
| 180 | logger => { type => 'object', optional => 1 }, | |||||
| 181 | min_interval => { type => 'number', min => 0, optional => 1 }, | |||||
| 182 | ua => { type => 'object', optional => 1 }, | |||||
| 183 | width => { type => 'string', optional => 1 }, | |||||
| 184 | zoom => { type => 'integer', min => $ZOOM_MIN, max => $ZOOM_MAX, optional => 1 }, | |||||
| 185 | }, | |||||
| 186 | }); | |||||
| 187 | ||||||
| 188 | # Config file values override programmatic defaults (separation of config and code). | |||||
| 189 | 2326 | 764172 | $params = Object::Configure::configure($class, $params); | |||
| 190 | ||||||
| 191 | # Inject the resolved cache so the bless hash spreads it correctly. | |||||
| 192 | 2326 | 3911740 | $params->{cache} //= CHI->new( | |||
| 193 | driver => 'Memory', | |||||
| 194 | global => 1, | |||||
| 195 | expires_in => '1 day', | |||||
| 196 | ); | |||||
| 197 | ||||||
| 198 | return bless { | |||||
| 199 | # Defaults â spread of %{$params} below overrides each one when supplied. | |||||
| 200 | coordinates => [], | |||||
| 201 | height => '400px', | |||||
| 202 | host => $NOMINATIM_HOST, | |||||
| 203 | width => '600px', | |||||
| 204 | zoom => 12, | |||||
| 205 | min_interval => 0, | |||||
| 206 | last_request => 0, | |||||
| 207 | cluster => 0, | |||||
| 208 | css_url => $LEAFLET_CSS_URL, | |||||
| 209 | js_url => $LEAFLET_JS_URL, | |||||
| 210 | cluster_js_url => $CLUSTER_JS_URL, | |||||
| 211 | cluster_css_url => $CLUSTER_CSS_URL, | |||||
| 212 | cluster_default_css_url => $CLUSTER_DEFAULT_CSS_URL, | |||||
| 213 | heatmap_js_url => $HEATMAP_JS_URL, | |||||
| 214 | gpx_js_url => $GPX_JS_URL, | |||||
| 215 | 2326 2326 | 1594180 13707 | %{$params}, | |||
| 216 | }, $class; | |||||
| 217 | } | |||||
| 218 | ||||||
| 219 - 272 | =head2 add_marker
Add a point marker to the map.
$map->add_marker([51.5074, -0.1278], html => 'London');
$map->add_marker('Paris, France', html => 'Paris');
$map->add_marker($geo_coder_result);
Returns 1 on success, 0 if the point cannot be resolved or is out of range.
=head3 API SPECIFICATION
=head4 INPUT
point : arrayref [lat, lon] | string address | object with latitude()/longitude()
html : string (optional popup label)
icon : string (optional icon URL)
=head4 OUTPUT
{ type => integer, enum => [0, 1] }
=head3 MESSAGES
| Message | Meaning / Resolution |
|--------------------------------------|---------------------------------------------|
| add_marker(): unknown point type | Point is a ref type with no lat/lon methods |
=head3 EXAMPLES
# Coordinate array with a popup label
$map->add_marker([51.5074, -0.1278], html => 'London');
# String address geocoded via the injected geocoder or Nominatim
$map->add_marker('Paris, France', html => 'Paris');
# Custom icon URL with a popup
$map->add_marker(
[40.7128, -74.0060],
html => 'New York',
icon => 'https://example.com/pin.png',
);
# Geo::Coder result object that implements latitude()/longitude()
my $result = $geocoder->geocode('Berlin, Germany');
$map->add_marker($result, html => 'Berlin');
# Accumulate several markers, warn on geocode failure
for my $city (@cities) {
$map->add_marker($city->{coords}, html => $city->{name})
or warn "Could not place $city->{name}";
}
=cut | |||||
| 273 | ||||||
| 274 | sub add_marker | |||||
| 275 | { | |||||
| 276 | 182 | 1961 | my $self = shift; | |||
| 277 | 182 | 138 | my ($params, $point); | |||
| 278 | ||||||
| 279 | 182 | 350 | if(ref($_[0]) eq 'ARRAY') { | |||
| 280 | 150 | 109 | $point = shift; | |||
| 281 | 150 | 200 | $params = Params::Get::get_params(undef, \@_) || {}; | |||
| 282 | # Single-element arrayref is a wrapped address string | |||||
| 283 | 150 150 | 1553 171 | $point = $point->[0] if scalar(@{$point}) == 1; | |||
| 284 | } elsif(blessed($_[0]) && $_[0]->can('latitude')) { | |||||
| 285 | # Geo object as first positional arg: extract before Params::Get sees it, | |||||
| 286 | # otherwise Params::Get mistakes the blessed hashref for the params hash. | |||||
| 287 | 5 | 5 | $point = shift; | |||
| 288 | 5 | 19 | $params = Params::Get::get_params(undef, \@_) || {}; | |||
| 289 | } elsif(defined($_[0]) && !ref($_[0]) && scalar(@_) % 2 != 0) { | |||||
| 290 | # Plain string as first positional arg, optionally followed by key-value pairs. | |||||
| 291 | # An odd total count signals a leading positional; even count means all key-value. | |||||
| 292 | 17 | 12 | $point = shift; | |||
| 293 | 17 | 24 | $params = Params::Get::get_params(undef, \@_) || {}; | |||
| 294 | } else { | |||||
| 295 | 10 | 20 | $params = Params::Get::get_params('point', @_); | |||
| 296 | 9 | 139 | $point = $params->{'point'}; | |||
| 297 | } | |||||
| 298 | ||||||
| 299 | 181 | 346 | my ($lat, $lon); | |||
| 300 | ||||||
| 301 | 181 | 229 | if(ref($point) eq 'ARRAY') { | |||
| 302 | 146 146 | 79 167 | return 0 if scalar(@{$point}) != 2; | |||
| 303 | 140 140 | 87 126 | ($lat, $lon) = @{$point}; | |||
| 304 | } elsif(!ref($point)) { | |||||
| 305 | 26 | 44 | ($lat, $lon) = $self->_fetch_coordinates($point); | |||
| 306 | } elsif($point->can('latitude')) { | |||||
| 307 | 5 | 8 | ($lat, $lon) = ($point->latitude(), $point->longitude()); | |||
| 308 | } else { | |||||
| 309 | 4 | 13 | my $msg = 'add_marker(): unknown point type: ' . ref($point); | |||
| 310 | 4 | 18 | $self->{logger}->error($msg) if $self->{logger}; | |||
| 311 | 4 | 1107 | croak $msg; | |||
| 312 | } | |||||
| 313 | ||||||
| 314 | 169 | 372 | return 0 unless defined($lat) && defined($lon); | |||
| 315 | 159 | 167 | return 0 unless _validate($lat, $lon); | |||
| 316 | ||||||
| 317 | 149 149 | 99 238 | push @{$self->{coordinates}}, [$lat, $lon, $params->{'html'}, $params->{'icon'}]; | |||
| 318 | 149 | 235 | return 1; | |||
| 319 | } | |||||
| 320 | ||||||
| 321 - 364 | =head2 add_geojson
Add a GeoJSON layer to the map.
$map->add_geojson(\%data, style => { color => '#ff0000' }, popup => 'name');
The first argument may be a hashref/arrayref (GeoJSON structure) or a JSON string.
Returns 1 on success.
=head3 API SPECIFICATION
=head4 INPUT
data : hashref | arrayref | string (JSON)
style : hashref Leaflet path-style options (color, weight, fillColor, fillOpacity)
popup : string Feature property name whose value becomes the popup text
=head4 OUTPUT
{ type => integer, value => 1 }
=head3 MESSAGES
| Message | Meaning / Resolution |
|----------------------|---------------------------------|
| (JSON parse error) | data string is not valid JSON |
=head3 EXAMPLES
# Pre-parsed GeoJSON structure with style and popup property
$map->add_geojson(
{ type => 'FeatureCollection', features => \@features },
style => { color => '#ff0000', weight => 2, fillOpacity => 0.4 },
popup => 'name',
);
# Raw JSON string â decoded automatically
$map->add_geojson('{"type":"FeatureCollection","features":[]}');
# Multiple GeoJSON layers with different styles on the same map
$map->add_geojson(\%country_borders, style => { color => '#333333', fillOpacity => 0 });
$map->add_geojson(\%river_data, style => { color => '#0099ff', weight => 1 });
=cut | |||||
| 365 | ||||||
| 366 | sub add_geojson | |||||
| 367 | { | |||||
| 368 | 46 | 228 | my $self = shift; | |||
| 369 | 46 | 37 | my $data = shift; | |||
| 370 | 46 | 57 | my $params = Params::Get::get_params(undef, \@_) || {}; | |||
| 371 | ||||||
| 372 | # Accept either a pre-parsed structure or a raw JSON string | |||||
| 373 | 46 | 548 | $data = decode_json($data) if !ref($data); | |||
| 374 | ||||||
| 375 | 41 41 | 31 66 | push @{$self->{geojson}}, { data => $data, opts => $params }; | |||
| 376 | 41 | 53 | return 1; | |||
| 377 | } | |||||
| 378 | ||||||
| 379 - 423 | =head2 add_heatmap
Add a heatmap layer to the map.
$map->add_heatmap([[51.5, -0.1, 0.8], [51.6, -0.2, 0.5]], radius => 25);
Each point is C<[$lat, $lon]> or C<[$lat, $lon, $intensity]> (intensity: 0-1).
Requires the Leaflet.heat plugin (C<heatmap_js_url>).
Returns 1 on success.
=head3 API SPECIFICATION
=head4 INPUT
points : arrayref of ([lat, lon] | [lat, lon, intensity])
radius : integer default 25
blur : integer default 15
=head4 OUTPUT
{ type => integer, value => 1 }
=head3 MESSAGES
| Message | Meaning / Resolution |
|--------------------------------------|-----------------------------------|
| add_heatmap: points must be arrayref | First argument is not an arrayref |
=head3 EXAMPLES
# Basic heatmap â [lat, lon] per point
$map->add_heatmap([
[51.5074, -0.1278],
[51.6000, -0.2000],
[51.4000, 0.0000],
]);
# With intensity values (0..1) and custom radius/blur
$map->add_heatmap(
[ [51.5, -0.1, 0.9], [51.6, -0.2, 0.5], [51.4, 0.0, 0.2] ],
radius => 30,
blur => 20,
);
=cut | |||||
| 424 | ||||||
| 425 | sub add_heatmap | |||||
| 426 | { | |||||
| 427 | 33 | 939 | my $self = shift; | |||
| 428 | 33 | 25 | my $points = shift; | |||
| 429 | 33 | 39 | my $params = Params::Get::get_params(undef, \@_) || {}; | |||
| 430 | ||||||
| 431 | 33 | 449 | croak 'add_heatmap: points must be an arrayref' unless ref($points) eq 'ARRAY'; | |||
| 432 | ||||||
| 433 | 21 21 | 16 35 | push @{$self->{heatmap_layers}}, { points => $points, opts => $params }; | |||
| 434 | 21 | 48 | return 1; | |||
| 435 | } | |||||
| 436 | ||||||
| 437 - 472 | =head2 add_gpx
Add a GPX track to the map from a URL.
$map->add_gpx('https://example.com/track.gpx');
The map view is auto-fitted to the track bounds after loading.
Requires the leaflet-gpx plugin (C<gpx_js_url>).
Returns 1 on success.
=head3 API SPECIFICATION
=head4 INPUT
url : string URL of the GPX file (required)
=head4 OUTPUT
{ type => integer, value => 1 }
=head3 MESSAGES
| Message | Meaning / Resolution |
|----------------------|----------------------------|
| add_gpx: url required | No URL argument supplied |
=head3 EXAMPLES
# Add a GPX track from a public URL; the map auto-fits to its bounds
$map->add_gpx('https://example.com/route.gpx');
# Multiple tracks on the same map
$map->add_gpx('https://example.com/morning-run.gpx');
$map->add_gpx('https://example.com/evening-walk.gpx');
=cut | |||||
| 473 | ||||||
| 474 | sub add_gpx | |||||
| 475 | { | |||||
| 476 | 26 | 194 | my $self = shift; | |||
| 477 | 26 | 35 | my $params = Params::Get::get_params('url', \@_); | |||
| 478 | 22 | 255 | my $url = $params->{'url'}; | |||
| 479 | ||||||
| 480 | 22 | 75 | croak 'add_gpx: url is required' unless $url; | |||
| 481 | ||||||
| 482 | 18 18 | 13 22 | push @{$self->{gpx_tracks}}, $url; | |||
| 483 | 18 | 29 | return 1; | |||
| 484 | } | |||||
| 485 | ||||||
| 486 - 542 | =head2 add_choropleth
Add a choropleth (data-driven colour fill) layer to the map.
$map->add_choropleth(
\@geojson_features,
{ England => 100, Scotland => 80, Wales => 60 },
key => 'name',
scale => ['#ffffcc', '#a1dab4', '#41b6c4', '#2c7fb8', '#253494'],
);
Colours are pre-computed in Perl and baked into the emitted JavaScript.
No extra browser plugin is required.
Returns 1 on success.
=head3 API SPECIFICATION
=head4 INPUT
features : arrayref of GeoJSON Feature hashrefs (required)
values : hashref { feature_property_value => numeric_value } (required)
key : string feature property to match against values (default: 'name')
scale : arrayref hex-colour strings low-to-high (default: 5-step YlGnBu)
=head4 OUTPUT
{ type => integer, value => 1 }
=head3 MESSAGES
| Message | Meaning / Resolution |
|------------------------------------------|----------------------------------------|
| add_choropleth: features must be arrayref | First argument is not an arrayref |
| add_choropleth: values must be hashref | Second argument is not a hashref |
=head3 EXAMPLES
# Default 5-step YlGnBu scale, key property "name"
$map->add_choropleth(
\@geojson_features,
{ England => 100, Scotland => 80, Wales => 60 },
);
# Custom 3-step scale and a different GeoJSON property as the key
$map->add_choropleth(
\@geojson_features,
{ England => 100, Scotland => 80, Wales => 60 },
key => 'country',
scale => ['#f7fbff', '#6baed6', '#08519c'],
);
# choropleth-only map â center() must be called explicitly
$map->center([54.0, -2.0]);
$map->add_choropleth(\@uk_features, \%population_by_region);
my ($head, $body) = $map->onload_render();
=cut | |||||
| 543 | ||||||
| 544 | sub add_choropleth | |||||
| 545 | { | |||||
| 546 | 49 | 1601 | my $self = shift; | |||
| 547 | 49 | 35 | my $features = shift; | |||
| 548 | 49 | 32 | my $values = shift; | |||
| 549 | 49 | 58 | my $params = Params::Get::get_params(undef, \@_) || {}; | |||
| 550 | ||||||
| 551 | 49 | 593 | croak 'add_choropleth: features must be an arrayref' unless ref($features) eq 'ARRAY'; | |||
| 552 | 42 | 99 | croak 'add_choropleth: values must be a hashref' unless ref($values) eq 'HASH'; | |||
| 553 | ||||||
| 554 | 37 | 61 | my $key = $params->{key} || 'name'; | |||
| 555 | 37 | 62 | my $scale = $params->{scale} || ['#ffffcc', '#a1dab4', '#41b6c4', '#2c7fb8', '#253494']; | |||
| 556 | ||||||
| 557 | # Compute colour index for each feature in Perl so the browser needs no | |||||
| 558 | # extra maths â the resulting lookup object is baked into the JS. | |||||
| 559 | 37 27 37 | 30 28 60 | my @sorted_vals = sort { $a <=> $b } values %{$values}; | |||
| 560 | 37 | 71 | my ($min, $max) = ($sorted_vals[0] // 0, $sorted_vals[-1] // 0); | |||
| 561 | 37 | 41 | $max = $min + 1 if $max == $min; # avoid division by zero for single-value sets | |||
| 562 | ||||||
| 563 | 37 | 22 | my %colors; | |||
| 564 | 37 92 | 31 112 | while(my ($k, $v) = each %{$values}) { | |||
| 565 | 55 55 | 45 63 | my $idx = int(($v - $min) / ($max - $min) * $#{$scale}); | |||
| 566 | 55 1 55 | 28 1 50 | $idx = $#{$scale} if $idx > $#{$scale}; | |||
| 567 | 55 | 58 | $colors{$k} = $scale->[$idx]; | |||
| 568 | } | |||||
| 569 | ||||||
| 570 | 37 37 | 25 73 | push @{$self->{choropleth_layers}}, { | |||
| 571 | features => $features, | |||||
| 572 | values => $values, | |||||
| 573 | colors => \%colors, | |||||
| 574 | key => $key, | |||||
| 575 | }; | |||||
| 576 | 37 | 66 | return 1; | |||
| 577 | } | |||||
| 578 | ||||||
| 579 - 623 | =head2 center
Set the map centre to a given point.
$map->center([40.7128, -74.0060]);
$map->center($geo_object);
$map->center('Berlin, Germany');
Returns 1 on success, 0 if the point cannot be resolved.
=head3 API SPECIFICATION
=head4 INPUT
point : arrayref [lat, lon] | object with latitude()/longitude() | string address
=head4 OUTPUT
{ type => integer, enum => [0, 1] }
=head3 MESSAGES
| Message | Meaning / Resolution |
|------------------------------------------------|------------------------------------------|
| center(): usage: point => [lat, lon] | No point argument supplied |
| center(): point must have latitude & longitude | Arrayref has != 2 elements |
| center(): unknown point type | Ref type has no lat/lon methods |
=head3 EXAMPLES
# Coordinate array
$map->center([40.7128, -74.0060]);
# Object that implements latitude()/longitude() (e.g. a Geo::Coder result)
$map->center($geocoder->geocode('Berlin, Germany'));
# String address resolved via the injected geocoder or Nominatim
$map->center('Eiffel Tower, Paris, France');
# Required when rendering without point markers (GeoJSON-only, choropleth, etc.)
$map->center([54.0, -2.0]);
$map->add_geojson(\%uk_regions, popup => 'name');
my ($head, $body) = $map->onload_render();
=cut | |||||
| 624 | ||||||
| 625 | sub center | |||||
| 626 | { | |||||
| 627 | 67 | 1846 | my $self = shift; | |||
| 628 | 67 | 98 | my $params = Params::Get::get_params('point', \@_); | |||
| 629 | 62 | 792 | my $point = $params->{'point'}; | |||
| 630 | ||||||
| 631 | 62 | 102 | croak 'center(): usage: point => [ latitude, longitude ]' unless defined($point); | |||
| 632 | ||||||
| 633 | 60 | 97 | my ($lat, $lon); | |||
| 634 | ||||||
| 635 | 60 | 118 | if(ref($point) eq 'ARRAY') { | |||
| 636 | croak 'center(): point must have latitude and longitude' | |||||
| 637 | 49 49 | 43 149 | if scalar(@{$point}) != 2; | |||
| 638 | 42 42 | 30 40 | ($lat, $lon) = @{$point}; | |||
| 639 | } elsif(ref($point) && $point->can('latitude')) { | |||||
| 640 | 2 | 4 | ($lat, $lon) = ($point->latitude(), $point->longitude()); | |||
| 641 | } elsif(!ref($point)) { | |||||
| 642 | 5 | 9 | ($lat, $lon) = $self->_fetch_coordinates($point); | |||
| 643 | } else { | |||||
| 644 | 4 | 5 | my $msg = 'center(): unknown point type: ' . ref($point); | |||
| 645 | 4 | 15 | $self->{logger}->error($msg) if $self->{logger}; | |||
| 646 | 4 | 992 | croak $msg; | |||
| 647 | } | |||||
| 648 | ||||||
| 649 | 49 | 136 | return 0 unless defined($lat) && defined($lon); | |||
| 650 | 46 | 55 | return 0 unless _validate($lat, $lon); | |||
| 651 | ||||||
| 652 | 42 | 54 | $self->{'center'} = [$lat, $lon]; | |||
| 653 | 42 | 68 | return 1; | |||
| 654 | } | |||||
| 655 | ||||||
| 656 - 696 | =head2 zoom
Get or set the zoom level (0 = world, 19 = building).
$map->zoom(10);
my $z = $map->zoom();
=head3 API SPECIFICATION
=head4 INPUT
{ zoom => { type => integer, min => 0, max => 19, optional => 1 } }
=head4 OUTPUT
{ type => integer, min => 0, max => 19 }
=head3 MESSAGES
| Message | Meaning / Resolution |
|------------------------------|-------------------------------------------|
| (Params::Validate::Strict) | zoom is not an integer or is out of range |
=head3 EXAMPLES
# Setter: store the zoom level
$map->zoom(14);
# Getter: retrieve the current level
my $level = $map->zoom();
print "Current zoom: $level\n"; # 14
# Setter return value equals the new level
my $confirmed = $map->zoom(10);
die 'unexpected' unless $confirmed == 10;
# Chain: set via new(), read back via zoom()
my $m = HTML::OSM->new(zoom => 6);
$m->zoom($m->zoom() + 2); # nudge up by 2
=cut | |||||
| 697 | ||||||
| 698 | sub zoom | |||||
| 699 | { | |||||
| 700 | 1268 | 4461 | my $self = shift; | |||
| 701 | ||||||
| 702 | 1268 | 1195 | if(scalar(@_)) { | |||
| 703 | 1237 | 1481 | my $params = Params::Validate::Strict::validate_strict({ | |||
| 704 | args => Params::Get::get_params('zoom', \@_), | |||||
| 705 | schema => { | |||||
| 706 | zoom => { optional => 1, type => 'integer', min => $ZOOM_MIN, max => $ZOOM_MAX }, | |||||
| 707 | }, | |||||
| 708 | }); | |||||
| 709 | 493 | 47445 | $self->{'zoom'} = $params->{'zoom'} if defined($params->{'zoom'}); | |||
| 710 | } | |||||
| 711 | ||||||
| 712 | 524 | 992 | return $self->{'zoom'}; | |||
| 713 | } | |||||
| 714 | ||||||
| 715 - 795 | =head2 onload_render
Render the map and return a two-element list suitable for embedding in HTML.
my ($head_html, $body_html) = $map->onload_render();
C<$head_html> contains the Leaflet CSS, JavaScript, and plugin assets.
Place it inside C<< <head>...</head> >>.
C<$body_html> contains the search box, control buttons, map C<< <div> >>,
and the initialisation C<< <script> >>.
Place it inside C<< <body>...</body> >> where the map should appear.
The rendered page provides:
=over 4
=item * A Nominatim-powered search box that adds temporary markers.
=item * A "Clear search markers" button that removes those temporary markers,
leaving static markers (added via C<add_marker>) intact.
=item * A "Reset Map" button that returns the view to the initial centre and zoom.
=back
=head3 API SPECIFICATION
=head4 INPUT
(none - uses object state)
=head4 OUTPUT
{ type => list, elements => [string, string] }
=head3 MESSAGES
| Message | Meaning / Resolution |
|--------------------------------------------------|---------------------------------------------|
| No map data provided | No markers, GeoJSON, heatmap, GPX, or choropleth added yet |
| center() must be called when no point markers | Non-marker-only render needs explicit centre |
=head3 EXAMPLES
# Minimal: one marker, embed in a CGI response
use HTML::OSM;
my $map = HTML::OSM->new(zoom => 12);
$map->add_marker([51.5074, -0.1278], html => 'London');
my ($head, $body) = $map->onload_render();
print "Content-Type: text/html\n\n";
print "<html><head>$head</head><body>$body</body></html>\n";
# Mixed layers: markers + GeoJSON, explicit center
$map->center([51.5, -0.1]);
$map->add_geojson(\%borough_data, popup => 'name', style => { color => '#333' });
$map->add_marker([51.5074, -0.1278], html => 'City of London');
my ($head_html, $body_html) = $map->onload_render();
# Template Toolkit integration
$tt->process('map.tt', {
map_head => scalar(($map->onload_render())[0]),
map_body => scalar(($map->onload_render())[1]),
});
=head3 PSEUDOCODE
1. Gather all data layers; croak if none populated.
2. Geocode/validate each coordinate tuple; discard invalids with a warning.
3. Determine map centre: caller-supplied > computed midpoint of marker bounds.
Croak if neither is available.
4. Build <head>: Leaflet CSS + JS; inject cluster/heatmap/GPX plugin assets
only when the corresponding layer type is present.
5. Build <body>: search box, clear-search button, reset button, map <div>.
6. Initialise Leaflet map, tile layer, searchMarkers array.
7. Emit JS for each marker (via clusterGroup when cluster is set).
8. Emit JS for each GeoJSON, heatmap, GPX, and choropleth layer.
9. Attach event listeners: reset-view, clear-search-markers, search-on-Enter.
10. Return ($head, $body).
=cut | |||||
| 796 | ||||||
| 797 | sub onload_render | |||||
| 798 | { | |||||
| 799 | 149 | 3323 | my $self = shift; | |||
| 800 | ||||||
| 801 | 149 | 169 | my $height = $self->{'height'} || '400px'; | |||
| 802 | 149 | 155 | my $width = $self->{'width'} || '600px'; | |||
| 803 | 149 | 137 | my $coordinates = $self->{coordinates} || []; | |||
| 804 | 149 | 211 | my $geojson_layers = $self->{geojson} || []; | |||
| 805 | 149 | 187 | my $heatmap_layers = $self->{heatmap_layers} || []; | |||
| 806 | 149 | 180 | my $gpx_tracks = $self->{gpx_tracks} || []; | |||
| 807 | 149 | 174 | my $choropleth_layers = $self->{choropleth_layers} || []; | |||
| 808 | ||||||
| 809 | 149 | 255 | unless(@$coordinates || @$geojson_layers || @$heatmap_layers | |||
| 810 | || @$gpx_tracks || @$choropleth_layers) { | |||||
| 811 | 8 | 32 | $self->{logger}->error('No map data provided') if $self->{logger}; | |||
| 812 | 8 | 2399 | croak 'No map data provided'; | |||
| 813 | } | |||||
| 814 | ||||||
| 815 | # Geocode address strings; validate and discard bad numeric pairs. | |||||
| 816 | 141 | 89 | my @valid_coordinates; | |||
| 817 | 141 | 126 | for my $coord (@$coordinates) { | |||
| 818 | 125 | 120 | my ($lat, $lon, $label, $icon_url) = @$coord; | |||
| 819 | 125 | 188 | if(!defined $lat || !defined $lon) { | |||
| 820 | 3 | 5 | ($lat, $lon) = $self->_fetch_coordinates($label); | |||
| 821 | } | |||||
| 822 | # Validate ALL coordinates here â including geocoder-returned ones. | |||||
| 823 | # A compromised geocoder or Nominatim response could return a crafted | |||||
| 824 | # lat/lon string that would inject JS if embedded without validation. | |||||
| 825 | 125 | 195 | next unless defined($lat) && defined($lon) && _validate($lat, $lon); | |||
| 826 | 119 | 148 | push @valid_coordinates, [$lat, $lon, $label, $icon_url]; | |||
| 827 | } | |||||
| 828 | ||||||
| 829 | # Determine map centre: caller-set wins; else compute from marker bounds. | |||||
| 830 | 141 | 89 | my ($center_lat, $center_lon); | |||
| 831 | 141 | 158 | if($self->{'center'}) { | |||
| 832 | 35 35 | 22 37 | ($center_lat, $center_lon) = @{$self->{'center'}}; | |||
| 833 | } elsif(@valid_coordinates) { | |||||
| 834 | 98 | 94 | my ($min_lat, $min_lon, $max_lat, $max_lon) = (90, 180, -90, -180); | |||
| 835 | 98 | 69 | for my $c (@valid_coordinates) { | |||
| 836 | 113 | 162 | $min_lat = $c->[0] if $c->[0] < $min_lat; | |||
| 837 | 113 | 113 | $max_lat = $c->[0] if $c->[0] > $max_lat; | |||
| 838 | 113 | 94 | $min_lon = $c->[1] if $c->[1] < $min_lon; | |||
| 839 | 113 | 119 | $max_lon = $c->[1] if $c->[1] > $max_lon; | |||
| 840 | } | |||||
| 841 | 98 | 79 | $center_lat = ($min_lat + $max_lat) / 2; | |||
| 842 | 98 | 69 | $center_lon = ($min_lon + $max_lon) / 2; | |||
| 843 | } else { | |||||
| 844 | 8 | 91 | croak 'center() must be called when no point markers are provided'; | |||
| 845 | } | |||||
| 846 | ||||||
| 847 | # --- <head> --- | |||||
| 848 | 133 | 145 | my $head = qq{ | |||
| 849 | <link rel="stylesheet" href="$self->{css_url}" /> | |||||
| 850 | <script src="$self->{js_url}"></script> | |||||
| 851 | }; | |||||
| 852 | ||||||
| 853 | 133 | 121 | if($self->{cluster}) { | |||
| 854 | 8 | 13 | $head .= qq{ | |||
| 855 | <link rel="stylesheet" href="$self->{cluster_css_url}" /> | |||||
| 856 | <link rel="stylesheet" href="$self->{cluster_default_css_url}" /> | |||||
| 857 | <script src="$self->{cluster_js_url}"></script> | |||||
| 858 | }; | |||||
| 859 | } | |||||
| 860 | 133 | 133 | $head .= qq{\t\t<script src="$self->{heatmap_js_url}"></script>\n} if @$heatmap_layers; | |||
| 861 | 133 | 99 | $head .= qq{\t\t<script src="$self->{gpx_js_url}"></script>\n} if @$gpx_tracks; | |||
| 862 | ||||||
| 863 | 133 | 127 | $head .= qq{ | |||
| 864 | <style> | |||||
| 865 | #map { width: $width; height: $height; } | |||||
| 866 | #search-box { margin: 10px; padding: 5px; } | |||||
| 867 | #reset-button, #clear-search-button { margin: 10px; padding: 5px; cursor: pointer; } | |||||
| 868 | </style> | |||||
| 869 | }; | |||||
| 870 | ||||||
| 871 | # --- <body> --- | |||||
| 872 | 133 | 309 | my $body = qq{ | |||
| 873 | <input type="text" id="search-box" placeholder="Enter location"> | |||||
| 874 | <button id="clear-search-button">Clear search markers</button> | |||||
| 875 | <button id="reset-button">Reset Map</button> | |||||
| 876 | <div id="map"></div> | |||||
| 877 | <script> | |||||
| 878 | var map = L.map('map').setView([$center_lat, $center_lon], $self->{zoom}); | |||||
| 879 | L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |||||
| 880 | attribution: '© OpenStreetMap contributors' | |||||
| 881 | }).addTo(map); | |||||
| 882 | ||||||
| 883 | var searchMarkers = []; | |||||
| 884 | }; | |||||
| 885 | ||||||
| 886 | # Point markers â optionally grouped into a cluster layer. | |||||
| 887 | 133 | 128 | if(@valid_coordinates) { | |||
| 888 | 102 | 93 | $body .= "\t\t\tvar clusterGroup = L.markerClusterGroup();\n" if $self->{cluster}; | |||
| 889 | ||||||
| 890 | 102 | 66 | for my $coord (@valid_coordinates) { | |||
| 891 | 119 | 102 | my ($lat, $lon, $label, $icon_url) = @$coord; | |||
| 892 | 119 | 101 | my $js_label = _js_string($label); | |||
| 893 | 119 | 126 | if($icon_url) { | |||
| 894 | 4 | 5 | my $js_icon = _js_string($icon_url); | |||
| 895 | my $add_cmd = $self->{cluster} | |||||
| 896 | 4 | 41 | ? 'clusterGroup.addLayer(m);' | |||
| 897 | : 'm.addTo(map);'; | |||||
| 898 | 4 | 13 | $body .= qq{ | |||
| 899 | (function() { | |||||
| 900 | var icon = L.icon({ iconUrl: '$js_icon', iconAnchor: [16,32], popupAnchor: [0,-32] }); | |||||
| 901 | var m = L.marker([$lat, $lon], { icon: icon }).bindPopup('$js_label'); | |||||
| 902 | $add_cmd | |||||
| 903 | })(); | |||||
| 904 | }; | |||||
| 905 | } elsif($self->{cluster}) { | |||||
| 906 | 11 | 23 | $body .= "\t\t\tclusterGroup.addLayer(L.marker([$lat, $lon]).bindPopup('$js_label'));\n"; | |||
| 907 | } else { | |||||
| 908 | 104 | 182 | $body .= "\t\t\tL.marker([$lat, $lon]).addTo(map).bindPopup('$js_label');\n"; | |||
| 909 | } | |||||
| 910 | } | |||||
| 911 | ||||||
| 912 | 102 | 89 | $body .= "\t\t\tmap.addLayer(clusterGroup);\n" if $self->{cluster}; | |||
| 913 | } | |||||
| 914 | ||||||
| 915 | # GeoJSON layers. | |||||
| 916 | 133 | 100 | for my $layer (@$geojson_layers) { | |||
| 917 | 23 | 35 | my $json = _html_json($layer->{data}); | |||
| 918 | 22 | 30 | my $opts = $layer->{opts} || {}; | |||
| 919 | 22 | 19 | my $style_js = ''; | |||
| 920 | 22 | 13 | my $popup_js = ''; | |||
| 921 | 22 | 26 | if(my $style = $opts->{style}) { | |||
| 922 | 11 | 10 | $style_js = 'style: ' . _html_json($style) . ','; | |||
| 923 | } | |||||
| 924 | 22 | 27 | if(my $prop = $opts->{popup}) { | |||
| 925 | 4 | 4 | my $js_prop = _js_string($prop); | |||
| 926 | 4 | 6 | $popup_js = "onEachFeature: function(f,l){ if(f.properties && f.properties['$js_prop']){ l.bindPopup(String(f.properties['$js_prop'])); } },"; | |||
| 927 | } | |||||
| 928 | 22 | 33 | $body .= "\t\t\tL.geoJSON($json, { $style_js $popup_js }).addTo(map);\n"; | |||
| 929 | } | |||||
| 930 | ||||||
| 931 | # Heatmap layers. | |||||
| 932 | 132 | 105 | for my $layer (@$heatmap_layers) { | |||
| 933 | 12 | 13 | my $pts = _html_json($layer->{points}); | |||
| 934 | 12 | 17 | my $opts = $layer->{opts} || {}; | |||
| 935 | 12 | 20 | my $radius = $opts->{radius} || 25; | |||
| 936 | 12 | 20 | my $blur = $opts->{blur} || 15; | |||
| 937 | 12 | 23 | $body .= "\t\t\tL.heatLayer($pts, { radius: $radius, blur: $blur }).addTo(map);\n"; | |||
| 938 | } | |||||
| 939 | ||||||
| 940 | # GPX tracks â browser fetches the file; fitBounds called on load. | |||||
| 941 | 132 | 102 | for my $url (@$gpx_tracks) { | |||
| 942 | 10 | 8 | my $js_url = _js_string($url); | |||
| 943 | 10 | 14 | $body .= "\t\t\tnew L.GPX('$js_url', { async: true }).on('loaded', function(e){ map.fitBounds(e.target.getBounds()); }).addTo(map);\n"; | |||
| 944 | } | |||||
| 945 | ||||||
| 946 | # Choropleth layers â colours are pre-baked; no browser-side scale maths. | |||||
| 947 | 132 | 106 | for my $layer (@$choropleth_layers) { | |||
| 948 | 21 | 39 | my $fc_json = _html_json({ type => 'FeatureCollection', features => $layer->{features} }); | |||
| 949 | 21 | 27 | my $colors_json = _html_json($layer->{colors}); | |||
| 950 | 21 | 24 | my $values_json = _html_json($layer->{values}); | |||
| 951 | 21 | 23 | my $js_key = _js_string($layer->{key}); | |||
| 952 | 21 | 72 | $body .= qq{ | |||
| 953 | (function() { | |||||
| 954 | var choroplethColors = $colors_json; | |||||
| 955 | var choroplethValues = $values_json; | |||||
| 956 | L.geoJSON($fc_json, { | |||||
| 957 | style: function(f) { | |||||
| 958 | var k = f.properties && f.properties['$js_key']; | |||||
| 959 | return { fillColor: choroplethColors[k] || '#cccccc', | |||||
| 960 | weight: 2, opacity: 1, color: 'white', | |||||
| 961 | dashArray: '3', fillOpacity: 0.7 }; | |||||
| 962 | }, | |||||
| 963 | onEachFeature: function(f, l) { | |||||
| 964 | var k = f.properties && f.properties['$js_key']; | |||||
| 965 | if(k && choroplethValues[k] !== undefined) { | |||||
| 966 | l.bindPopup(k + ': ' + choroplethValues[k]); | |||||
| 967 | } | |||||
| 968 | } | |||||
| 969 | }).addTo(map); | |||||
| 970 | })(); | |||||
| 971 | }; | |||||
| 972 | } | |||||
| 973 | ||||||
| 974 | # Event handlers. | |||||
| 975 | 132 | 358 | $body .= qq{ | |||
| 976 | document.getElementById('reset-button').addEventListener('click', function() { | |||||
| 977 | map.setView([$center_lat, $center_lon], $self->{zoom}); | |||||
| 978 | }); | |||||
| 979 | ||||||
| 980 | document.getElementById('clear-search-button').addEventListener('click', function() { | |||||
| 981 | searchMarkers.forEach(function(m) { map.removeLayer(m); }); | |||||
| 982 | searchMarkers = []; | |||||
| 983 | }); | |||||
| 984 | ||||||
| 985 | document.getElementById('search-box').addEventListener('keyup', function(event) { | |||||
| 986 | if(event.key === 'Enter') { | |||||
| 987 | var query = event.target.value.trim(); | |||||
| 988 | if(!query) { alert('Please enter a valid location.'); return; } | |||||
| 989 | fetch('https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(query)) | |||||
| 990 | .then(function(r) { return r.json(); }) | |||||
| 991 | .then(function(data) { | |||||
| 992 | if(data.length > 0) { | |||||
| 993 | var lat = data[0].lat, lon = data[0].lon; | |||||
| 994 | map.setView([lat, lon], 14); | |||||
| 995 | searchMarkers.push(L.marker([lat, lon]).addTo(map).bindPopup(query).openPopup()); | |||||
| 996 | } else { | |||||
| 997 | alert('No results found. Try a different location.'); | |||||
| 998 | } | |||||
| 999 | }) | |||||
| 1000 | .catch(function(err) { | |||||
| 1001 | console.error('Search error:', err); | |||||
| 1002 | alert('Failed to fetch location. Please check your internet connection.'); | |||||
| 1003 | }); | |||||
| 1004 | } | |||||
| 1005 | }); | |||||
| 1006 | </script> | |||||
| 1007 | }; | |||||
| 1008 | ||||||
| 1009 | 132 | 280 | return ($head, $body); | |||
| 1010 | } | |||||
| 1011 | ||||||
| 1012 | # _fetch_coordinates: Resolve a place-name string to a (lat, lon) pair. | |||||
| 1013 | # Purpose: Centralise all geocoding logic â try the injected geocoder first, | |||||
| 1014 | # fall back to a direct Nominatim HTTP call with caching and rate-limiting. | |||||
| 1015 | # Entry: $self, $location (non-empty string). | |||||
| 1016 | # Exit: ($lat, $lon) strings on success; (undef, undef) on failure. | |||||
| 1017 | # Side Effects: May sleep to honour min_interval; writes to $self->{cache}. | |||||
| 1018 | sub _fetch_coordinates | |||||
| 1019 | { | |||||
| 1020 | 55 | 5499 | my ($self, $location) = @_; | |||
| 1021 | ||||||
| 1022 | 55 | 123 | croak 'address not given to _fetch_coordinates' unless $location; | |||
| 1023 | ||||||
| 1024 | # Prefer an injected geocoder (e.g. Geo::Coder::List) to avoid HTTP. | |||||
| 1025 | 51 | 116 | if(my $geocoder = $self->{'geocoder'}) { | |||
| 1026 | 33 | 42 | my $rc = $geocoder->geocode($location); | |||
| 1027 | 33 | 224 | return (undef, undef) unless defined $rc; | |||
| 1028 | ||||||
| 1029 | 25 | 61 | if(blessed($rc) && $rc->can('latitude')) { | |||
| 1030 | 2 | 4 | return ($rc->latitude(), $rc->longitude()); | |||
| 1031 | } | |||||
| 1032 | 23 | 34 | if(ref($rc) eq 'HASH') { | |||
| 1033 | return ($rc->{lat}, $rc->{lon}) | |||||
| 1034 | 20 | 81 | if defined($rc->{lat}) && defined($rc->{lon}); | |||
| 1035 | return ($rc->{geometry}{location}{lat}, $rc->{geometry}{location}{lng}) | |||||
| 1036 | 4 | 10 | if defined($rc->{geometry}{location}{lat}); | |||
| 1037 | } | |||||
| 1038 | # Some geocoders return a flat [lat, lon] arrayref | |||||
| 1039 | 5 2 | 8 4 | return @{$rc} if ref($rc) eq 'ARRAY'; | |||
| 1040 | ||||||
| 1041 | # Unrecognised return type â treat as failure | |||||
| 1042 | 3 | 42 | carp '_fetch_coordinates: unrecognised geocoder result type: ' . ref($rc); | |||
| 1043 | 3 | 603 | return (undef, undef); | |||
| 1044 | } | |||||
| 1045 | ||||||
| 1046 | # Direct Nominatim path: apply caching and rate-limiting. | |||||
| 1047 | 18 | 33 | my $cache_key = 'osm:' . uri_escape_utf8($location); | |||
| 1048 | 18 | 289 | if(my $cached = $self->{cache}->get($cache_key)) { | |||
| 1049 | 4 | 484 | return ($cached->{lat}, $cached->{lon}); | |||
| 1050 | } | |||||
| 1051 | ||||||
| 1052 | # Honour the minimum interval between outbound requests. | |||||
| 1053 | 14 | 684 | my $elapsed = time() - $self->{last_request}; | |||
| 1054 | 14 | 25 | if($elapsed < $self->{min_interval}) { | |||
| 1055 | 3 | 999339 | Time::HiRes::sleep($self->{min_interval} - $elapsed); | |||
| 1056 | } | |||||
| 1057 | ||||||
| 1058 | 14 | 60 | my $ua = $self->{'ua'} | |||
| 1059 | || LWP::UserAgent->new(agent => __PACKAGE__ . "/$VERSION"); | |||||
| 1060 | 14 | 57 | $ua->default_header(accept_encoding => 'gzip,deflate'); | |||
| 1061 | 14 | 141 | $ua->env_proxy(1); | |||
| 1062 | ||||||
| 1063 | 14 | 2572 | my $url = 'https://' . $self->{'host'} . '?format=json&q=' . uri_escape_utf8($location); | |||
| 1064 | 14 | 160 | my $response = $ua->get($url); | |||
| 1065 | 14 | 173 | $self->{'last_request'} = time(); | |||
| 1066 | ||||||
| 1067 | 14 | 28 | if($response->is_success()) { | |||
| 1068 | # eval guard: Nominatim normally returns valid JSON, but a maintenance | |||||
| 1069 | # page or rate-limit response could return HTML with a 200 OK. Dying | |||||
| 1070 | # inside _fetch_coordinates would bubble uncaught to add_marker / onload_render. | |||||
| 1071 | 11 11 | 27 17 | my $data = eval { decode_json($response->decoded_content()) }; | |||
| 1072 | 11 | 287 | if($@) { | |||
| 1073 | 2 | 30 | carp "_fetch_coordinates: failed to decode Nominatim response: $@"; | |||
| 1074 | 2 | 398 | return (undef, undef); | |||
| 1075 | } | |||||
| 1076 | 9 | 30 | $data = $data->[0] if ref($data) eq 'ARRAY'; | |||
| 1077 | 9 | 27 | if(ref($data) eq 'HASH' && defined($data->{lat})) { | |||
| 1078 | 8 | 22 | $self->{'cache'}->set($cache_key, $data); | |||
| 1079 | 8 | 1701 | return ($data->{lat}, $data->{lon}); | |||
| 1080 | } | |||||
| 1081 | } | |||||
| 1082 | ||||||
| 1083 | 4 | 12 | return (undef, undef); | |||
| 1084 | } | |||||
| 1085 | ||||||
| 1086 | # _validate: Check that a (lat, lon) pair is numeric and within WGS-84 bounds. | |||||
| 1087 | # Purpose: Single guard point for all coordinate ingestion; emits a carp when | |||||
| 1088 | # both values are defined but wrong so the caller can log without dying. | |||||
| 1089 | # Entry: ($lat, $lon) â any scalar, including undef. | |||||
| 1090 | # Exit: 1 if valid, 0 if not. | |||||
| 1091 | # Side Effects: carp when coordinates are defined but out-of-range/non-numeric. | |||||
| 1092 | sub _validate | |||||
| 1093 | { | |||||
| 1094 | 365 | 22273 | my ($lat, $lon) = @_; | |||
| 1095 | ||||||
| 1096 | # Require at least one digit (rejects empty string â unlike \d* which matches ''). | |||||
| 1097 | # Leading-decimal notation (e.g. -.5167) is valid per Changes 0.05. | |||||
| 1098 | # \z (not $) prevents a trailing \n from sneaking through: $ matches before | |||||
| 1099 | # a final newline, which would make "0\n" valid and embed a newline in JS. | |||||
| 1100 | 365 | 427 | my $numeric = qr/^-?(?:\d+(?:\.\d+)?|\.\d+)\z/; | |||
| 1101 | ||||||
| 1102 | 365 | 2910 | my $ok = defined($lat) && defined($lon) | |||
| 1103 | && $lat =~ $numeric && $lon =~ $numeric | |||||
| 1104 | && $lat >= $LAT_MIN && $lat <= $LAT_MAX | |||||
| 1105 | && $lon >= $LON_MIN && $lon <= $LON_MAX; | |||||
| 1106 | ||||||
| 1107 | 365 | 807 | carp(sprintf 'Skipping invalid coordinate: (%s, %s)', | |||
| 1108 | $lat // 'undef', $lon // 'undef') | |||||
| 1109 | if !$ok && defined($lat) && defined($lon); | |||||
| 1110 | ||||||
| 1111 | 365 | 4888 | return $ok ? 1 : 0; | |||
| 1112 | } | |||||
| 1113 | ||||||
| 1114 | # _html_json: Encode data as JSON and make the result safe for embedding in a | |||||
| 1115 | # <script> block. JSON encoders do not escape '/' by default, so a value like | |||||
| 1116 | # '</script>' would close the enclosing script tag and allow HTML injection. | |||||
| 1117 | # Escaping every '</' as '<\/' prevents this without altering the decoded value. | |||||
| 1118 | # Entry: Any Perl data structure accepted by encode_json. | |||||
| 1119 | # Exit: JSON string safe for direct insertion inside a <script> block. | |||||
| 1120 | sub _html_json | |||||
| 1121 | { | |||||
| 1122 | 114 | 94035 | my $j = encode_json(shift); | |||
| 1123 | 113 | 1491 | $j =~ s|</|<\\/|g; | |||
| 1124 | 113 | 89 | return $j; | |||
| 1125 | } | |||||
| 1126 | ||||||
| 1127 | # _js_string: Escape a Perl string for safe embedding in a JS single-quoted literal. | |||||
| 1128 | # Purpose: Prevent JS injection via user-supplied labels, URLs, or property names. | |||||
| 1129 | # Entry: Any scalar (undef becomes ''). | |||||
| 1130 | # Exit: Escaped string safe for insertion between JS single quotes. | |||||
| 1131 | sub _js_string | |||||
| 1132 | { | |||||
| 1133 | 175 | 109010 | my $s = shift // ''; | |||
| 1134 | 175 | 112 | $s =~ s/\\/\\\\/g; # escape backslash first to avoid double-escaping | |||
| 1135 | 175 | 114 | $s =~ s/'/\\'/g; # escape our JS string delimiter | |||
| 1136 | 175 | 96 | $s =~ s/\r?\n/\\n/g; # newlines would break the JS string | |||
| 1137 | 175 | 122 | $s =~ s|</|<\\/|g; # prevent </script> injection | |||
| 1138 | 175 | 149 | return $s; | |||
| 1139 | } | |||||
| 1140 | ||||||
| 1141 - 1310 | =head1 LIMITATIONS
=over 4
=item * B<Per-marker removal>: Markers added via C<add_marker()> cannot yet be
removed individually by clicking them. The "Clear search markers" button only
removes markers added by the in-page Nominatim search box.
=item * B<Clone validation>: The clone path (C<< $obj->new(%overrides) >>)
bypasses the Params::Validate::Strict schema so subclasses and internal callers
can merge arbitrary state. Callers are responsible for passing valid overrides.
=item * B<Config-file params unvalidated>: Keys injected by
L<Object::Configure> from a config file are not re-run through the schema, so
a malformed config file can introduce invalid types at runtime.
=item * B<Private-method encapsulation>: C<_fetch_coordinates>, C<_validate>,
and C<_js_string> are named with a leading underscore by convention only.
Using L<Sub::Private> in C<enforce> mode would make the contract explicit, but
that module is not yet listed as a dependency to avoid breaking white-box tests
in C<t/mock.t>.
=item * B<Routing>: Turn-by-turn routing (Leaflet Routing Machine / OSRM) is
explicitly out of scope for this module and will not be added here.
=back
=head1 AUTHOR
Nigel Horne, C<< <njh at nigelhorne.com> >>
=head1 BUGS
Please report bugs at L<https://github.com/nigelhorne/HTML-OSM/issues>.
=head1 SEE ALSO
=over 4
=item * L<https://wiki.openstreetmap.org/wiki/API>
=item * L<HTML::GoogleMaps::V3> - the interface this module mirrors for compatibility.
=item * L<https://leafletjs.com/>
=item * L<Configure an Object at Runtime|Object::Configure>
=item * L<Test Dashboard|https://nigelhorne.github.io/HTML-OSM/coverage/>
=back
=head1 SUPPORT
This module is provided as-is without any warranty.
L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=HTML-OSM>
=head2 TODO
Allow per-marker removal via clicking on a marker.
=encoding utf-8
=head1 FORMAL SPECIFICATION
=head2 new
HTML_OSM
coordinates : iseq (â x â x S x S)
zoom : Z
cluster : B
----------------------------------------
ZOOM_MIN <= zoom <= ZOOM_MAX
new â
params? : Params
osm! : HTML_OSM
----------------------------------------
osm!.zoom = params?.zoom ⨠12
osm!.cluster = params?.cluster ⨠false
=head2 add_marker
AddMarker
ÎHTML_OSM
point? : (â x â) ⪠S ⪠GeoObject
result! : {0, 1}
-----------------------------------------
result! = 1 ⺠point? resolves to (lat, lon) â ValidCoord
result! = 1 â¹ coordinates' = coordinates ⢠â¨(lat, lon, label, icon)â©
=head2 add_geojson
AddGeoJSON
ÎHTML_OSM
data? : GeoJSONStruct ⪠S
style? : StyleMap ⪠{â
}
popup? : S ⪠{â
}
-----------------------------------------
geojson' = geojson ⢠â¨{data, style, popup}â©
=head2 add_heatmap
AddHeatmap
ÎHTML_OSM
points? : iseq (â x â x [0,1])
-----------------------------------------
heatmap_layers' = heatmap_layers ⢠â¨{points, radius, blur}â©
=head2 add_gpx
AddGPX
ÎHTML_OSM
url? : S | url? â ''
-----------------------------------------
gpx_tracks' = gpx_tracks ⢠â¨url?â©
=head2 add_chropleth
AddChoropleth
ÎHTML_OSM
features? : iseq GeoFeature
values? : S --> â
key? : S
scale? : iseq S
-----------------------------------------
Let min = min(ran values?), max = max(ran values?) ⪠{min+1}
â k â dom values? â¢
color(k) = scale?[floor((values?(k)-min)/(max-min) * (#scale?-1))]
choropleth_layers' = choropleth_layers ⢠â¨{features, values, colors, key}â©
=head2 center
Center
ÎHTML_OSM
point? : (â x â) ⪠S ⪠GeoObject
result! : {0, 1}
-----------------------------------------
result! = 1 ⺠point? resolves to (lat, lon) â ValidCoord
result! = 1 â¹ center' = (lat, lon)
=head2 zoom
Zoom
ÎHTML_OSM
zoom? : Z ⪠{â
}
zoom! : Z
-----------------------------------------
zoom? â â
â¹ ZOOM_MIN <= zoom? <= ZOOM_MAX
zoom! = (zoom? â â
⧠zoom' = zoom?) ⨠zoom
=head2 onload_render
OnloadRender
HTML_OSM
head! : S
body! : S
-----------------------------------------
(#coordinates + #geojson + #heatmap_layers + #gpx_tracks + #choropleth_layers) > 0
center â â
⨠â valid â coordinates ⢠valid â ValidCoord
=head1 LICENSE AND COPYRIGHT
Copyright 2025-2026 Nigel Horne.
This program is released under the following licence: GPL2
If you use it,
please let me know.
=cut | |||||
| 1311 | ||||||
| 1312 | 1; | |||||
| 1313 | ||||||