lib/HTML/OSM.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 97.01%
TER3 (LCSAJ): 100.0% (20/20)
Approximate LCSAJ segments: 135

LCSAJ Legend

Covered — this LCSAJ path was executed during testing.

Not covered — this LCSAJ path was never executed. These are the paths to focus on.

Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:

        start → end → jump
        

Uncovered paths show [NOT COVERED] in the tooltip.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    1: #!/usr/bin/env perl
    2: 
    3: package HTML::OSM;
    4: 
    5: use strict;
    6: use warnings;
    7: use autodie qw(:all);
    8: 
    9: use Carp qw(carp croak);
   10: use CHI;
   11: use JSON::MaybeXS qw(decode_json encode_json);
   12: use LWP::UserAgent;
   13: use Object::Configure 0.15;
   14: use Params::Get 0.13;
   15: use Params::Validate::Strict 0.28;
   16: use Readonly;
   17: use Scalar::Util qw(blessed);
   18: use Time::HiRes qw(time);
   19: use URI::Escape qw(uri_escape_utf8);
   20: 
   21: =head1 NAME
   22: 
   23: HTML::OSM - Generate an interactive OpenStreetMap with Leaflet.js
   24: 
   25: =head1 VERSION
   26: 
   27: Version 0.10
   28: 
   29: =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: =head1 SYNOPSIS
   53: 
   54:     use HTML::OSM;
   55: 
   56:     my $map = HTML::OSM->new(
   57:         coordinates => [
   58:             [37.7749, -122.4194, 'San Francisco'],
   59:             [undef,   undef,     'Paris'],
   60:         ],
   61:         zoom => 10,
   62:     );
   63:     my ($head, $body) = $map->onload_render();
   64: 
   65: =over 4
   66: 
   67: =item * Caching
   68: 
   69: Geocode results are cached via L<CHI> (default: in-memory, 1-day TTL).
   70: Supply your own C<cache> object to persist across processes.
   71: 
   72: =item * Rate-Limiting
   73: 
   74: Set C<min_interval> (seconds) to throttle outbound Nominatim calls
   75: and comply with the API fair-use policy.
   76: 
   77: =back
   78: 
   79: =head1 SUBROUTINES/METHODS
   80: 
   81: =head2 new
   82: 
   83: Construct a new C<HTML::OSM> object.
   84: 
   85:     my $map = HTML::OSM->new(%params);
   86:     my $map = HTML::OSM->new(\%params);
   87: 
   88: Both method-style (C<< HTML::OSM->new(...) >>) and function-style
   89: (C<HTML::OSM::new(...)>) calls are supported.
   90: Calling C<< $existing_obj->new(%overrides) >> performs a shallow clone,
   91: merging C<%overrides> onto the existing object's state without re-validating.
   92: 
   93: =head3 API SPECIFICATION
   94: 
   95: =head4 INPUT
   96: 
   97:   {
   98:     cache                   => { type => object, can => [get, set], optional },
   99:     cluster                 => { type => boolean,                   optional },
  100:     cluster_css_url         => { type => string,                    optional },
  101:     cluster_default_css_url => { type => string,                    optional },
  102:     cluster_js_url          => { type => string,                    optional },
  103:     config_file             => { type => string,                    optional },
  104:     coordinates             => { type => arrayref,                  optional },
  105:     css_url                 => { type => string,                    optional },
  106:     geocoder                => { type => object, can => geocode,    optional },
  107:     gpx_js_url              => { type => string,                    optional },
  108:     heatmap_js_url          => { type => string,                    optional },
  109:     height                  => { type => string,                    optional },
  110:     host                    => { type => string,                    optional },
  111:     js_url                  => { type => string,                    optional },
  112:     logger                  => { type => object,                    optional },
  113:     min_interval            => { type => number,  min => 0,         optional },
  114:     ua                      => { type => object,                    optional },
  115:     width                   => { type => string,                    optional },
  116:     zoom                    => { type => integer, min => 0, max => 19, optional },
  117:   }
  118: 
  119: =head4 OUTPUT
  120: 
  121:   { type => object, isa => 'HTML::OSM' }
  122: 
  123: =head3 MESSAGES
  124: 
  125:   | Message                                        | Meaning / Resolution                          |
  126:   |------------------------------------------------|-----------------------------------------------|
  127:   | (validation error from Params::Validate::Strict) | A param has the wrong type or is out of range |
  128: 
  129: =head3 PSEUDOCODE
  130: 
  131:   1. If $class is neither a package name nor a blessed ref, treat as
  132:      function-style call: prepend $class back onto @_ and use __PACKAGE__.
  133:   2. If $class is a blessed ref (clone call): merge override params onto a
  134:      shallow copy and return immediately, bypassing schema validation.
  135:   3. Validate all supplied args against the declared schema.
  136:   4. Merge config-file settings via Object::Configure.
  137:   5. Resolve the cache: caller-supplied object, or in-memory CHI instance.
  138:   6. Bless and return with Readonly CDN constants as defaults.
  139: 
  140: =cut
  141: 
  142: sub new
  143: {
144 → 149 → 156  144: 	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: 	if(defined($class) && !blessed($class) && !UNIVERSAL::isa($class, __PACKAGE__)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

150: unshift @_, $class; 151: $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 → 156 → 161 156: if(blessed($class)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

157: my $extra = Params::Get::get_params(undef, \@_) || {}; 158: return bless { %{$class}, %{$extra} }, ref($class);

Mutants (Total: 2, Killed: 2, Survived: 0)

159: } 160: 161: $class //= __PACKAGE__; 162: 163: 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: $params = Object::Configure::configure($class, $params); 190: 191: # Inject the resolved cache so the bless hash spreads it correctly. 192: $params->{cache} //= CHI->new( 193: driver => 'Memory', 194: global => 1, 195: expires_in => '1 day', 196: ); 197: 198: return bless {

Mutants (Total: 2, Killed: 2, Survived: 0)

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: %{$params}, 216: }, $class; 217: } 218: 219: =head2 add_marker 220: 221: Add a point marker to the map. 222: 223: $map->add_marker([51.5074, -0.1278], html => 'London'); 224: $map->add_marker('Paris, France', html => 'Paris'); 225: $map->add_marker($geo_coder_result); 226: 227: Returns 1 on success, 0 if the point cannot be resolved or is out of range. 228: 229: =head3 API SPECIFICATION 230: 231: =head4 INPUT 232: 233: point : arrayref [lat, lon] | string address | object with latitude()/longitude() 234: html : string (optional popup label) 235: icon : string (optional icon URL) 236: 237: =head4 OUTPUT 238: 239: { type => integer, enum => [0, 1] } 240: 241: =head3 MESSAGES 242: 243: | Message | Meaning / Resolution | 244: |--------------------------------------|---------------------------------------------| 245: | add_marker(): unknown point type | Point is a ref type with no lat/lon methods | 246: 247: =head3 EXAMPLES 248: 249: # Coordinate array with a popup label 250: $map->add_marker([51.5074, -0.1278], html => 'London'); 251: 252: # String address geocoded via the injected geocoder or Nominatim 253: $map->add_marker('Paris, France', html => 'Paris'); 254: 255: # Custom icon URL with a popup 256: $map->add_marker( 257: [40.7128, -74.0060], 258: html => 'New York', 259: icon => 'https://example.com/pin.png', 260: ); 261: 262: # Geo::Coder result object that implements latitude()/longitude() 263: my $result = $geocoder->geocode('Berlin, Germany'); 264: $map->add_marker($result, html => 'Berlin'); 265: 266: # Accumulate several markers, warn on geocode failure 267: for my $city (@cities) { 268: $map->add_marker($city->{coords}, html => $city->{name}) 269: or warn "Could not place $city->{name}"; 270: } 271: 272: =cut 273: 274: sub add_marker 275: { 276 → 279 → 299 276: my $self = shift; 277: my ($params, $point); 278: 279: if(ref($_[0]) eq 'ARRAY') {

Mutants (Total: 1, Killed: 1, Survived: 0)

280: $point = shift; 281: $params = Params::Get::get_params(undef, \@_) || {}; 282: # Single-element arrayref is a wrapped address string 283: $point = $point->[0] if scalar(@{$point}) == 1;

Mutants (Total: 1, Killed: 1, Survived: 0)

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: $point = shift; 288: $params = Params::Get::get_params(undef, \@_) || {}; 289: } elsif(defined($_[0]) && !ref($_[0]) && scalar(@_) % 2 != 0) {

Mutants (Total: 1, Killed: 1, Survived: 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: $point = shift; 293: $params = Params::Get::get_params(undef, \@_) || {}; 294: } else { 295: $params = Params::Get::get_params('point', @_); 296: $point = $params->{'point'}; 297: } 298: 299 → 301 → 314 299: my ($lat, $lon); 300: 301: if(ref($point) eq 'ARRAY') {

Mutants (Total: 1, Killed: 1, Survived: 0)

302: return 0 if scalar(@{$point}) != 2;

Mutants (Total: 3, Killed: 3, Survived: 0)

303: ($lat, $lon) = @{$point}; 304: } elsif(!ref($point)) { 305: ($lat, $lon) = $self->_fetch_coordinates($point); 306: } elsif($point->can('latitude')) { 307: ($lat, $lon) = ($point->latitude(), $point->longitude()); 308: } else { 309: my $msg = 'add_marker(): unknown point type: ' . ref($point); 310: $self->{logger}->error($msg) if $self->{logger}; 311: croak $msg; 312: } 313: 314: return 0 unless defined($lat) && defined($lon);

Mutants (Total: 2, Killed: 2, Survived: 0)

315: return 0 unless _validate($lat, $lon);

Mutants (Total: 2, Killed: 2, Survived: 0)

316: 317: push @{$self->{coordinates}}, [$lat, $lon, $params->{'html'}, $params->{'icon'}]; 318: return 1;

Mutants (Total: 2, Killed: 2, Survived: 0)

319: } 320: 321: =head2 add_geojson 322: 323: Add a GeoJSON layer to the map. 324: 325: $map->add_geojson(\%data, style => { color => '#ff0000' }, popup => 'name'); 326: 327: The first argument may be a hashref/arrayref (GeoJSON structure) or a JSON string. 328: Returns 1 on success. 329: 330: =head3 API SPECIFICATION 331: 332: =head4 INPUT 333: 334: data : hashref | arrayref | string (JSON) 335: style : hashref Leaflet path-style options (color, weight, fillColor, fillOpacity) 336: popup : string Feature property name whose value becomes the popup text 337: 338: =head4 OUTPUT 339: 340: { type => integer, value => 1 } 341: 342: =head3 MESSAGES 343: 344: | Message | Meaning / Resolution | 345: |----------------------|---------------------------------| 346: | (JSON parse error) | data string is not valid JSON | 347: 348: =head3 EXAMPLES 349: 350: # Pre-parsed GeoJSON structure with style and popup property 351: $map->add_geojson( 352: { type => 'FeatureCollection', features => \@features }, 353: style => { color => '#ff0000', weight => 2, fillOpacity => 0.4 }, 354: popup => 'name', 355: ); 356: 357: # Raw JSON string — decoded automatically 358: $map->add_geojson('{"type":"FeatureCollection","features":[]}'); 359: 360: # Multiple GeoJSON layers with different styles on the same map 361: $map->add_geojson(\%country_borders, style => { color => '#333333', fillOpacity => 0 }); 362: $map->add_geojson(\%river_data, style => { color => '#0099ff', weight => 1 }); 363: 364: =cut 365: 366: sub add_geojson 367: { 368: my $self = shift; 369: my $data = shift; 370: my $params = Params::Get::get_params(undef, \@_) || {}; 371: 372: # Accept either a pre-parsed structure or a raw JSON string 373: $data = decode_json($data) if !ref($data); 374: 375: push @{$self->{geojson}}, { data => $data, opts => $params }; 376: return 1;

Mutants (Total: 2, Killed: 2, Survived: 0)

377: } 378: 379: =head2 add_heatmap 380: 381: Add a heatmap layer to the map. 382: 383: $map->add_heatmap([[51.5, -0.1, 0.8], [51.6, -0.2, 0.5]], radius => 25); 384: 385: Each point is C<[$lat, $lon]> or C<[$lat, $lon, $intensity]> (intensity: 0-1). 386: Requires the Leaflet.heat plugin (C<heatmap_js_url>). 387: Returns 1 on success. 388: 389: =head3 API SPECIFICATION 390: 391: =head4 INPUT 392: 393: points : arrayref of ([lat, lon] | [lat, lon, intensity]) 394: radius : integer default 25 395: blur : integer default 15 396: 397: =head4 OUTPUT 398: 399: { type => integer, value => 1 } 400: 401: =head3 MESSAGES 402: 403: | Message | Meaning / Resolution | 404: |--------------------------------------|-----------------------------------| 405: | add_heatmap: points must be arrayref | First argument is not an arrayref | 406: 407: =head3 EXAMPLES 408: 409: # Basic heatmap — [lat, lon] per point 410: $map->add_heatmap([ 411: [51.5074, -0.1278], 412: [51.6000, -0.2000], 413: [51.4000, 0.0000], 414: ]); 415: 416: # With intensity values (0..1) and custom radius/blur 417: $map->add_heatmap( 418: [ [51.5, -0.1, 0.9], [51.6, -0.2, 0.5], [51.4, 0.0, 0.2] ], 419: radius => 30, 420: blur => 20, 421: ); 422: 423: =cut 424: 425: sub add_heatmap 426: { 427: my $self = shift; 428: my $points = shift; 429: my $params = Params::Get::get_params(undef, \@_) || {}; 430: 431: croak 'add_heatmap: points must be an arrayref' unless ref($points) eq 'ARRAY'; 432: 433: push @{$self->{heatmap_layers}}, { points => $points, opts => $params }; 434: return 1;

Mutants (Total: 2, Killed: 2, Survived: 0)

435: } 436: 437: =head2 add_gpx 438: 439: Add a GPX track to the map from a URL. 440: 441: $map->add_gpx('https://example.com/track.gpx'); 442: 443: The map view is auto-fitted to the track bounds after loading. 444: Requires the leaflet-gpx plugin (C<gpx_js_url>). 445: Returns 1 on success. 446: 447: =head3 API SPECIFICATION 448: 449: =head4 INPUT 450: 451: url : string URL of the GPX file (required) 452: 453: =head4 OUTPUT 454: 455: { type => integer, value => 1 } 456: 457: =head3 MESSAGES 458: 459: | Message | Meaning / Resolution | 460: |----------------------|----------------------------| 461: | add_gpx: url required | No URL argument supplied | 462: 463: =head3 EXAMPLES 464: 465: # Add a GPX track from a public URL; the map auto-fits to its bounds 466: $map->add_gpx('https://example.com/route.gpx'); 467: 468: # Multiple tracks on the same map 469: $map->add_gpx('https://example.com/morning-run.gpx'); 470: $map->add_gpx('https://example.com/evening-walk.gpx'); 471: 472: =cut 473: 474: sub add_gpx 475: { 476: my $self = shift; 477: my $params = Params::Get::get_params('url', \@_); 478: my $url = $params->{'url'}; 479: 480: croak 'add_gpx: url is required' unless $url; 481: 482: push @{$self->{gpx_tracks}}, $url; 483: return 1;

Mutants (Total: 2, Killed: 2, Survived: 0)

484: } 485: 486: =head2 add_choropleth 487: 488: Add a choropleth (data-driven colour fill) layer to the map. 489: 490: $map->add_choropleth( 491: \@geojson_features, 492: { England => 100, Scotland => 80, Wales => 60 }, 493: key => 'name', 494: scale => ['#ffffcc', '#a1dab4', '#41b6c4', '#2c7fb8', '#253494'], 495: ); 496: 497: Colours are pre-computed in Perl and baked into the emitted JavaScript. 498: No extra browser plugin is required. 499: Returns 1 on success. 500: 501: =head3 API SPECIFICATION 502: 503: =head4 INPUT 504: 505: features : arrayref of GeoJSON Feature hashrefs (required) 506: values : hashref { feature_property_value => numeric_value } (required) 507: key : string feature property to match against values (default: 'name') 508: scale : arrayref hex-colour strings low-to-high (default: 5-step YlGnBu) 509: 510: =head4 OUTPUT 511: 512: { type => integer, value => 1 } 513: 514: =head3 MESSAGES 515: 516: | Message | Meaning / Resolution | 517: |------------------------------------------|----------------------------------------| 518: | add_choropleth: features must be arrayref | First argument is not an arrayref | 519: | add_choropleth: values must be hashref | Second argument is not a hashref | 520: 521: =head3 EXAMPLES 522: 523: # Default 5-step YlGnBu scale, key property "name" 524: $map->add_choropleth( 525: \@geojson_features, 526: { England => 100, Scotland => 80, Wales => 60 }, 527: ); 528: 529: # Custom 3-step scale and a different GeoJSON property as the key 530: $map->add_choropleth( 531: \@geojson_features, 532: { England => 100, Scotland => 80, Wales => 60 }, 533: key => 'country', 534: scale => ['#f7fbff', '#6baed6', '#08519c'], 535: ); 536: 537: # choropleth-only map — center() must be called explicitly 538: $map->center([54.0, -2.0]); 539: $map->add_choropleth(\@uk_features, \%population_by_region); 540: my ($head, $body) = $map->onload_render(); 541: 542: =cut 543: 544: sub add_choropleth 545: { 546 → 564 → 570 546: my $self = shift; 547: my $features = shift; 548: my $values = shift; 549: my $params = Params::Get::get_params(undef, \@_) || {}; 550: 551: croak 'add_choropleth: features must be an arrayref' unless ref($features) eq 'ARRAY'; 552: croak 'add_choropleth: values must be a hashref' unless ref($values) eq 'HASH'; 553: 554: my $key = $params->{key} || 'name'; 555: 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: my @sorted_vals = sort { $a <=> $b } values %{$values}; 560: my ($min, $max) = ($sorted_vals[0] // 0, $sorted_vals[-1] // 0); 561: $max = $min + 1 if $max == $min; # avoid division by zero for single-value sets

Mutants (Total: 1, Killed: 1, Survived: 0)

562: 563: my %colors; 564: while(my ($k, $v) = each %{$values}) { 565: my $idx = int(($v - $min) / ($max - $min) * $#{$scale}); 566: $idx = $#{$scale} if $idx > $#{$scale};

Mutants (Total: 3, Killed: 3, Survived: 0)

567: $colors{$k} = $scale->[$idx]; 568: } 569: 570: push @{$self->{choropleth_layers}}, { 571: features => $features, 572: values => $values, 573: colors => \%colors, 574: key => $key, 575: }; 576: return 1;

Mutants (Total: 2, Killed: 2, Survived: 0)

577: } 578: 579: =head2 center 580: 581: Set the map centre to a given point. 582: 583: $map->center([40.7128, -74.0060]); 584: $map->center($geo_object); 585: $map->center('Berlin, Germany'); 586: 587: Returns 1 on success, 0 if the point cannot be resolved. 588: 589: =head3 API SPECIFICATION 590: 591: =head4 INPUT 592: 593: point : arrayref [lat, lon] | object with latitude()/longitude() | string address 594: 595: =head4 OUTPUT 596: 597: { type => integer, enum => [0, 1] } 598: 599: =head3 MESSAGES 600: 601: | Message | Meaning / Resolution | 602: |------------------------------------------------|------------------------------------------| 603: | center(): usage: point => [lat, lon] | No point argument supplied | 604: | center(): point must have latitude & longitude | Arrayref has != 2 elements | 605: | center(): unknown point type | Ref type has no lat/lon methods | 606: 607: =head3 EXAMPLES 608: 609: # Coordinate array 610: $map->center([40.7128, -74.0060]); 611: 612: # Object that implements latitude()/longitude() (e.g. a Geo::Coder result) 613: $map->center($geocoder->geocode('Berlin, Germany')); 614: 615: # String address resolved via the injected geocoder or Nominatim 616: $map->center('Eiffel Tower, Paris, France'); 617: 618: # Required when rendering without point markers (GeoJSON-only, choropleth, etc.) 619: $map->center([54.0, -2.0]); 620: $map->add_geojson(\%uk_regions, popup => 'name'); 621: my ($head, $body) = $map->onload_render(); 622: 623: =cut 624: 625: sub center 626: { 627 → 635 → 649 627: my $self = shift; 628: my $params = Params::Get::get_params('point', \@_); 629: my $point = $params->{'point'}; 630: 631: croak 'center(): usage: point => [ latitude, longitude ]' unless defined($point); 632: 633: my ($lat, $lon); 634: 635: if(ref($point) eq 'ARRAY') {

Mutants (Total: 1, Killed: 1, Survived: 0)

636: croak 'center(): point must have latitude and longitude' 637: if scalar(@{$point}) != 2;

Mutants (Total: 1, Killed: 1, Survived: 0)

638: ($lat, $lon) = @{$point}; 639: } elsif(ref($point) && $point->can('latitude')) { 640: ($lat, $lon) = ($point->latitude(), $point->longitude()); 641: } elsif(!ref($point)) { 642: ($lat, $lon) = $self->_fetch_coordinates($point); 643: } else { 644: my $msg = 'center(): unknown point type: ' . ref($point); 645: $self->{logger}->error($msg) if $self->{logger}; 646: croak $msg; 647: } 648: 649: return 0 unless defined($lat) && defined($lon);

Mutants (Total: 2, Killed: 2, Survived: 0)

650: return 0 unless _validate($lat, $lon);

Mutants (Total: 2, Killed: 2, Survived: 0)

651: 652: $self->{'center'} = [$lat, $lon]; 653: return 1;

Mutants (Total: 2, Killed: 2, Survived: 0)

654: } 655: 656: =head2 zoom 657: 658: Get or set the zoom level (0 = world, 19 = building). 659: 660: $map->zoom(10); 661: my $z = $map->zoom(); 662: 663: =head3 API SPECIFICATION 664: 665: =head4 INPUT 666: 667: { zoom => { type => integer, min => 0, max => 19, optional => 1 } } 668: 669: =head4 OUTPUT 670: 671: { type => integer, min => 0, max => 19 } 672: 673: =head3 MESSAGES 674: 675: | Message | Meaning / Resolution | 676: |------------------------------|-------------------------------------------| 677: | (Params::Validate::Strict) | zoom is not an integer or is out of range | 678: 679: =head3 EXAMPLES 680: 681: # Setter: store the zoom level 682: $map->zoom(14); 683: 684: # Getter: retrieve the current level 685: my $level = $map->zoom(); 686: print "Current zoom: $level\n"; # 14 687: 688: # Setter return value equals the new level 689: my $confirmed = $map->zoom(10); 690: die 'unexpected' unless $confirmed == 10; 691: 692: # Chain: set via new(), read back via zoom() 693: my $m = HTML::OSM->new(zoom => 6); 694: $m->zoom($m->zoom() + 2); # nudge up by 2 695: 696: =cut 697: 698: sub zoom 699: { 700 → 702 → 712 700: my $self = shift; 701: 702: if(scalar(@_)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

703: 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: $self->{'zoom'} = $params->{'zoom'} if defined($params->{'zoom'}); 710: } 711: 712: return $self->{'zoom'};

Mutants (Total: 2, Killed: 2, Survived: 0)

713: } 714: 715: =head2 onload_render 716: 717: Render the map and return a two-element list suitable for embedding in HTML. 718: 719: my ($head_html, $body_html) = $map->onload_render(); 720: 721: C<$head_html> contains the Leaflet CSS, JavaScript, and plugin assets. 722: Place it inside C<< <head>...</head> >>. 723: 724: C<$body_html> contains the search box, control buttons, map C<< <div> >>, 725: and the initialisation C<< <script> >>. 726: Place it inside C<< <body>...</body> >> where the map should appear. 727: 728: The rendered page provides: 729: 730: =over 4 731: 732: =item * A Nominatim-powered search box that adds temporary markers. 733: 734: =item * A "Clear search markers" button that removes those temporary markers, 735: leaving static markers (added via C<add_marker>) intact. 736: 737: =item * A "Reset Map" button that returns the view to the initial centre and zoom. 738: 739: =back 740: 741: =head3 API SPECIFICATION 742: 743: =head4 INPUT 744: 745: (none - uses object state) 746: 747: =head4 OUTPUT 748: 749: { type => list, elements => [string, string] } 750: 751: =head3 MESSAGES 752: 753: | Message | Meaning / Resolution | 754: |--------------------------------------------------|---------------------------------------------| 755: | No map data provided | No markers, GeoJSON, heatmap, GPX, or choropleth added yet | 756: | center() must be called when no point markers | Non-marker-only render needs explicit centre | 757: 758: =head3 EXAMPLES 759: 760: # Minimal: one marker, embed in a CGI response 761: use HTML::OSM; 762: my $map = HTML::OSM->new(zoom => 12); 763: $map->add_marker([51.5074, -0.1278], html => 'London'); 764: my ($head, $body) = $map->onload_render(); 765: print "Content-Type: text/html\n\n"; 766: print "<html><head>$head</head><body>$body</body></html>\n"; 767: 768: # Mixed layers: markers + GeoJSON, explicit center 769: $map->center([51.5, -0.1]); 770: $map->add_geojson(\%borough_data, popup => 'name', style => { color => '#333' }); 771: $map->add_marker([51.5074, -0.1278], html => 'City of London'); 772: my ($head_html, $body_html) = $map->onload_render(); 773: 774: # Template Toolkit integration 775: $tt->process('map.tt', { 776: map_head => scalar(($map->onload_render())[0]), 777: map_body => scalar(($map->onload_render())[1]), 778: }); 779: 780: =head3 PSEUDOCODE 781: 782: 1. Gather all data layers; croak if none populated. 783: 2. Geocode/validate each coordinate tuple; discard invalids with a warning. 784: 3. Determine map centre: caller-supplied > computed midpoint of marker bounds. 785: Croak if neither is available. 786: 4. Build <head>: Leaflet CSS + JS; inject cluster/heatmap/GPX plugin assets 787: only when the corresponding layer type is present. 788: 5. Build <body>: search box, clear-search button, reset button, map <div>. 789: 6. Initialise Leaflet map, tile layer, searchMarkers array. 790: 7. Emit JS for each marker (via clusterGroup when cluster is set). 791: 8. Emit JS for each GeoJSON, heatmap, GPX, and choropleth layer. 792: 9. Attach event listeners: reset-view, clear-search-markers, search-on-Enter. 793: 10. Return ($head, $body). 794: 795: =cut 796: 797: sub onload_render 798: { 799 → 809 → 816 799: my $self = shift; 800: 801: my $height = $self->{'height'} || '400px'; 802: my $width = $self->{'width'} || '600px'; 803: my $coordinates = $self->{coordinates} || []; 804: my $geojson_layers = $self->{geojson} || []; 805: my $heatmap_layers = $self->{heatmap_layers} || []; 806: my $gpx_tracks = $self->{gpx_tracks} || []; 807: my $choropleth_layers = $self->{choropleth_layers} || []; 808: 809: unless(@$coordinates || @$geojson_layers || @$heatmap_layers

Mutants (Total: 1, Killed: 1, Survived: 0)

810: || @$gpx_tracks || @$choropleth_layers) { 811: $self->{logger}->error('No map data provided') if $self->{logger}; 812: croak 'No map data provided'; 813: } 814: 815: # Geocode address strings; validate and discard bad numeric pairs. 816 → 817 → 830 816: my @valid_coordinates; 817: for my $coord (@$coordinates) { 818: my ($lat, $lon, $label, $icon_url) = @$coord; 819: if(!defined $lat || !defined $lon) {

Mutants (Total: 1, Killed: 1, Survived: 0)

820: ($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: next unless defined($lat) && defined($lon) && _validate($lat, $lon); 826: push @valid_coordinates, [$lat, $lon, $label, $icon_url]; 827: } 828: 829: # Determine map centre: caller-set wins; else compute from marker bounds. 830 → 831 → 848 830: my ($center_lat, $center_lon); 831: if($self->{'center'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

832: ($center_lat, $center_lon) = @{$self->{'center'}}; 833: } elsif(@valid_coordinates) { 834: my ($min_lat, $min_lon, $max_lat, $max_lon) = (90, 180, -90, -180); 835: for my $c (@valid_coordinates) { 836: $min_lat = $c->[0] if $c->[0] < $min_lat;

Mutants (Total: 3, Killed: 3, Survived: 0)

837: $max_lat = $c->[0] if $c->[0] > $max_lat;

Mutants (Total: 3, Killed: 3, Survived: 0)

838: $min_lon = $c->[1] if $c->[1] < $min_lon;

Mutants (Total: 3, Killed: 3, Survived: 0)

839: $max_lon = $c->[1] if $c->[1] > $max_lon;

Mutants (Total: 3, Killed: 3, Survived: 0)

840: } 841: $center_lat = ($min_lat + $max_lat) / 2; 842: $center_lon = ($min_lon + $max_lon) / 2; 843: } else { 844: croak 'center() must be called when no point markers are provided'; 845: } 846: 847: # --- <head> --- 848 → 853 → 860 848: my $head = qq{ 849: <link rel="stylesheet" href="$self->{css_url}" /> 850: <script src="$self->{js_url}"></script> 851: }; 852: 853: if($self->{cluster}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

854: $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 → 887 → 916 860: $head .= qq{\t\t<script src="$self->{heatmap_js_url}"></script>\n} if @$heatmap_layers; 861: $head .= qq{\t\t<script src="$self->{gpx_js_url}"></script>\n} if @$gpx_tracks; 862: 863: $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: 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: '&copy; OpenStreetMap contributors' 881: }).addTo(map); 882: 883: var searchMarkers = []; 884: }; 885: 886: # Point markers — optionally grouped into a cluster layer. 887: if(@valid_coordinates) {

Mutants (Total: 1, Killed: 1, Survived: 0)

888: $body .= "\t\t\tvar clusterGroup = L.markerClusterGroup();\n" if $self->{cluster}; 889: 890: for my $coord (@valid_coordinates) { 891: my ($lat, $lon, $label, $icon_url) = @$coord; 892: my $js_label = _js_string($label); 893: if($icon_url) {

Mutants (Total: 1, Killed: 1, Survived: 0)

894: my $js_icon = _js_string($icon_url); 895: my $add_cmd = $self->{cluster} 896: ? 'clusterGroup.addLayer(m);' 897: : 'm.addTo(map);'; 898: $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: $body .= "\t\t\tclusterGroup.addLayer(L.marker([$lat, $lon]).bindPopup('$js_label'));\n"; 907: } else { 908: $body .= "\t\t\tL.marker([$lat, $lon]).addTo(map).bindPopup('$js_label');\n"; 909: } 910: } 911: 912: $body .= "\t\t\tmap.addLayer(clusterGroup);\n" if $self->{cluster}; 913: } 914: 915: # GeoJSON layers. 916 → 916 → 932 916: for my $layer (@$geojson_layers) { 917: my $json = _html_json($layer->{data}); 918: my $opts = $layer->{opts} || {}; 919: my $style_js = ''; 920: my $popup_js = ''; 921: if(my $style = $opts->{style}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

922: $style_js = 'style: ' . _html_json($style) . ','; 923: } 924: if(my $prop = $opts->{popup}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

925: my $js_prop = _js_string($prop); 926: $popup_js = "onEachFeature: function(f,l){ if(f.properties && f.properties['$js_prop']){ l.bindPopup(String(f.properties['$js_prop'])); } },"; 927: } 928: $body .= "\t\t\tL.geoJSON($json, { $style_js $popup_js }).addTo(map);\n"; 929: } 930: 931: # Heatmap layers. 932 → 932 → 941 932: for my $layer (@$heatmap_layers) { 933: my $pts = _html_json($layer->{points}); 934: my $opts = $layer->{opts} || {}; 935: my $radius = $opts->{radius} || 25; 936: my $blur = $opts->{blur} || 15; 937: $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 → 941 → 947 941: for my $url (@$gpx_tracks) { 942: my $js_url = _js_string($url); 943: $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 → 947 → 975 947: for my $layer (@$choropleth_layers) { 948: my $fc_json = _html_json({ type => 'FeatureCollection', features => $layer->{features} }); 949: my $colors_json = _html_json($layer->{colors}); 950: my $values_json = _html_json($layer->{values}); 951: my $js_key = _js_string($layer->{key}); 952: $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: $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: 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 → 1025 → 1047 1020: my ($self, $location) = @_; 1021: 1022: croak 'address not given to _fetch_coordinates' unless $location; 1023: 1024: # Prefer an injected geocoder (e.g. Geo::Coder::List) to avoid HTTP. 1025: if(my $geocoder = $self->{'geocoder'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1026: my $rc = $geocoder->geocode($location); 1027: return (undef, undef) unless defined $rc; 1028: 1029: if(blessed($rc) && $rc->can('latitude')) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1030: return ($rc->latitude(), $rc->longitude()); 1031: } 1032: if(ref($rc) eq 'HASH') {

Mutants (Total: 1, Killed: 1, Survived: 0)

1033: return ($rc->{lat}, $rc->{lon}) 1034: if defined($rc->{lat}) && defined($rc->{lon}); 1035: return ($rc->{geometry}{location}{lat}, $rc->{geometry}{location}{lng}) 1036: if defined($rc->{geometry}{location}{lat}); 1037: } 1038: # Some geocoders return a flat [lat, lon] arrayref 1039: return @{$rc} if ref($rc) eq 'ARRAY';

Mutants (Total: 2, Killed: 2, Survived: 0)

1040: 1041: # Unrecognised return type — treat as failure 1042: carp '_fetch_coordinates: unrecognised geocoder result type: ' . ref($rc); 1043: return (undef, undef); 1044: } 1045: 1046: # Direct Nominatim path: apply caching and rate-limiting. 1047 → 1048 → 1053 1047: my $cache_key = 'osm:' . uri_escape_utf8($location); 1048: if(my $cached = $self->{cache}->get($cache_key)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1049: return ($cached->{lat}, $cached->{lon}); 1050: } 1051: 1052: # Honour the minimum interval between outbound requests. 1053 → 1054 → 1058 1053: my $elapsed = time() - $self->{last_request}; 1054: if($elapsed < $self->{min_interval}) {

Mutants (Total: 4, Killed: 4, Survived: 0)

1055: Time::HiRes::sleep($self->{min_interval} - $elapsed); 1056: } 1057: 1058 → 1067 → 1083 1058: my $ua = $self->{'ua'} 1059: || LWP::UserAgent->new(agent => __PACKAGE__ . "/$VERSION"); 1060: $ua->default_header(accept_encoding => 'gzip,deflate'); 1061: $ua->env_proxy(1); 1062: 1063: my $url = 'https://' . $self->{'host'} . '?format=json&q=' . uri_escape_utf8($location); 1064: my $response = $ua->get($url); 1065: $self->{'last_request'} = time(); 1066: 1067: if($response->is_success()) {

Mutants (Total: 1, Killed: 1, Survived: 0)

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: my $data = eval { decode_json($response->decoded_content()) }; 1072: if($@) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1073: carp "_fetch_coordinates: failed to decode Nominatim response: $@"; 1074: return (undef, undef); 1075: } 1076: $data = $data->[0] if ref($data) eq 'ARRAY'; 1077: if(ref($data) eq 'HASH' && defined($data->{lat})) {

Mutants (Total: 1, Killed: 1, Survived: 0)

1078: $self->{'cache'}->set($cache_key, $data); 1079: return ($data->{lat}, $data->{lon}); 1080: } 1081: } 1082: 1083: 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: 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: my $numeric = qr/^-?(?:\d+(?:\.\d+)?|\.\d+)\z/; 1101: 1102: my $ok = defined($lat) && defined($lon) 1103: && $lat =~ $numeric && $lon =~ $numeric 1104: && $lat >= $LAT_MIN && $lat <= $LAT_MAX

Mutants (Total: 6, Killed: 6, Survived: 0)

1105: && $lon >= $LON_MIN && $lon <= $LON_MAX;

Mutants (Total: 6, Killed: 6, Survived: 0)

1106: 1107: carp(sprintf 'Skipping invalid coordinate: (%s, %s)', 1108: $lat // 'undef', $lon // 'undef') 1109: if !$ok && defined($lat) && defined($lon); 1110: 1111: return $ok ? 1 : 0;

Mutants (Total: 2, Killed: 2, Survived: 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: my $j = encode_json(shift); 1123: $j =~ s|</|<\\/|g; 1124: return $j;

Mutants (Total: 2, Killed: 2, Survived: 0)

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: my $s = shift // ''; 1134: $s =~ s/\\/\\\\/g; # escape backslash first to avoid double-escaping 1135: $s =~ s/'/\\'/g; # escape our JS string delimiter 1136: $s =~ s/\r?\n/\\n/g; # newlines would break the JS string 1137: $s =~ s|</|<\\/|g; # prevent </script> injection 1138: return $s;

Mutants (Total: 2, Killed: 2, Survived: 0)

1139: } 1140: 1141: =head1 LIMITATIONS 1142: 1143: =over 4 1144: 1145: =item * B<Per-marker removal>: Markers added via C<add_marker()> cannot yet be 1146: removed individually by clicking them. The "Clear search markers" button only 1147: removes markers added by the in-page Nominatim search box. 1148: 1149: =item * B<Clone validation>: The clone path (C<< $obj->new(%overrides) >>) 1150: bypasses the Params::Validate::Strict schema so subclasses and internal callers 1151: can merge arbitrary state. Callers are responsible for passing valid overrides. 1152: 1153: =item * B<Config-file params unvalidated>: Keys injected by 1154: L<Object::Configure> from a config file are not re-run through the schema, so 1155: a malformed config file can introduce invalid types at runtime. 1156: 1157: =item * B<Private-method encapsulation>: C<_fetch_coordinates>, C<_validate>, 1158: and C<_js_string> are named with a leading underscore by convention only. 1159: Using L<Sub::Private> in C<enforce> mode would make the contract explicit, but 1160: that module is not yet listed as a dependency to avoid breaking white-box tests 1161: in C<t/mock.t>. 1162: 1163: =item * B<Routing>: Turn-by-turn routing (Leaflet Routing Machine / OSRM) is 1164: explicitly out of scope for this module and will not be added here. 1165: 1166: =back 1167: 1168: =head1 AUTHOR 1169: 1170: Nigel Horne, C<< <njh at nigelhorne.com> >> 1171: 1172: =head1 BUGS 1173: 1174: Please report bugs at L<https://github.com/nigelhorne/HTML-OSM/issues>. 1175: 1176: =head1 SEE ALSO 1177: 1178: =over 4 1179: 1180: =item * L<https://wiki.openstreetmap.org/wiki/API> 1181: 1182: =item * L<HTML::GoogleMaps::V3> - the interface this module mirrors for compatibility. 1183: 1184: =item * L<https://leafletjs.com/> 1185: 1186: =item * L<Configure an Object at Runtime|Object::Configure> 1187: 1188: =item * L<Test Dashboard|https://nigelhorne.github.io/HTML-OSM/coverage/> 1189: 1190: =back 1191: 1192: =head1 SUPPORT 1193: 1194: This module is provided as-is without any warranty. 1195: 1196: L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=HTML-OSM> 1197: 1198: =head2 TODO 1199: 1200: Allow per-marker removal via clicking on a marker. 1201: 1202: =encoding utf-8 1203: 1204: =head1 FORMAL SPECIFICATION 1205: 1206: =head2 new 1207: 1208: HTML_OSM 1209: coordinates : iseq (ℝ x ℝ x S x S) 1210: zoom : Z 1211: cluster : B 1212: ---------------------------------------- 1213: ZOOM_MIN <= zoom <= ZOOM_MAX 1214: 1215: new ≙ 1216: params? : Params 1217: osm! : HTML_OSM 1218: ---------------------------------------- 1219: osm!.zoom = params?.zoom ∨ 12 1220: osm!.cluster = params?.cluster ∨ false 1221: 1222: =head2 add_marker 1223: 1224: AddMarker 1225: ΔHTML_OSM 1226: point? : (ℝ x ℝ) ∪ S ∪ GeoObject 1227: result! : {0, 1} 1228: ----------------------------------------- 1229: result! = 1 ⟺ point? resolves to (lat, lon) ∈ ValidCoord 1230: result! = 1 ⟹ coordinates' = coordinates ⌢ ⟨(lat, lon, label, icon)⟩ 1231: 1232: =head2 add_geojson 1233: 1234: AddGeoJSON 1235: ΔHTML_OSM 1236: data? : GeoJSONStruct ∪ S 1237: style? : StyleMap ∪ {∅} 1238: popup? : S ∪ {∅} 1239: ----------------------------------------- 1240: geojson' = geojson ⌢ ⟨{data, style, popup}⟩ 1241: 1242: =head2 add_heatmap 1243: 1244: AddHeatmap 1245: ΔHTML_OSM 1246: points? : iseq (ℝ x ℝ x [0,1]) 1247: ----------------------------------------- 1248: heatmap_layers' = heatmap_layers ⌢ ⟨{points, radius, blur}⟩ 1249: 1250: =head2 add_gpx 1251: 1252: AddGPX 1253: ΔHTML_OSM 1254: url? : S | url? ≠ '' 1255: ----------------------------------------- 1256: gpx_tracks' = gpx_tracks ⌢ ⟨url?⟩ 1257: 1258: =head2 add_chropleth 1259: 1260: AddChoropleth 1261: ΔHTML_OSM 1262: features? : iseq GeoFeature 1263: values? : S --> ℝ 1264: key? : S 1265: scale? : iseq S 1266: ----------------------------------------- 1267: Let min = min(ran values?), max = max(ran values?) ∪ {min+1} 1268: ∀ k ∈ dom values? • 1269: color(k) = scale?[floor((values?(k)-min)/(max-min) * (#scale?-1))] 1270: choropleth_layers' = choropleth_layers ⌢ ⟨{features, values, colors, key}⟩ 1271: 1272: =head2 center 1273: 1274: Center 1275: ΔHTML_OSM 1276: point? : (ℝ x ℝ) ∪ S ∪ GeoObject 1277: result! : {0, 1} 1278: ----------------------------------------- 1279: result! = 1 ⟺ point? resolves to (lat, lon) ∈ ValidCoord 1280: result! = 1 ⟹ center' = (lat, lon) 1281: 1282: =head2 zoom 1283: 1284: Zoom 1285: ΔHTML_OSM 1286: zoom? : Z ∪ {∅} 1287: zoom! : Z 1288: ----------------------------------------- 1289: zoom? ≠ ∅ ⟹ ZOOM_MIN <= zoom? <= ZOOM_MAX 1290: zoom! = (zoom? ≠ ∅ ∧ zoom' = zoom?) ∨ zoom 1291: 1292: =head2 onload_render 1293: 1294: OnloadRender 1295: HTML_OSM 1296: head! : S 1297: body! : S 1298: ----------------------------------------- 1299: (#coordinates + #geojson + #heatmap_layers + #gpx_tracks + #choropleth_layers) > 0 1300: center ≠ ∅ ∨ ∃ valid ∈ coordinates • valid ∈ ValidCoord 1301: 1302: =head1 LICENSE AND COPYRIGHT 1303: 1304: Copyright 2025-2026 Nigel Horne. 1305: 1306: This program is released under the following licence: GPL2 1307: If you use it, 1308: please let me know. 1309: 1310: =cut 1311: 1312: 1; 1313: 1314: __END__