view release on metacpan or search on metacpan
$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
$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
# 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
-----------------------------------------
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');
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');
};
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');
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');
};