HTML-OSM

 view release on metacpan or  search on metacpan

README.md  view on Meta::CPAN

    $map->add_marker('Paris, France',    html => 'Paris');
    $map->add_marker($geo_coder_result);

Returns 1 on success, 0 if the point cannot be resolved or is out of range.

### API SPECIFICATION

#### INPUT

    point : arrayref [lat, lon] | string address | object with latitude()/longitude()
    html  : string   (optional popup label)
    icon  : string   (optional icon URL)

#### OUTPUT

    { type => integer, enum => [0, 1] }

### MESSAGES

    | Message                              | Meaning / Resolution                        |
    |--------------------------------------|---------------------------------------------|
    | add_marker(): unknown point type     | Point is a ref type with no lat/lon methods |

### EXAMPLES

    # Coordinate array with a popup label
    $map->add_marker([51.5074, -0.1278], html => 'London');

    # String address geocoded via the injected geocoder or Nominatim
    $map->add_marker('Paris, France', html => 'Paris');

    # Custom icon URL with a popup
    $map->add_marker(
        [40.7128, -74.0060],
        html => 'New York',
        icon => 'https://example.com/pin.png',
    );

    # Geo::Coder result object that implements latitude()/longitude()
    my $result = $geocoder->geocode('Berlin, Germany');
    $map->add_marker($result, html => 'Berlin');

    # Accumulate several markers, warn on geocode failure
    for my $city (@cities) {
        $map->add_marker($city->{coords}, html => $city->{name})
            or warn "Could not place $city->{name}";
    }

## add\_geojson

Add a GeoJSON layer to the map.

    $map->add_geojson(\%data, style => { color => '#ff0000' }, popup => 'name');

The first argument may be a hashref/arrayref (GeoJSON structure) or a JSON string.
Returns 1 on success.

### API SPECIFICATION

#### INPUT

    data  : hashref | arrayref | string (JSON)
    style : hashref   Leaflet path-style options (color, weight, fillColor, fillOpacity)
    popup : string    Feature property name whose value becomes the popup text

#### OUTPUT

    { type => integer, value => 1 }

### MESSAGES

    | Message              | Meaning / Resolution            |
    |----------------------|---------------------------------|
    | (JSON parse error)   | data string is not valid JSON   |

### EXAMPLES

    # Pre-parsed GeoJSON structure with style and popup property
    $map->add_geojson(
        { type => 'FeatureCollection', features => \@features },
        style => { color => '#ff0000', weight => 2, fillOpacity => 0.4 },
        popup => 'name',
    );

    # Raw JSON string — decoded automatically
    $map->add_geojson('{"type":"FeatureCollection","features":[]}');

    # Multiple GeoJSON layers with different styles on the same map
    $map->add_geojson(\%country_borders, style => { color => '#333333', fillOpacity => 0 });
    $map->add_geojson(\%river_data,      style => { color => '#0099ff', weight => 1    });

## add\_heatmap

README.md  view on Meta::CPAN

    $map->center([40.7128, -74.0060]);

    # Object that implements latitude()/longitude() (e.g. a Geo::Coder result)
    $map->center($geocoder->geocode('Berlin, Germany'));

    # String address resolved via the injected geocoder or Nominatim
    $map->center('Eiffel Tower, Paris, France');

    # Required when rendering without point markers (GeoJSON-only, choropleth, etc.)
    $map->center([54.0, -2.0]);
    $map->add_geojson(\%uk_regions, popup => 'name');
    my ($head, $body) = $map->onload_render();

## zoom

Get or set the zoom level (0 = world, 19 = building).

    $map->zoom(10);
    my $z = $map->zoom();

### API SPECIFICATION

README.md  view on Meta::CPAN

    # Minimal: one marker, embed in a CGI response
    use HTML::OSM;
    my $map = HTML::OSM->new(zoom => 12);
    $map->add_marker([51.5074, -0.1278], html => 'London');
    my ($head, $body) = $map->onload_render();
    print "Content-Type: text/html\n\n";
    print "<html><head>$head</head><body>$body</body></html>\n";

    # Mixed layers: markers + GeoJSON, explicit center
    $map->center([51.5, -0.1]);
    $map->add_geojson(\%borough_data, popup => 'name', style => { color => '#333' });
    $map->add_marker([51.5074, -0.1278], html => 'City of London');
    my ($head_html, $body_html) = $map->onload_render();

    # Template Toolkit integration
    $tt->process('map.tt', {
        map_head => scalar(($map->onload_render())[0]),
        map_body => scalar(($map->onload_render())[1]),
    });

### PSEUDOCODE

README.md  view on Meta::CPAN

    -----------------------------------------
      result! = 1 ⟺ point? resolves to (lat, lon) ∈ ValidCoord
      result! = 1 ⟹ coordinates' = coordinates ⌢ ⟨(lat, lon, label, icon)⟩

## add\_geojson

    AddGeoJSON
      ΔHTML_OSM
      data?  : GeoJSONStruct ∪ S
      style? : StyleMap ∪ {∅}
      popup? : S ∪ {∅}
    -----------------------------------------
      geojson' = geojson ⌢ ⟨{data, style, popup}⟩

## add\_heatmap

    AddHeatmap
      ΔHTML_OSM
      points? : iseq (ℝ x ℝ x [0,1])
    -----------------------------------------
      heatmap_layers' = heatmap_layers ⌢ ⟨{points, radius, blur}⟩

## add\_gpx

lib/HTML/OSM.pm  view on Meta::CPAN

    $map->add_marker('Paris, France',    html => 'Paris');
    $map->add_marker($geo_coder_result);

Returns 1 on success, 0 if the point cannot be resolved or is out of range.

=head3 API SPECIFICATION

=head4 INPUT

  point : arrayref [lat, lon] | string address | object with latitude()/longitude()
  html  : string   (optional popup label)
  icon  : string   (optional icon URL)

=head4 OUTPUT

  { type => integer, enum => [0, 1] }

=head3 MESSAGES

  | Message                              | Meaning / Resolution                        |
  |--------------------------------------|---------------------------------------------|
  | add_marker(): unknown point type     | Point is a ref type with no lat/lon methods |

=head3 EXAMPLES

    # Coordinate array with a popup label
    $map->add_marker([51.5074, -0.1278], html => 'London');

    # String address geocoded via the injected geocoder or Nominatim
    $map->add_marker('Paris, France', html => 'Paris');

    # Custom icon URL with a popup
    $map->add_marker(
        [40.7128, -74.0060],
        html => 'New York',
        icon => 'https://example.com/pin.png',
    );

    # Geo::Coder result object that implements latitude()/longitude()
    my $result = $geocoder->geocode('Berlin, Germany');
    $map->add_marker($result, html => 'Berlin');

lib/HTML/OSM.pm  view on Meta::CPAN

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

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

=head2 add_geojson

Add a GeoJSON layer to the map.

    $map->add_geojson(\%data, style => { color => '#ff0000' }, popup => 'name');

The first argument may be a hashref/arrayref (GeoJSON structure) or a JSON string.
Returns 1 on success.

=head3 API SPECIFICATION

=head4 INPUT

  data  : hashref | arrayref | string (JSON)
  style : hashref   Leaflet path-style options (color, weight, fillColor, fillOpacity)
  popup : string    Feature property name whose value becomes the popup text

=head4 OUTPUT

  { type => integer, value => 1 }

=head3 MESSAGES

  | Message              | Meaning / Resolution            |
  |----------------------|---------------------------------|
  | (JSON parse error)   | data string is not valid JSON   |

=head3 EXAMPLES

    # Pre-parsed GeoJSON structure with style and popup property
    $map->add_geojson(
        { type => 'FeatureCollection', features => \@features },
        style => { color => '#ff0000', weight => 2, fillOpacity => 0.4 },
        popup => 'name',
    );

    # Raw JSON string — decoded automatically
    $map->add_geojson('{"type":"FeatureCollection","features":[]}');

    # Multiple GeoJSON layers with different styles on the same map
    $map->add_geojson(\%country_borders, style => { color => '#333333', fillOpacity => 0 });
    $map->add_geojson(\%river_data,      style => { color => '#0099ff', weight => 1    });

=cut

lib/HTML/OSM.pm  view on Meta::CPAN

    $map->center([40.7128, -74.0060]);

    # Object that implements latitude()/longitude() (e.g. a Geo::Coder result)
    $map->center($geocoder->geocode('Berlin, Germany'));

    # String address resolved via the injected geocoder or Nominatim
    $map->center('Eiffel Tower, Paris, France');

    # Required when rendering without point markers (GeoJSON-only, choropleth, etc.)
    $map->center([54.0, -2.0]);
    $map->add_geojson(\%uk_regions, popup => 'name');
    my ($head, $body) = $map->onload_render();

=cut

sub center
{
	my $self   = shift;
	my $params = Params::Get::get_params('point', \@_);
	my $point  = $params->{'point'};

lib/HTML/OSM.pm  view on Meta::CPAN

    # Minimal: one marker, embed in a CGI response
    use HTML::OSM;
    my $map = HTML::OSM->new(zoom => 12);
    $map->add_marker([51.5074, -0.1278], html => 'London');
    my ($head, $body) = $map->onload_render();
    print "Content-Type: text/html\n\n";
    print "<html><head>$head</head><body>$body</body></html>\n";

    # Mixed layers: markers + GeoJSON, explicit center
    $map->center([51.5, -0.1]);
    $map->add_geojson(\%borough_data, popup => 'name', style => { color => '#333' });
    $map->add_marker([51.5074, -0.1278], html => 'City of London');
    my ($head_html, $body_html) = $map->onload_render();

    # Template Toolkit integration
    $tt->process('map.tt', {
        map_head => scalar(($map->onload_render())[0]),
        map_body => scalar(($map->onload_render())[1]),
    });

=head3 PSEUDOCODE

lib/HTML/OSM.pm  view on Meta::CPAN

		for my $coord (@valid_coordinates) {
			my ($lat, $lon, $label, $icon_url) = @$coord;
			my $js_label = _js_string($label);
			if($icon_url) {
				my $js_icon = _js_string($icon_url);
				my $add_cmd = $self->{cluster}
					? 'clusterGroup.addLayer(m);'
					: 'm.addTo(map);';
				$body .= qq{
			(function() {
				var icon = L.icon({ iconUrl: '$js_icon', iconAnchor: [16,32], popupAnchor: [0,-32] });
				var m = L.marker([$lat, $lon], { icon: icon }).bindPopup('$js_label');
				$add_cmd
			})();
				};
			} elsif($self->{cluster}) {
				$body .= "\t\t\tclusterGroup.addLayer(L.marker([$lat, $lon]).bindPopup('$js_label'));\n";
			} else {
				$body .= "\t\t\tL.marker([$lat, $lon]).addTo(map).bindPopup('$js_label');\n";
			}
		}

		$body .= "\t\t\tmap.addLayer(clusterGroup);\n" if $self->{cluster};
	}

	# GeoJSON layers.
	for my $layer (@$geojson_layers) {
		my $json     = _html_json($layer->{data});
		my $opts     = $layer->{opts} || {};
		my $style_js = '';
		my $popup_js = '';
		if(my $style = $opts->{style}) {
			$style_js = 'style: ' . _html_json($style) . ',';
		}
		if(my $prop = $opts->{popup}) {
			my $js_prop = _js_string($prop);
			$popup_js = "onEachFeature: function(f,l){ if(f.properties && f.properties['$js_prop']){ l.bindPopup(String(f.properties['$js_prop'])); } },";
		}
		$body .= "\t\t\tL.geoJSON($json, { $style_js $popup_js }).addTo(map);\n";
	}

	# Heatmap layers.
	for my $layer (@$heatmap_layers) {
		my $pts    = _html_json($layer->{points});
		my $opts   = $layer->{opts} || {};
		my $radius = $opts->{radius} || 25;
		my $blur   = $opts->{blur}   || 15;
		$body .= "\t\t\tL.heatLayer($pts, { radius: $radius, blur: $blur }).addTo(map);\n";
	}

lib/HTML/OSM.pm  view on Meta::CPAN

  -----------------------------------------
    result! = 1 ⟺ point? resolves to (lat, lon) ∈ ValidCoord
    result! = 1 ⟹ coordinates' = coordinates ⌢ ⟨(lat, lon, label, icon)⟩

=head2 add_geojson

  AddGeoJSON
    ΔHTML_OSM
    data?  : GeoJSONStruct ∪ S
    style? : StyleMap ∪ {∅}
    popup? : S ∪ {∅}
  -----------------------------------------
    geojson' = geojson ⌢ ⟨{data, style, popup}⟩

=head2 add_heatmap

  AddHeatmap
    ΔHTML_OSM
    points? : iseq (ℝ x ℝ x [0,1])
  -----------------------------------------
    heatmap_layers' = heatmap_layers ⌢ ⟨{points, radius, blur}⟩

=head2 add_gpx

t/40-layers.t  view on Meta::CPAN

	is(scalar @{$m->{geojson}}, 1, 'geojson layer stored');
};

subtest 'add_geojson accepts JSON string' => sub {
	my $m = HTML::OSM->new();
	ok($m->add_geojson('{"type":"FeatureCollection","features":[]}'),
		'add_geojson accepts a JSON string');
	is(scalar @{$m->{geojson}}, 1, 'geojson layer stored from string');
};

subtest 'onload_render emits L.geoJSON with style and popup' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker($london, html => 'London');
	$m->add_geojson(
		{ type => 'FeatureCollection', features => [] },
		style => { color => '#ff0000', weight => 2 },
		popup => 'name',
	);
	my ($head, $body) = $m->onload_render();
	like($body, qr/L\.geoJSON/,   'body contains L.geoJSON');
	like($body, qr/#ff0000/,       'body contains style colour');
	like($body, qr/'name'/,        'body references popup property');
};

subtest 'geojson renders without point markers when center is set' => sub {
	my $m = HTML::OSM->new();
	$m->center($london);
	$m->add_geojson({ type => 'FeatureCollection', features => [] });
	my ($head, $body) = $m->onload_render();
	like($body, qr/L\.geoJSON/, 'GeoJSON rendered without point markers');
};

t/40-layers.t  view on Meta::CPAN

	is($layer->{key}, 'name', 'key stored');
};

subtest 'onload_render emits choropleth JS without extra plugin' => sub {
	my $m = HTML::OSM->new();
	$m->center([54.0, -2.0]);
	$m->add_choropleth(\@features, { England => 100, Scotland => 50 }, key => 'name');
	my ($head, $body) = $m->onload_render();
	like($body, qr/choroplethColors/,  'body contains choroplethColors lookup');
	like($body, qr/fillColor/,         'body contains fillColor style');
	like($body, qr/choroplethValues/,  'body contains choroplethValues for popups');
	unlike($head, qr/leaflet-heat|leaflet-gpx|markercluster/,
		'no extra plugin injected for choropleth');
};

subtest 'choropleth renders without point markers when center set' => sub {
	my $m = HTML::OSM->new();
	$m->center($london);
	$m->add_choropleth(\@features, { England => 100, Scotland => 50 });
	my ($head, $body) = $m->onload_render();
	like($body, qr/choroplethColors/, 'choropleth rendered without point markers');

t/edge_cases.t  view on Meta::CPAN

			type       => 'Feature',
			properties => { name => $C{SCRIPT_CLOSE} },
			geometry   => { type => 'Point', coordinates => [0, 0] },
		}],
	});
	my (undef, $body) = $m->onload_render();
	unlike($body, qr|</script><script>|,
		'raw </script><script> absent when feature property contains injection');
};

subtest 'security: GeoJSON popup property name with </script> is escaped' => sub {
	my $m = HTML::OSM->new();
	$m->center([$C{LAT_LONDON}, $C{LON_LONDON}]);
	# The popup property name goes through _js_string — test that the data
	# path (the whole GeoJSON blob) is also safe.
	$m->add_geojson(
		{ type => 'FeatureCollection', features => [] },
		popup => 'name',
		style => { fillOpacity => 0.5 },
	);
	my (undef, $body) = $m->onload_render();
	unlike($body, qr|</script><script>|,
		'rendered output does not contain script-close injection');
};

# ─────────────────────────────────────────────────────────────────────────────
# 6. add_heatmap() — hostile inputs and XSS via points JSON
# ─────────────────────────────────────────────────────────────────────────────

t/extended_tests.t  view on Meta::CPAN

#   geocoder HASH escapes — {lat,lon} missing/partial, falls to carp
#   HTTP HASH response    — Nominatim direct-hash (not array-wrapped) path
#   HTTP no-lat           — 200 OK but response data lacks a lat field
#   HTTP invalid JSON     — 200 OK but body is not JSON (bug fix: now carp+undef)
#   rate-limit sleep      — Time::HiRes::sleep call when min_interval violated
#   key-value API styles  — zoom(zoom=>N) and add_marker(point=>[…]) branches
#   _validate edge cases  — explicit + prefix, whitespace in coord string
#   onload_render misc    — custom width/height, idempotency, height/width fallback
#   _js_string edge cases — standalone CR, Unicode pass-through
#   add_marker tuple      — html AND icon both stored in correct tuple slots
#   GeoJSON popup escaping — popup property name JS-escaped in rendered output

use strict;
use warnings;

use Readonly;
use Scalar::Util qw(blessed);
use Test::Mockingbird qw(mock restore_all);
use Test::Most;
use Test::Returns;
use Time::HiRes qw(time);

t/extended_tests.t  view on Meta::CPAN

		[$C{LAT_LONDON}, $C{LON_LONDON}],
		html => 'London',
		icon => 'https://example.com/pin.png',
	);
	my $t = $m->{coordinates}[0];
	is($t->[2], 'London',                   'html in tuple slot [2]');
	is($t->[3], 'https://example.com/pin.png', 'icon in tuple slot [3]');
};

# ─────────────────────────────────────────────────────────────────────────────
# 21. onload_render() — GeoJSON popup property name JS-escaped
#     The property name passed to popup => '…' is interpolated via _js_string
#     into the onEachFeature callback.  A single quote in the prop name would
#     break the JS string if not escaped.
# ─────────────────────────────────────────────────────────────────────────────

subtest "onload_render: GeoJSON popup property name with single quote is JS-escaped" => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
	$m->add_geojson(
		{ type => 'FeatureCollection', features => [] },
		popup => "o'malley",    # property name containing a single quote
	);
	my (undef, $body) = $m->onload_render();
	unlike($body, qr/properties\['o'malley'\]/, "raw quote not in property name");
	like($body,   qr/o\\'malley/,               "quote escaped in popup property name");
};

# ─────────────────────────────────────────────────────────────────────────────
# 22. onload_render() — choropleth key name JS-escaped in rendered callback
#     The choropleth key (feature property name used for lookup) passes through
#     _js_string before embedding.  A quote in the key would break the JS.
# ─────────────────────────────────────────────────────────────────────────────

subtest "onload_render: choropleth key with single quote is JS-escaped" => sub {
	my $m = HTML::OSM->new();

t/function.t  view on Meta::CPAN

	$m->add_geojson('{"type":"FeatureCollection","features":[]}');
	is(ref($m->{geojson}[0]{data}), 'HASH', 'decoded to hashref');
	is($m->{geojson}[0]{data}{type}, 'FeatureCollection', 'content correct');
};

subtest 'add_geojson: invalid JSON dies' => sub {
	my $m = HTML::OSM->new();
	dies_ok { $m->add_geojson('not json at all') } 'invalid JSON dies';
};

subtest 'add_geojson: style and popup opts stored' => sub {
	my $m = HTML::OSM->new();
	$m->add_geojson({ type => 'FeatureCollection', features => [] },
		style => { color => '#ff0000' }, popup => 'name');
	is_deeply($m->{geojson}[0]{opts}{style}, { color => '#ff0000' }, 'style stored');
	is($m->{geojson}[0]{opts}{popup}, 'name', 'popup stored');
};

subtest 'add_geojson: multiple calls accumulate layers' => sub {
	my $m = HTML::OSM->new();
	$m->add_geojson({ type => 'FeatureCollection', features => [] });
	$m->add_geojson({ type => 'FeatureCollection', features => [] });
	is(scalar @{$m->{geojson}}, 2, 'two layers');
	memory_cycle_ok($m->{geojson}, 'no cycles in geojson');
};

t/function.t  view on Meta::CPAN


subtest 'onload_render: icon URL uses L.icon in emitted JS' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$CFG{LAT_LONDON}, $CFG{LON_LONDON}],
		icon => 'https://example.com/pin.png');
	my (undef, $body) = $m->onload_render();
	like($body, qr/L\.icon/,              'L.icon call present');
	like($body, qr|example\.com/pin\.png|, 'icon URL present');
};

subtest 'onload_render: popup label with single quote is JS-escaped' => sub {
	# A raw single quote in a label would break the JS string — _js_string must fire.
	my $m = HTML::OSM->new();
	$m->add_marker([$CFG{LAT_LONDON}, $CFG{LON_LONDON}], html => "O'Brien's Pub");
	my (undef, $body) = $m->onload_render();
	like($body, qr/O\\'Brien/, 'single quote JS-escaped in popup label');
};

subtest 'onload_render: GPX URL with special chars is JS-escaped' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$CFG{LAT_LONDON}, $CFG{LON_LONDON}]);
	$m->add_gpx("https://example.com/track's.gpx");
	my (undef, $body) = $m->onload_render();
	like($body, qr/track\\'s\.gpx/, 'GPX URL JS-escaped');
};

t/integration.t  view on Meta::CPAN

	like($body, qr/L1/,           'marker label present');
	like($body, qr/#aabbcc/,      'GeoJSON colour present');
	like($body, qr/radius:\s*30/, 'heatmap custom radius present');
};

# ─────────────────────────────────────────────────────────────────────────────
# Integration 8: JS escaping pipeline — end-to-end injection attack vectors
# Every user-supplied string that reaches the rendered JS must be escaped.
# ─────────────────────────────────────────────────────────────────────────────

subtest 'XSS: </script> injection in popup label neutralised' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}],
		html => '</script><script>alert(1)</script>');
	my (undef, $body) = $m->onload_render();
	unlike($body, qr|</script><script>|, 'raw </script> absent');
	like($body,   qr|<\\/script>|,        'escaped form present');
};

subtest 'XSS: backslash + single-quote combo fully escaped in popup' => sub {
	# Input: C:\path\'  — backslash then quote
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], html => "C:\\path\\'s");
	my (undef, $body) = $m->onload_render();
	like($body, qr/C:\\\\path/, 'backslash doubled');
};

subtest 'XSS: newline in popup label converted to literal \\n in JS' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], html => "Line1\nLine2");
	my (undef, $body) = $m->onload_render();
	unlike($body, qr/Line1\nLine2/, 'raw newline absent');
	like($body,   qr/Line1\\nLine2/, 'escaped \\n present');
};

subtest 'XSS: GPX URL with single quote JS-escaped end-to-end' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);

t/integration.t  view on Meta::CPAN

		scale => ['#col0', '#col1', '#col2', '#col3', '#col4'],
	);

	my (undef, $body) = $m->onload_render();
	like($body, qr/#col0/, 'low-end colour in body');
	like($body, qr/#col4/, 'high-end colour in body');
	like($body, qr/fillColor/,        'fillColor property emitted');
	like($body, qr/choroplethValues/, 'value lookup emitted');
};

subtest 'choropleth: popup template binds key and value' => sub {
	my $m = HTML::OSM->new();
	$m->center([$C{LAT_LONDON}, $C{LON_LONDON}]);
	$m->add_choropleth(\@FEATURES, { England => 100, Scotland => 50 });
	my (undef, $body) = $m->onload_render();
	# Emitted JS: k + ': ' + choroplethValues[k]
	like($body, qr/choroplethValues\[k\]/, 'value lookup present in popup template');
};

subtest 'choropleth: multiple layers all rendered in body' => sub {
	my $m = HTML::OSM->new();
	$m->center([$C{LAT_LONDON}, $C{LON_LONDON}]);
	$m->add_choropleth(\@FEATURES, { England => 100 });
	$m->add_choropleth(\@FEATURES, { England => 200 });
	my (undef, $body) = $m->onload_render();
	my @decls = ($body =~ /var choroplethColors/g);
	is(scalar @decls, 2, 'two choroplethColors declarations emitted');

t/unit.t  view on Meta::CPAN


subtest 'add_marker: out-of-range marker does not appear in rendered body' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);      # valid — provides center
	{ local $SIG{__WARN__} = $SILENCE;
	  $m->add_marker([999, 999], html => 'ImpossiblePlace'); }
	my (undef, $body) = $m->onload_render();
	unlike($body, qr/ImpossiblePlace/, 'invalid label absent from body');
};

subtest 'add_marker: html popup label appears in bindPopup in rendered body' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], html => 'My London Label');
	my (undef, $body) = $m->onload_render();
	like($body, qr/My London Label/, 'popup label in bindPopup');
};

subtest 'add_marker: icon URL triggers L.icon in rendered body' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], icon => 'https://ex.com/pin.png');
	my (undef, $body) = $m->onload_render();
	like($body, qr/L\.icon/,                 'L.icon call present');
	like($body, qr|ex\.com/pin\.png|,        'icon URL present');
};

t/unit.t  view on Meta::CPAN

subtest 'add_geojson: style colour appears in rendered body' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
	$m->add_geojson({ type => 'FeatureCollection', features => [] },
		style => { color => '#abcdef' });
	my (undef, $body) = $m->onload_render();
	like($body, qr/#abcdef/,   'style colour in body');
	like($body, qr/L\.geoJSON/, 'L.geoJSON call in body');
};

subtest 'add_geojson: popup property name appears in rendered body' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
	$m->add_geojson({ type => 'FeatureCollection', features => [] }, popup => 'region');
	my (undef, $body) = $m->onload_render();
	like($body, qr/region/, "popup property 'region' in body JS");
};

subtest 'add_geojson: multiple layers all rendered' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
	$m->add_geojson({ type => 'FeatureCollection', features => [] }, style => { color => '#ff0000' });
	$m->add_geojson({ type => 'FeatureCollection', features => [] }, style => { color => '#0000ff' });
	my (undef, $body) = $m->onload_render();
	my @calls = ($body =~ /L\.geoJSON/g);
	is(scalar @calls, 2, 'two L.geoJSON calls emitted');

t/unit.t  view on Meta::CPAN

	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
	my ($head, $body) = $m->onload_render();
	unlike($head, qr/markercluster/, 'no cluster assets in head');
	unlike($body, qr/clusterGroup/,  'no clusterGroup in body');
	like($body,   qr/addTo\(map\)/,  'marker added directly to map');
};

# Popup labels with single quotes must be JS-escaped so they don't break the
# surrounding JS single-quoted string literal.
subtest 'onload_render: popup label with single quote is JS-escaped' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], html => "O'Brien's Bar");
	my (undef, $body) = $m->onload_render();
	like($body,   qr/O\\'Brien/,           'single quote JS-escaped');
	unlike($body, qr/O'Brien(?!\\)/,       'unescaped quote absent from popup');
};

# GPX URLs with special characters must also pass through _js_string.
subtest 'onload_render: GPX URL with single quote is JS-escaped' => sub {
	my $m = HTML::OSM->new();
	$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
	$m->add_gpx("https://example.com/track's.gpx");
	my (undef, $body) = $m->onload_render();
	like($body, qr/track\\'s\.gpx/, 'GPX URL single-quote JS-escaped');
};



( run in 2.104 seconds using v1.01-cache-2.11-cpan-364913b4093 )