TER1 (Statement): 100.00%
TER2 (Branch): 97.01%
TER3 (LCSAJ): 100.0% (20/20)
Approximate LCSAJ segments: 135
● 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.
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: '© 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__