HTML-OSM
view release on metacpan or search on metacpan
lib/HTML/OSM.pm view on Meta::CPAN
geocoder => { type => 'object', can => 'geocode', optional => 1 },
gpx_js_url => { type => 'string', optional => 1 },
heatmap_js_url => { type => 'string', optional => 1 },
height => { type => 'string', optional => 1 },
host => { type => 'string', optional => 1 },
js_url => { type => 'string', optional => 1 },
logger => { type => 'object', optional => 1 },
min_interval => { type => 'number', min => 0, optional => 1 },
ua => { type => 'object', optional => 1 },
width => { type => 'string', optional => 1 },
zoom => { type => 'integer', min => $ZOOM_MIN, max => $ZOOM_MAX, optional => 1 },
},
});
# Config file values override programmatic defaults (separation of config and code).
$params = Object::Configure::configure($class, $params);
# Inject the resolved cache so the bless hash spreads it correctly.
$params->{cache} //= CHI->new(
driver => 'Memory',
global => 1,
expires_in => '1 day',
);
return bless {
# Defaults â spread of %{$params} below overrides each one when supplied.
coordinates => [],
height => '400px',
host => $NOMINATIM_HOST,
width => '600px',
zoom => 12,
min_interval => 0,
last_request => 0,
cluster => 0,
css_url => $LEAFLET_CSS_URL,
js_url => $LEAFLET_JS_URL,
cluster_js_url => $CLUSTER_JS_URL,
cluster_css_url => $CLUSTER_CSS_URL,
cluster_default_css_url => $CLUSTER_DEFAULT_CSS_URL,
heatmap_js_url => $HEATMAP_JS_URL,
gpx_js_url => $GPX_JS_URL,
%{$params},
}, $class;
}
=head2 add_marker
Add a point marker to the map.
$map->add_marker([51.5074, -0.1278], html => 'London');
$map->add_marker('Paris, France', html => 'Paris');
$map->add_marker($geo_coder_result);
Returns 1 on success, 0 if the point cannot be resolved or is out of range.
=head3 API SPECIFICATION
=head4 INPUT
point : arrayref [lat, lon] | string address | object with latitude()/longitude()
html : string (optional popup label)
icon : string (optional icon URL)
=head4 OUTPUT
{ type => integer, enum => [0, 1] }
=head3 MESSAGES
| Message | Meaning / Resolution |
|--------------------------------------|---------------------------------------------|
| add_marker(): unknown point type | Point is a ref type with no lat/lon methods |
=head3 EXAMPLES
# Coordinate array with a popup label
$map->add_marker([51.5074, -0.1278], html => 'London');
# String address geocoded via the injected geocoder or Nominatim
$map->add_marker('Paris, France', html => 'Paris');
# Custom icon URL with a popup
$map->add_marker(
[40.7128, -74.0060],
html => 'New York',
icon => 'https://example.com/pin.png',
);
# Geo::Coder result object that implements latitude()/longitude()
my $result = $geocoder->geocode('Berlin, Germany');
$map->add_marker($result, html => 'Berlin');
# Accumulate several markers, warn on geocode failure
for my $city (@cities) {
$map->add_marker($city->{coords}, html => $city->{name})
or warn "Could not place $city->{name}";
}
=cut
sub add_marker
{
my $self = shift;
my ($params, $point);
if(ref($_[0]) eq 'ARRAY') {
$point = shift;
$params = Params::Get::get_params(undef, \@_) || {};
# Single-element arrayref is a wrapped address string
$point = $point->[0] if scalar(@{$point}) == 1;
} elsif(blessed($_[0]) && $_[0]->can('latitude')) {
# Geo object as first positional arg: extract before Params::Get sees it,
# otherwise Params::Get mistakes the blessed hashref for the params hash.
$point = shift;
$params = Params::Get::get_params(undef, \@_) || {};
} elsif(defined($_[0]) && !ref($_[0]) && scalar(@_) % 2 != 0) {
# Plain string as first positional arg, optionally followed by key-value pairs.
# An odd total count signals a leading positional; even count means all key-value.
$point = shift;
$params = Params::Get::get_params(undef, \@_) || {};
} else {
$params = Params::Get::get_params('point', @_);
$point = $params->{'point'};
}
my ($lat, $lon);
if(ref($point) eq 'ARRAY') {
return 0 if scalar(@{$point}) != 2;
($lat, $lon) = @{$point};
} elsif(!ref($point)) {
($lat, $lon) = $self->_fetch_coordinates($point);
} elsif($point->can('latitude')) {
($lat, $lon) = ($point->latitude(), $point->longitude());
} else {
my $msg = 'add_marker(): unknown point type: ' . ref($point);
$self->{logger}->error($msg) if $self->{logger};
croak $msg;
}
return 0 unless defined($lat) && defined($lon);
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
sub add_geojson
{
my $self = shift;
my $data = shift;
my $params = Params::Get::get_params(undef, \@_) || {};
# Accept either a pre-parsed structure or a raw JSON string
$data = decode_json($data) if !ref($data);
push @{$self->{geojson}}, { data => $data, opts => $params };
return 1;
}
=head2 add_heatmap
Add a heatmap layer to the map.
$map->add_heatmap([[51.5, -0.1, 0.8], [51.6, -0.2, 0.5]], radius => 25);
Each point is C<[$lat, $lon]> or C<[$lat, $lon, $intensity]> (intensity: 0-1).
Requires the Leaflet.heat plugin (C<heatmap_js_url>).
Returns 1 on success.
=head3 API SPECIFICATION
=head4 INPUT
points : arrayref of ([lat, lon] | [lat, lon, intensity])
radius : integer default 25
blur : integer default 15
=head4 OUTPUT
{ type => integer, value => 1 }
=head3 MESSAGES
| Message | Meaning / Resolution |
|--------------------------------------|-----------------------------------|
| add_heatmap: points must be arrayref | First argument is not an arrayref |
=head3 EXAMPLES
# Basic heatmap â [lat, lon] per point
$map->add_heatmap([
[51.5074, -0.1278],
[51.6000, -0.2000],
[51.4000, 0.0000],
]);
lib/HTML/OSM.pm view on Meta::CPAN
my ($min, $max) = ($sorted_vals[0] // 0, $sorted_vals[-1] // 0);
$max = $min + 1 if $max == $min; # avoid division by zero for single-value sets
my %colors;
while(my ($k, $v) = each %{$values}) {
my $idx = int(($v - $min) / ($max - $min) * $#{$scale});
$idx = $#{$scale} if $idx > $#{$scale};
$colors{$k} = $scale->[$idx];
}
push @{$self->{choropleth_layers}}, {
features => $features,
values => $values,
colors => \%colors,
key => $key,
};
return 1;
}
=head2 center
Set the map centre to a given point.
$map->center([40.7128, -74.0060]);
$map->center($geo_object);
$map->center('Berlin, Germany');
Returns 1 on success, 0 if the point cannot be resolved.
=head3 API SPECIFICATION
=head4 INPUT
point : arrayref [lat, lon] | object with latitude()/longitude() | string address
=head4 OUTPUT
{ type => integer, enum => [0, 1] }
=head3 MESSAGES
| Message | Meaning / Resolution |
|------------------------------------------------|------------------------------------------|
| center(): usage: point => [lat, lon] | No point argument supplied |
| center(): point must have latitude & longitude | Arrayref has != 2 elements |
| center(): unknown point type | Ref type has no lat/lon methods |
=head3 EXAMPLES
# Coordinate array
$map->center([40.7128, -74.0060]);
# Object that implements latitude()/longitude() (e.g. a Geo::Coder result)
$map->center($geocoder->geocode('Berlin, Germany'));
# String address resolved via the injected geocoder or Nominatim
$map->center('Eiffel Tower, Paris, France');
# Required when rendering without point markers (GeoJSON-only, choropleth, etc.)
$map->center([54.0, -2.0]);
$map->add_geojson(\%uk_regions, popup => 'name');
my ($head, $body) = $map->onload_render();
=cut
sub center
{
my $self = shift;
my $params = Params::Get::get_params('point', \@_);
my $point = $params->{'point'};
croak 'center(): usage: point => [ latitude, longitude ]' unless defined($point);
my ($lat, $lon);
if(ref($point) eq 'ARRAY') {
croak 'center(): point must have latitude and longitude'
if scalar(@{$point}) != 2;
($lat, $lon) = @{$point};
} elsif(ref($point) && $point->can('latitude')) {
($lat, $lon) = ($point->latitude(), $point->longitude());
} elsif(!ref($point)) {
($lat, $lon) = $self->_fetch_coordinates($point);
} else {
my $msg = 'center(): unknown point type: ' . ref($point);
$self->{logger}->error($msg) if $self->{logger};
croak $msg;
}
return 0 unless defined($lat) && defined($lon);
return 0 unless _validate($lat, $lon);
$self->{'center'} = [$lat, $lon];
return 1;
}
=head2 zoom
Get or set the zoom level (0 = world, 19 = building).
$map->zoom(10);
my $z = $map->zoom();
=head3 API SPECIFICATION
=head4 INPUT
{ zoom => { type => integer, min => 0, max => 19, optional => 1 } }
=head4 OUTPUT
{ type => integer, min => 0, max => 19 }
=head3 MESSAGES
| Message | Meaning / Resolution |
|------------------------------|-------------------------------------------|
| (Params::Validate::Strict) | zoom is not an integer or is out of range |
=head3 EXAMPLES
lib/HTML/OSM.pm view on Meta::CPAN
}
return $self->{'zoom'};
}
=head2 onload_render
Render the map and return a two-element list suitable for embedding in HTML.
my ($head_html, $body_html) = $map->onload_render();
C<$head_html> contains the Leaflet CSS, JavaScript, and plugin assets.
Place it inside C<< <head>...</head> >>.
C<$body_html> contains the search box, control buttons, map C<< <div> >>,
and the initialisation C<< <script> >>.
Place it inside C<< <body>...</body> >> where the map should appear.
The rendered page provides:
=over 4
=item * A Nominatim-powered search box that adds temporary markers.
=item * A "Clear search markers" button that removes those temporary markers,
leaving static markers (added via C<add_marker>) intact.
=item * A "Reset Map" button that returns the view to the initial centre and zoom.
=back
=head3 API SPECIFICATION
=head4 INPUT
(none - uses object state)
=head4 OUTPUT
{ type => list, elements => [string, string] }
=head3 MESSAGES
| Message | Meaning / Resolution |
|--------------------------------------------------|---------------------------------------------|
| No map data provided | No markers, GeoJSON, heatmap, GPX, or choropleth added yet |
| center() must be called when no point markers | Non-marker-only render needs explicit centre |
=head3 EXAMPLES
# Minimal: one marker, embed in a CGI response
use HTML::OSM;
my $map = HTML::OSM->new(zoom => 12);
$map->add_marker([51.5074, -0.1278], html => 'London');
my ($head, $body) = $map->onload_render();
print "Content-Type: text/html\n\n";
print "<html><head>$head</head><body>$body</body></html>\n";
# Mixed layers: markers + GeoJSON, explicit center
$map->center([51.5, -0.1]);
$map->add_geojson(\%borough_data, popup => 'name', style => { color => '#333' });
$map->add_marker([51.5074, -0.1278], html => 'City of London');
my ($head_html, $body_html) = $map->onload_render();
# Template Toolkit integration
$tt->process('map.tt', {
map_head => scalar(($map->onload_render())[0]),
map_body => scalar(($map->onload_render())[1]),
});
=head3 PSEUDOCODE
1. Gather all data layers; croak if none populated.
2. Geocode/validate each coordinate tuple; discard invalids with a warning.
3. Determine map centre: caller-supplied > computed midpoint of marker bounds.
Croak if neither is available.
4. Build <head>: Leaflet CSS + JS; inject cluster/heatmap/GPX plugin assets
only when the corresponding layer type is present.
5. Build <body>: search box, clear-search button, reset button, map <div>.
6. Initialise Leaflet map, tile layer, searchMarkers array.
7. Emit JS for each marker (via clusterGroup when cluster is set).
8. Emit JS for each GeoJSON, heatmap, GPX, and choropleth layer.
9. Attach event listeners: reset-view, clear-search-markers, search-on-Enter.
10. Return ($head, $body).
=cut
sub onload_render
{
my $self = shift;
my $height = $self->{'height'} || '400px';
my $width = $self->{'width'} || '600px';
my $coordinates = $self->{coordinates} || [];
my $geojson_layers = $self->{geojson} || [];
my $heatmap_layers = $self->{heatmap_layers} || [];
my $gpx_tracks = $self->{gpx_tracks} || [];
my $choropleth_layers = $self->{choropleth_layers} || [];
unless(@$coordinates || @$geojson_layers || @$heatmap_layers
|| @$gpx_tracks || @$choropleth_layers) {
$self->{logger}->error('No map data provided') if $self->{logger};
croak 'No map data provided';
}
# Geocode address strings; validate and discard bad numeric pairs.
my @valid_coordinates;
for my $coord (@$coordinates) {
my ($lat, $lon, $label, $icon_url) = @$coord;
if(!defined $lat || !defined $lon) {
($lat, $lon) = $self->_fetch_coordinates($label);
}
# Validate ALL coordinates here â including geocoder-returned ones.
# A compromised geocoder or Nominatim response could return a crafted
# lat/lon string that would inject JS if embedded without validation.
next unless defined($lat) && defined($lon) && _validate($lat, $lon);
push @valid_coordinates, [$lat, $lon, $label, $icon_url];
}
# Determine map centre: caller-set wins; else compute from marker bounds.
my ($center_lat, $center_lon);
lib/HTML/OSM.pm view on Meta::CPAN
}
$center_lat = ($min_lat + $max_lat) / 2;
$center_lon = ($min_lon + $max_lon) / 2;
} else {
croak 'center() must be called when no point markers are provided';
}
# --- <head> ---
my $head = qq{
<link rel="stylesheet" href="$self->{css_url}" />
<script src="$self->{js_url}"></script>
};
if($self->{cluster}) {
$head .= qq{
<link rel="stylesheet" href="$self->{cluster_css_url}" />
<link rel="stylesheet" href="$self->{cluster_default_css_url}" />
<script src="$self->{cluster_js_url}"></script>
};
}
$head .= qq{\t\t<script src="$self->{heatmap_js_url}"></script>\n} if @$heatmap_layers;
$head .= qq{\t\t<script src="$self->{gpx_js_url}"></script>\n} if @$gpx_tracks;
$head .= qq{
<style>
#map { width: $width; height: $height; }
#search-box { margin: 10px; padding: 5px; }
#reset-button, #clear-search-button { margin: 10px; padding: 5px; cursor: pointer; }
</style>
};
# --- <body> ---
my $body = qq{
<input type="text" id="search-box" placeholder="Enter location">
<button id="clear-search-button">Clear search markers</button>
<button id="reset-button">Reset Map</button>
<div id="map"></div>
<script>
var map = L.map('map').setView([$center_lat, $center_lon], $self->{zoom});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
var searchMarkers = [];
};
# Point markers â optionally grouped into a cluster layer.
if(@valid_coordinates) {
$body .= "\t\t\tvar clusterGroup = L.markerClusterGroup();\n" if $self->{cluster};
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";
}
# GPX tracks â browser fetches the file; fitBounds called on load.
for my $url (@$gpx_tracks) {
my $js_url = _js_string($url);
$body .= "\t\t\tnew L.GPX('$js_url', { async: true }).on('loaded', function(e){ map.fitBounds(e.target.getBounds()); }).addTo(map);\n";
}
# Choropleth layers â colours are pre-baked; no browser-side scale maths.
for my $layer (@$choropleth_layers) {
my $fc_json = _html_json({ type => 'FeatureCollection', features => $layer->{features} });
my $colors_json = _html_json($layer->{colors});
my $values_json = _html_json($layer->{values});
my $js_key = _js_string($layer->{key});
$body .= qq{
(function() {
var choroplethColors = $colors_json;
var choroplethValues = $values_json;
L.geoJSON($fc_json, {
style: function(f) {
var k = f.properties && f.properties['$js_key'];
return { fillColor: choroplethColors[k] || '#cccccc',
weight: 2, opacity: 1, color: 'white',
dashArray: '3', fillOpacity: 0.7 };
},
onEachFeature: function(f, l) {
var k = f.properties && f.properties['$js_key'];
if(k && choroplethValues[k] !== undefined) {
l.bindPopup(k + ': ' + choroplethValues[k]);
}
}
}).addTo(map);
})();
};
}
# Event handlers.
$body .= qq{
document.getElementById('reset-button').addEventListener('click', function() {
map.setView([$center_lat, $center_lon], $self->{zoom});
});
document.getElementById('clear-search-button').addEventListener('click', function() {
searchMarkers.forEach(function(m) { map.removeLayer(m); });
searchMarkers = [];
});
document.getElementById('search-box').addEventListener('keyup', function(event) {
if(event.key === 'Enter') {
var query = event.target.value.trim();
if(!query) { alert('Please enter a valid location.'); return; }
lib/HTML/OSM.pm view on Meta::CPAN
=over 4
=item * L<https://wiki.openstreetmap.org/wiki/API>
=item * L<HTML::GoogleMaps::V3> - the interface this module mirrors for compatibility.
=item * L<https://leafletjs.com/>
=item * L<Configure an Object at Runtime|Object::Configure>
=item * L<Test Dashboard|https://nigelhorne.github.io/HTML-OSM/coverage/>
=back
=head1 SUPPORT
This module is provided as-is without any warranty.
L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=HTML-OSM>
=head2 TODO
Allow per-marker removal via clicking on a marker.
=encoding utf-8
=head1 FORMAL SPECIFICATION
=head2 new
HTML_OSM
coordinates : iseq (â x â x S x S)
zoom : Z
cluster : B
----------------------------------------
ZOOM_MIN <= zoom <= ZOOM_MAX
new â
params? : Params
osm! : HTML_OSM
----------------------------------------
osm!.zoom = params?.zoom ⨠12
osm!.cluster = params?.cluster ⨠false
=head2 add_marker
AddMarker
ÎHTML_OSM
point? : (â x â) ⪠S ⪠GeoObject
result! : {0, 1}
-----------------------------------------
result! = 1 ⺠point? resolves to (lat, lon) â ValidCoord
result! = 1 â¹ coordinates' = coordinates ⢠â¨(lat, lon, label, icon)â©
=head2 add_geojson
AddGeoJSON
ÎHTML_OSM
data? : GeoJSONStruct ⪠S
style? : StyleMap ⪠{â
}
popup? : S ⪠{â
}
-----------------------------------------
geojson' = geojson ⢠â¨{data, style, popup}â©
=head2 add_heatmap
AddHeatmap
ÎHTML_OSM
points? : iseq (â x â x [0,1])
-----------------------------------------
heatmap_layers' = heatmap_layers ⢠â¨{points, radius, blur}â©
=head2 add_gpx
AddGPX
ÎHTML_OSM
url? : S | url? â ''
-----------------------------------------
gpx_tracks' = gpx_tracks ⢠â¨url?â©
=head2 add_chropleth
AddChoropleth
ÎHTML_OSM
features? : iseq GeoFeature
values? : S --> â
key? : S
scale? : iseq S
-----------------------------------------
Let min = min(ran values?), max = max(ran values?) ⪠{min+1}
â k â dom values? â¢
color(k) = scale?[floor((values?(k)-min)/(max-min) * (#scale?-1))]
choropleth_layers' = choropleth_layers ⢠â¨{features, values, colors, key}â©
=head2 center
Center
ÎHTML_OSM
point? : (â x â) ⪠S ⪠GeoObject
result! : {0, 1}
-----------------------------------------
result! = 1 ⺠point? resolves to (lat, lon) â ValidCoord
result! = 1 â¹ center' = (lat, lon)
=head2 zoom
Zoom
ÎHTML_OSM
zoom? : Z ⪠{â
}
zoom! : Z
-----------------------------------------
zoom? â â
â¹ ZOOM_MIN <= zoom? <= ZOOM_MAX
zoom! = (zoom? â â
⧠zoom' = zoom?) ⨠zoom
=head2 onload_render
OnloadRender
HTML_OSM
head! : S
body! : S
-----------------------------------------
(#coordinates + #geojson + #heatmap_layers + #gpx_tracks + #choropleth_layers) > 0
center â â
⨠â valid â coordinates ⢠valid â ValidCoord
( run in 2.366 seconds using v1.01-cache-2.11-cpan-364913b4093 )