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: '&copy; 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 )