File Coverage

File:blib/lib/HTML/OSM.pm
Coverage:96.7%

linestmtbrancondsubtimecode
1#!/usr/bin/env perl
2
3package HTML::OSM;
4
5
12
12
12
991792
9
153
use strict;
6
12
12
12
22
8
240
use warnings;
7
12
12
12
929
30459
33
use autodie qw(:all);
8
9
12
12
12
51768
19
291
use Carp qw(carp croak);
10
12
12
12
2345
304809
201
use CHI;
11
12
12
12
33
8
376
use JSON::MaybeXS qw(decode_json encode_json);
12
12
12
12
3850
244741
285
use LWP::UserAgent;
13
12
12
12
3472
686302
259
use Object::Configure 0.15;
14
12
12
12
39
66
222
use Params::Get 0.13;
15
12
12
12
27
84
166
use Params::Validate::Strict 0.28;
16
12
12
12
20
7
265
use Readonly;
17
12
12
12
17
10
861
use Scalar::Util qw(blessed);
18
12
12
12
34
11
41
use Time::HiRes qw(time);
19
12
12
12
542
6
20806
use URI::Escape qw(uri_escape_utf8);
20
21 - 29
=head1 NAME

HTML::OSM - Generate an interactive OpenStreetMap with Leaflet.js

=head1 VERSION

Version 0.10

=cut
30
31our $VERSION = '0.10';
32
33# CDN URLs pinned to tested versions.  Override via constructor params to use
34# a self-hosted copy or a different version.
35Readonly::Scalar my $LEAFLET_CSS_URL         => 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
36Readonly::Scalar my $LEAFLET_JS_URL          => 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
37Readonly::Scalar my $CLUSTER_JS_URL          => 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js';
38Readonly::Scalar my $CLUSTER_CSS_URL         => 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css';
39Readonly::Scalar my $CLUSTER_DEFAULT_CSS_URL => 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css';
40Readonly::Scalar my $HEATMAP_JS_URL          => 'https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js';
41Readonly::Scalar my $GPX_JS_URL              => 'https://cdnjs.cloudflare.com/ajax/libs/leaflet-gpx/1.7.0/gpx.min.js';
42Readonly::Scalar my $NOMINATIM_HOST          => 'nominatim.openstreetmap.org/search';
43
44# WGS-84 valid ranges
45Readonly::Scalar my $LAT_MIN => -90;
46Readonly::Scalar my $LAT_MAX =>  90;
47Readonly::Scalar my $LON_MIN => -180;
48Readonly::Scalar my $LON_MAX =>  180;
49Readonly::Scalar my $ZOOM_MIN =>  0;
50Readonly::Scalar my $ZOOM_MAX => 19;
51
52 - 140
=head1 SYNOPSIS

    use HTML::OSM;

    my $map = HTML::OSM->new(
        coordinates => [
            [37.7749, -122.4194, 'San Francisco'],
            [undef,   undef,     'Paris'],
        ],
        zoom => 10,
    );
    my ($head, $body) = $map->onload_render();

=over 4

=item * Caching

Geocode results are cached via L<CHI> (default: in-memory, 1-day TTL).
Supply your own C<cache> object to persist across processes.

=item * Rate-Limiting

Set C<min_interval> (seconds) to throttle outbound Nominatim calls
and comply with the API fair-use policy.

=back

=head1 SUBROUTINES/METHODS

=head2 new

Construct a new C<HTML::OSM> object.

    my $map = HTML::OSM->new(%params);
    my $map = HTML::OSM->new(\%params);

Both method-style (C<< HTML::OSM->new(...) >>) and function-style
(C<HTML::OSM::new(...)>) calls are supported.
Calling C<< $existing_obj->new(%overrides) >> performs a shallow clone,
merging C<%overrides> onto the existing object's state without re-validating.

=head3 API SPECIFICATION

=head4 INPUT

  {
    cache                   => { type => object, can => [get, set], optional },
    cluster                 => { type => boolean,                   optional },
    cluster_css_url         => { type => string,                    optional },
    cluster_default_css_url => { type => string,                    optional },
    cluster_js_url          => { type => string,                    optional },
    config_file             => { type => string,                    optional },
    coordinates             => { type => arrayref,                  optional },
    css_url                 => { type => string,                    optional },
    geocoder                => { type => object, can => geocode,    optional },
    gpx_js_url              => { type => string,                    optional },
    heatmap_js_url          => { type => string,                    optional },
    height                  => { type => string,                    optional },
    host                    => { type => string,                    optional },
    js_url                  => { type => string,                    optional },
    logger                  => { type => object,                    optional },
    min_interval            => { type => number,  min => 0,         optional },
    ua                      => { type => object,                    optional },
    width                   => { type => string,                    optional },
    zoom                    => { type => integer, min => 0, max => 19, optional },
  }

=head4 OUTPUT

  { type => object, isa => 'HTML::OSM' }

=head3 MESSAGES

  | Message                                        | Meaning / Resolution                          |
  |------------------------------------------------|-----------------------------------------------|
  | (validation error from Params::Validate::Strict) | A param has the wrong type or is out of range |

=head3 PSEUDOCODE

  1. If $class is neither a package name nor a blessed ref, treat as
     function-style call: prepend $class back onto @_ and use __PACKAGE__.
  2. If $class is a blessed ref (clone call): merge override params onto a
     shallow copy and return immediately, bypassing schema validation.
  3. Validate all supplied args against the declared schema.
  4. Merge config-file settings via Object::Configure.
  5. Resolve the cache: caller-supplied object, or in-memory CHI instance.
  6. Bless and return with Readonly CDN constants as defaults.

=cut
141
142sub new
143{
144
4183
1426654
        my $class = shift;
145
146        # Function-style call: HTML::OSM::new(key => val).
147        # Perl places the first argument where the class name should be,
148        # so we put it back and set the class explicitly.
149
4183
12383
        if(defined($class) && !blessed($class) && !UNIVERSAL::isa($class, __PACKAGE__)) {
150
3
5
                unshift @_, $class;
151
3
3
                $class = __PACKAGE__;
152        }
153
154        # Clone path: $obj->new(%overrides) — shallow-merge onto the existing
155        # object without re-running schema validation so subclasses can extend.
156
4183
3907
        if(blessed($class)) {
157
10
21
                my $extra = Params::Get::get_params(undef, \@_) || {};
158
10
10
10
118
19
40
                return bless { %{$class}, %{$extra} }, ref($class);
159        }
160
161
4173
3426
        $class //= __PACKAGE__;
162
163
4173
5324
        my $params = Params::Validate::Strict::validate_strict({
164                args   => Params::Get::get_params(undef, \@_) || {},
165                schema => {
166                        cache                   => { type => 'object',  can => [qw(get set)], optional => 1 },
167                        cluster                 => { type => 'boolean',                        optional => 1 },
168                        cluster_css_url         => { type => 'string',                         optional => 1 },
169                        cluster_default_css_url => { type => 'string',                         optional => 1 },
170                        cluster_js_url          => { type => 'string',                         optional => 1 },
171                        config_file             => { type => 'string',                         optional => 1 },
172                        coordinates             => { type => 'arrayref',                       optional => 1 },
173                        css_url                 => { type => 'string',                         optional => 1 },
174                        geocoder                => { type => 'object',  can => 'geocode',      optional => 1 },
175                        gpx_js_url              => { type => 'string',                         optional => 1 },
176                        heatmap_js_url          => { type => 'string',                         optional => 1 },
177                        height                  => { type => 'string',                         optional => 1 },
178                        host                    => { type => 'string',                         optional => 1 },
179                        js_url                  => { type => 'string',                         optional => 1 },
180                        logger                  => { type => 'object',                         optional => 1 },
181                        min_interval            => { type => 'number',  min => 0,              optional => 1 },
182                        ua                      => { type => 'object',                         optional => 1 },
183                        width                   => { type => 'string',                         optional => 1 },
184                        zoom                    => { type => 'integer', min => $ZOOM_MIN, max => $ZOOM_MAX, optional => 1 },
185                },
186        });
187
188        # Config file values override programmatic defaults (separation of config and code).
189
2323
698640
        $params = Object::Configure::configure($class, $params);
190
191        # Inject the resolved cache so the bless hash spreads it correctly.
192
2323
4239745
        $params->{cache} //= CHI->new(
193                driver     => 'Memory',
194                global     => 1,
195                expires_in => '1 day',
196        );
197
198        return bless {
199                # Defaults — spread of %{$params} below overrides each one when supplied.
200                coordinates             => [],
201                height                  => '400px',
202                host                    => $NOMINATIM_HOST,
203                width                   => '600px',
204                zoom                    => 12,
205                min_interval            => 0,
206                last_request            => 0,
207                cluster                 => 0,
208                css_url                 => $LEAFLET_CSS_URL,
209                js_url                  => $LEAFLET_JS_URL,
210                cluster_js_url          => $CLUSTER_JS_URL,
211                cluster_css_url         => $CLUSTER_CSS_URL,
212                cluster_default_css_url => $CLUSTER_DEFAULT_CSS_URL,
213                heatmap_js_url          => $HEATMAP_JS_URL,
214                gpx_js_url              => $GPX_JS_URL,
215
2323
2323
1691602
15237
                %{$params},
216        }, $class;
217}
218
219 - 272
=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
273
274sub add_marker
275{
276
182
2123
        my $self = shift;
277
182
128
        my ($params, $point);
278
279
182
424
        if(ref($_[0]) eq 'ARRAY') {
280
150
110
                $point  = shift;
281
150
218
                $params = Params::Get::get_params(undef, \@_) || {};
282                # Single-element arrayref is a wrapped address string
283
150
150
1687
199
                $point  = $point->[0] if scalar(@{$point}) == 1;
284        } elsif(blessed($_[0]) && $_[0]->can('latitude')) {
285                # Geo object as first positional arg: extract before Params::Get sees it,
286                # otherwise Params::Get mistakes the blessed hashref for the params hash.
287
5
5
                $point  = shift;
288
5
9
                $params = Params::Get::get_params(undef, \@_) || {};
289        } elsif(defined($_[0]) && !ref($_[0]) && scalar(@_) % 2 != 0) {
290                # Plain string as first positional arg, optionally followed by key-value pairs.
291                # An odd total count signals a leading positional; even count means all key-value.
292
17
16
                $point  = shift;
293
17
26
                $params = Params::Get::get_params(undef, \@_) || {};
294        } else {
295
10
19
                $params = Params::Get::get_params('point', @_);
296
9
132
                $point  = $params->{'point'};
297        }
298
299
181
366
        my ($lat, $lon);
300
301
181
223
        if(ref($point) eq 'ARRAY') {
302
146
146
99
192
                return 0 if scalar(@{$point}) != 2;
303
140
140
97
131
                ($lat, $lon) = @{$point};
304        } elsif(!ref($point)) {
305
26
64
                ($lat, $lon) = $self->_fetch_coordinates($point);
306        } elsif($point->can('latitude')) {
307
5
17
                ($lat, $lon) = ($point->latitude(), $point->longitude());
308        } else {
309
4
5
                my $msg = 'add_marker(): unknown point type: ' . ref($point);
310
4
26
                $self->{logger}->error($msg) if $self->{logger};
311
4
1083
                croak $msg;
312        }
313
314
169
370
        return 0 unless defined($lat) && defined($lon);
315
159
175
        return 0 unless _validate($lat, $lon);
316
317
149
149
136
268
        push @{$self->{coordinates}}, [$lat, $lon, $params->{'html'}, $params->{'icon'}];
318
149
243
        return 1;
319}
320
321 - 364
=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
365
366sub add_geojson
367{
368
46
271
        my $self   = shift;
369
46
38
        my $data   = shift;
370
46
74
        my $params = Params::Get::get_params(undef, \@_) || {};
371
372        # Accept either a pre-parsed structure or a raw JSON string
373
46
562
        $data = decode_json($data) if !ref($data);
374
375
41
41
32
90
        push @{$self->{geojson}}, { data => $data, opts => $params };
376
41
57
        return 1;
377}
378
379 - 423
=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],
    ]);

    # With intensity values (0..1) and custom radius/blur
    $map->add_heatmap(
        [ [51.5, -0.1, 0.9], [51.6, -0.2, 0.5], [51.4, 0.0, 0.2] ],
        radius => 30,
        blur   => 20,
    );

=cut
424
425sub add_heatmap
426{
427
33
1144
        my $self   = shift;
428
33
28
        my $points = shift;
429
33
50
        my $params = Params::Get::get_params(undef, \@_) || {};
430
431
33
551
        croak 'add_heatmap: points must be an arrayref' unless ref($points) eq 'ARRAY';
432
433
21
21
15
45
        push @{$self->{heatmap_layers}}, { points => $points, opts => $params };
434
21
47
        return 1;
435}
436
437 - 472
=head2 add_gpx

Add a GPX track to the map from a URL.

    $map->add_gpx('https://example.com/track.gpx');

The map view is auto-fitted to the track bounds after loading.
Requires the leaflet-gpx plugin (C<gpx_js_url>).
Returns 1 on success.

=head3 API SPECIFICATION

=head4 INPUT

  url : string  URL of the GPX file (required)

=head4 OUTPUT

  { type => integer, value => 1 }

=head3 MESSAGES

  | Message              | Meaning / Resolution       |
  |----------------------|----------------------------|
  | add_gpx: url required | No URL argument supplied  |

=head3 EXAMPLES

    # Add a GPX track from a public URL; the map auto-fits to its bounds
    $map->add_gpx('https://example.com/route.gpx');

    # Multiple tracks on the same map
    $map->add_gpx('https://example.com/morning-run.gpx');
    $map->add_gpx('https://example.com/evening-walk.gpx');

=cut
473
474sub add_gpx
475{
476
26
259
        my $self   = shift;
477
26
43
        my $params = Params::Get::get_params('url', \@_);
478
22
286
        my $url    = $params->{'url'};
479
480
22
81
        croak 'add_gpx: url is required' unless $url;
481
482
18
18
14
23
        push @{$self->{gpx_tracks}}, $url;
483
18
29
        return 1;
484}
485
486 - 542
=head2 add_choropleth

Add a choropleth (data-driven colour fill) layer to the map.

    $map->add_choropleth(
        \@geojson_features,
        { England => 100, Scotland => 80, Wales => 60 },
        key   => 'name',
        scale => ['#ffffcc', '#a1dab4', '#41b6c4', '#2c7fb8', '#253494'],
    );

Colours are pre-computed in Perl and baked into the emitted JavaScript.
No extra browser plugin is required.
Returns 1 on success.

=head3 API SPECIFICATION

=head4 INPUT

  features : arrayref of GeoJSON Feature hashrefs  (required)
  values   : hashref  { feature_property_value => numeric_value }  (required)
  key      : string   feature property to match against values  (default: 'name')
  scale    : arrayref hex-colour strings low-to-high  (default: 5-step YlGnBu)

=head4 OUTPUT

  { type => integer, value => 1 }

=head3 MESSAGES

  | Message                                  | Meaning / Resolution                   |
  |------------------------------------------|----------------------------------------|
  | add_choropleth: features must be arrayref | First argument is not an arrayref      |
  | add_choropleth: values must be hashref    | Second argument is not a hashref       |

=head3 EXAMPLES

    # Default 5-step YlGnBu scale, key property "name"
    $map->add_choropleth(
        \@geojson_features,
        { England => 100, Scotland => 80, Wales => 60 },
    );

    # Custom 3-step scale and a different GeoJSON property as the key
    $map->add_choropleth(
        \@geojson_features,
        { England => 100, Scotland => 80, Wales => 60 },
        key   => 'country',
        scale => ['#f7fbff', '#6baed6', '#08519c'],
    );

    # choropleth-only map — center() must be called explicitly
    $map->center([54.0, -2.0]);
    $map->add_choropleth(\@uk_features, \%population_by_region);
    my ($head, $body) = $map->onload_render();

=cut
543
544sub add_choropleth
545{
546
49
1778
        my $self     = shift;
547
49
35
        my $features = shift;
548
49
30
        my $values   = shift;
549
49
68
        my $params   = Params::Get::get_params(undef, \@_) || {};
550
551
49
663
        croak 'add_choropleth: features must be an arrayref' unless ref($features) eq 'ARRAY';
552
42
100
        croak 'add_choropleth: values must be a hashref'     unless ref($values)   eq 'HASH';
553
554
37
59
        my $key   = $params->{key}   || 'name';
555
37
64
        my $scale = $params->{scale} || ['#ffffcc', '#a1dab4', '#41b6c4', '#2c7fb8', '#253494'];
556
557        # Compute colour index for each feature in Perl so the browser needs no
558        # extra maths — the resulting lookup object is baked into the JS.
559
37
29
37
31
30
70
        my @sorted_vals = sort { $a <=> $b } values %{$values};
560
37
76
        my ($min, $max) = ($sorted_vals[0] // 0, $sorted_vals[-1] // 0);
561
37
38
        $max = $min + 1 if $max == $min;    # avoid division by zero for single-value sets
562
563
37
29
        my %colors;
564
37
92
24
103
        while(my ($k, $v) = each %{$values}) {
565
55
55
46
45
                my $idx = int(($v - $min) / ($max - $min) * $#{$scale});
566
55
1
55
29
0
48
                $idx = $#{$scale} if $idx > $#{$scale};
567
55
60
                $colors{$k} = $scale->[$idx];
568        }
569
570
37
37
24
88
        push @{$self->{choropleth_layers}}, {
571                features => $features,
572                values   => $values,
573                colors   => \%colors,
574                key      => $key,
575        };
576
37
73
        return 1;
577}
578
579 - 623
=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
624
625sub center
626{
627
67
2132
        my $self   = shift;
628
67
111
        my $params = Params::Get::get_params('point', \@_);
629
62
844
        my $point  = $params->{'point'};
630
631
62
106
        croak 'center(): usage: point => [ latitude, longitude ]' unless defined($point);
632
633
60
65
        my ($lat, $lon);
634
635
60
140
        if(ref($point) eq 'ARRAY') {
636                croak 'center(): point must have latitude and longitude'
637
49
49
37
139
                        if scalar(@{$point}) != 2;
638
42
42
29
49
                ($lat, $lon) = @{$point};
639        } elsif(ref($point) && $point->can('latitude')) {
640
2
4
                ($lat, $lon) = ($point->latitude(), $point->longitude());
641        } elsif(!ref($point)) {
642
5
9
                ($lat, $lon) = $self->_fetch_coordinates($point);
643        } else {
644
4
13
                my $msg = 'center(): unknown point type: ' . ref($point);
645
4
17
                $self->{logger}->error($msg) if $self->{logger};
646
4
1067
                croak $msg;
647        }
648
649
49
122
        return 0 unless defined($lat) && defined($lon);
650
46
59
        return 0 unless _validate($lat, $lon);
651
652
42
69
        $self->{'center'} = [$lat, $lon];
653
42
65
        return 1;
654}
655
656 - 696
=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

    # Setter: store the zoom level
    $map->zoom(14);

    # Getter: retrieve the current level
    my $level = $map->zoom();
    print "Current zoom: $level\n";    # 14

    # Setter return value equals the new level
    my $confirmed = $map->zoom(10);
    die 'unexpected' unless $confirmed == 10;

    # Chain: set via new(), read back via zoom()
    my $m = HTML::OSM->new(zoom => 6);
    $m->zoom($m->zoom() + 2);   # nudge up by 2

=cut
697
698sub zoom
699{
700
1261
4882
        my $self = shift;
701
702
1261
1154
        if(scalar(@_)) {
703
1230
1634
                my $params = Params::Validate::Strict::validate_strict({
704                        args   => Params::Get::get_params('zoom', \@_),
705                        schema => {
706                                zoom => { optional => 1, type => 'integer', min => $ZOOM_MIN, max => $ZOOM_MAX },
707                        },
708                });
709
486
46544
                $self->{'zoom'} = $params->{'zoom'} if defined($params->{'zoom'});
710        }
711
712
517
1246
        return $self->{'zoom'};
713}
714
715 - 795
=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
796
797sub onload_render
798{
799
149
3782
        my $self = shift;
800
801
149
184
        my $height            = $self->{'height'} || '400px';
802
149
166
        my $width             = $self->{'width'}  || '600px';
803
149
159
        my $coordinates       = $self->{coordinates}        || [];
804
149
227
        my $geojson_layers    = $self->{geojson}             || [];
805
149
233
        my $heatmap_layers    = $self->{heatmap_layers}      || [];
806
149
196
        my $gpx_tracks        = $self->{gpx_tracks}          || [];
807
149
194
        my $choropleth_layers = $self->{choropleth_layers}   || [];
808
809
149
283
        unless(@$coordinates || @$geojson_layers || @$heatmap_layers
810                             || @$gpx_tracks     || @$choropleth_layers) {
811
8
39
                $self->{logger}->error('No map data provided') if $self->{logger};
812
8
2450
                croak 'No map data provided';
813        }
814
815        # Geocode address strings; validate and discard bad numeric pairs.
816
141
82
        my @valid_coordinates;
817
141
139
        for my $coord (@$coordinates) {
818
125
130
                my ($lat, $lon, $label, $icon_url) = @$coord;
819
125
181
                if(!defined $lat || !defined $lon) {
820
3
5
                        ($lat, $lon) = $self->_fetch_coordinates($label);
821                }
822                # Validate ALL coordinates here — including geocoder-returned ones.
823                # A compromised geocoder or Nominatim response could return a crafted
824                # lat/lon string that would inject JS if embedded without validation.
825
125
208
                next unless defined($lat) && defined($lon) && _validate($lat, $lon);
826
119
169
                push @valid_coordinates, [$lat, $lon, $label, $icon_url];
827        }
828
829        # Determine map centre: caller-set wins; else compute from marker bounds.
830
141
88
        my ($center_lat, $center_lon);
831
141
158
        if($self->{'center'}) {
832
35
35
23
40
                ($center_lat, $center_lon) = @{$self->{'center'}};
833        } elsif(@valid_coordinates) {
834
98
131
                my ($min_lat, $min_lon, $max_lat, $max_lon) = (90, 180, -90, -180);
835
98
101
                for my $c (@valid_coordinates) {
836
113
151
                        $min_lat = $c->[0] if $c->[0] < $min_lat;
837
113
128
                        $max_lat = $c->[0] if $c->[0] > $max_lat;
838
113
104
                        $min_lon = $c->[1] if $c->[1] < $min_lon;
839
113
129
                        $max_lon = $c->[1] if $c->[1] > $max_lon;
840                }
841
98
87
                $center_lat = ($min_lat + $max_lat) / 2;
842
98
70
                $center_lon = ($min_lon + $max_lon) / 2;
843        } else {
844
8
120
                croak 'center() must be called when no point markers are provided';
845        }
846
847        # --- <head> ---
848
133
161
        my $head = qq{
849                <link rel="stylesheet" href="$self->{css_url}" />
850                <script src="$self->{js_url}"></script>
851        };
852
853
133
133
        if($self->{cluster}) {
854
8
14
                $head .= qq{
855                <link rel="stylesheet" href="$self->{cluster_css_url}" />
856                <link rel="stylesheet" href="$self->{cluster_default_css_url}" />
857                <script src="$self->{cluster_js_url}"></script>
858                };
859        }
860
133
124
        $head .= qq{\t\t<script src="$self->{heatmap_js_url}"></script>\n} if @$heatmap_layers;
861
133
112
        $head .= qq{\t\t<script src="$self->{gpx_js_url}"></script>\n}     if @$gpx_tracks;
862
863
133
150
        $head .= qq{
864                <style>
865                        #map { width: $width; height: $height; }
866                        #search-box { margin: 10px; padding: 5px; }
867                        #reset-button, #clear-search-button { margin: 10px; padding: 5px; cursor: pointer; }
868                </style>
869        };
870
871        # --- <body> ---
872
133
302
        my $body = qq{
873                <input type="text" id="search-box" placeholder="Enter location">
874                <button id="clear-search-button">Clear search markers</button>
875                <button id="reset-button">Reset Map</button>
876                <div id="map"></div>
877                <script>
878                        var map = L.map('map').setView([$center_lat, $center_lon], $self->{zoom});
879                        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
880                                attribution: '&copy; OpenStreetMap contributors'
881                        }).addTo(map);
882
883                        var searchMarkers = [];
884        };
885
886        # Point markers — optionally grouped into a cluster layer.
887
133
151
        if(@valid_coordinates) {
888
102
90
                $body .= "\t\t\tvar clusterGroup = L.markerClusterGroup();\n" if $self->{cluster};
889
890
102
86
                for my $coord (@valid_coordinates) {
891
119
141
                        my ($lat, $lon, $label, $icon_url) = @$coord;
892
119
112
                        my $js_label = _js_string($label);
893
119
158
                        if($icon_url) {
894
4
10
                                my $js_icon = _js_string($icon_url);
895                                my $add_cmd = $self->{cluster}
896
4
9
                                        ? 'clusterGroup.addLayer(m);'
897                                        : 'm.addTo(map);';
898
4
11
                                $body .= qq{
899                        (function() {
900                                var icon = L.icon({ iconUrl: '$js_icon', iconAnchor: [16,32], popupAnchor: [0,-32] });
901                                var m = L.marker([$lat, $lon], { icon: icon }).bindPopup('$js_label');
902                                $add_cmd
903                        })();
904                                };
905                        } elsif($self->{cluster}) {
906
11
37
                                $body .= "\t\t\tclusterGroup.addLayer(L.marker([$lat, $lon]).bindPopup('$js_label'));\n";
907                        } else {
908
104
215
                                $body .= "\t\t\tL.marker([$lat, $lon]).addTo(map).bindPopup('$js_label');\n";
909                        }
910                }
911
912
102
98
                $body .= "\t\t\tmap.addLayer(clusterGroup);\n" if $self->{cluster};
913        }
914
915        # GeoJSON layers.
916
133
113
        for my $layer (@$geojson_layers) {
917
23
42
                my $json     = _html_json($layer->{data});
918
22
31
                my $opts     = $layer->{opts} || {};
919
22
19
                my $style_js = '';
920
22
16
                my $popup_js = '';
921
22
30
                if(my $style = $opts->{style}) {
922
11
11
                        $style_js = 'style: ' . _html_json($style) . ',';
923                }
924
22
29
                if(my $prop = $opts->{popup}) {
925
4
5
                        my $js_prop = _js_string($prop);
926
4
5
                        $popup_js = "onEachFeature: function(f,l){ if(f.properties && f.properties['$js_prop']){ l.bindPopup(String(f.properties['$js_prop'])); } },";
927                }
928
22
37
                $body .= "\t\t\tL.geoJSON($json, { $style_js $popup_js }).addTo(map);\n";
929        }
930
931        # Heatmap layers.
932
132
120
        for my $layer (@$heatmap_layers) {
933
12
19
                my $pts    = _html_json($layer->{points});
934
12
15
                my $opts   = $layer->{opts} || {};
935
12
23
                my $radius = $opts->{radius} || 25;
936
12
39
                my $blur   = $opts->{blur}   || 15;
937
12
20
                $body .= "\t\t\tL.heatLayer($pts, { radius: $radius, blur: $blur }).addTo(map);\n";
938        }
939
940        # GPX tracks — browser fetches the file; fitBounds called on load.
941
132
109
        for my $url (@$gpx_tracks) {
942
10
11
                my $js_url = _js_string($url);
943
10
14
                $body .= "\t\t\tnew L.GPX('$js_url', { async: true }).on('loaded', function(e){ map.fitBounds(e.target.getBounds()); }).addTo(map);\n";
944        }
945
946        # Choropleth layers — colours are pre-baked; no browser-side scale maths.
947
132
106
        for my $layer (@$choropleth_layers) {
948
21
43
                my $fc_json     = _html_json({ type => 'FeatureCollection', features => $layer->{features} });
949
21
31
                my $colors_json = _html_json($layer->{colors});
950
21
21
                my $values_json = _html_json($layer->{values});
951
21
25
                my $js_key      = _js_string($layer->{key});
952
21
60
                $body .= qq{
953                        (function() {
954                                var choroplethColors = $colors_json;
955                                var choroplethValues = $values_json;
956                                L.geoJSON($fc_json, {
957                                        style: function(f) {
958                                                var k = f.properties && f.properties['$js_key'];
959                                                return { fillColor: choroplethColors[k] || '#cccccc',
960                                                         weight: 2, opacity: 1, color: 'white',
961                                                         dashArray: '3', fillOpacity: 0.7 };
962                                        },
963                                        onEachFeature: function(f, l) {
964                                                var k = f.properties && f.properties['$js_key'];
965                                                if(k && choroplethValues[k] !== undefined) {
966                                                        l.bindPopup(k + ': ' + choroplethValues[k]);
967                                                }
968                                        }
969                                }).addTo(map);
970                        })();
971                };
972        }
973
974        # Event handlers.
975
132
370
        $body .= qq{
976                        document.getElementById('reset-button').addEventListener('click', function() {
977                                map.setView([$center_lat, $center_lon], $self->{zoom});
978                        });
979
980                        document.getElementById('clear-search-button').addEventListener('click', function() {
981                                searchMarkers.forEach(function(m) { map.removeLayer(m); });
982                                searchMarkers = [];
983                        });
984
985                        document.getElementById('search-box').addEventListener('keyup', function(event) {
986                                if(event.key === 'Enter') {
987                                        var query = event.target.value.trim();
988                                        if(!query) { alert('Please enter a valid location.'); return; }
989                                        fetch('https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(query))
990                                        .then(function(r) { return r.json(); })
991                                        .then(function(data) {
992                                                if(data.length > 0) {
993                                                        var lat = data[0].lat, lon = data[0].lon;
994                                                        map.setView([lat, lon], 14);
995                                                        searchMarkers.push(L.marker([lat, lon]).addTo(map).bindPopup(query).openPopup());
996                                                } else {
997                                                        alert('No results found. Try a different location.');
998                                                }
999                                        })
1000                                        .catch(function(err) {
1001                                                console.error('Search error:', err);
1002                                                alert('Failed to fetch location. Please check your internet connection.');
1003                                        });
1004                                }
1005                        });
1006                </script>
1007        };
1008
1009
132
293
        return ($head, $body);
1010}
1011
1012# _fetch_coordinates: Resolve a place-name string to a (lat, lon) pair.
1013# Purpose: Centralise all geocoding logic — try the injected geocoder first,
1014#          fall back to a direct Nominatim HTTP call with caching and rate-limiting.
1015# Entry:   $self, $location (non-empty string).
1016# Exit:    ($lat, $lon) strings on success; (undef, undef) on failure.
1017# Side Effects: May sleep to honour min_interval; writes to $self->{cache}.
1018sub _fetch_coordinates
1019{
1020
55
5678
        my ($self, $location) = @_;
1021
1022
55
139
        croak 'address not given to _fetch_coordinates' unless $location;
1023
1024        # Prefer an injected geocoder (e.g. Geo::Coder::List) to avoid HTTP.
1025
51
129
        if(my $geocoder = $self->{'geocoder'}) {
1026
33
55
                my $rc = $geocoder->geocode($location);
1027
33
238
                return (undef, undef) unless defined $rc;
1028
1029
25
79
                if(blessed($rc) && $rc->can('latitude')) {
1030
2
4
                        return ($rc->latitude(), $rc->longitude());
1031                }
1032
23
44
                if(ref($rc) eq 'HASH') {
1033                        return ($rc->{lat}, $rc->{lon})
1034
20
77
                                if defined($rc->{lat}) && defined($rc->{lon});
1035                        return ($rc->{geometry}{location}{lat}, $rc->{geometry}{location}{lng})
1036
4
13
                                if defined($rc->{geometry}{location}{lat});
1037                }
1038                # Some geocoders return a flat [lat, lon] arrayref
1039
5
2
8
4
                return @{$rc} if ref($rc) eq 'ARRAY';
1040
1041                # Unrecognised return type — treat as failure
1042
3
45
                carp '_fetch_coordinates: unrecognised geocoder result type: ' . ref($rc);
1043
3
644
                return (undef, undef);
1044        }
1045
1046        # Direct Nominatim path: apply caching and rate-limiting.
1047
18
38
        my $cache_key = 'osm:' . uri_escape_utf8($location);
1048
18
308
        if(my $cached = $self->{cache}->get($cache_key)) {
1049
4
439
                return ($cached->{lat}, $cached->{lon});
1050        }
1051
1052        # Honour the minimum interval between outbound requests.
1053
14
711
        my $elapsed = time() - $self->{last_request};
1054
14
28
        if($elapsed < $self->{min_interval}) {
1055
3
999230
                Time::HiRes::sleep($self->{min_interval} - $elapsed);
1056        }
1057
1058
14
65
        my $ua = $self->{'ua'}
1059                || LWP::UserAgent->new(agent => __PACKAGE__ . "/$VERSION");
1060
14
43
        $ua->default_header(accept_encoding => 'gzip,deflate');
1061
14
143
        $ua->env_proxy(1);
1062
1063
14
2831
        my $url      = 'https://' . $self->{'host'} . '?format=json&q=' . uri_escape_utf8($location);
1064
14
161
        my $response = $ua->get($url);
1065
14
141
        $self->{'last_request'} = time();
1066
1067
14
26
        if($response->is_success()) {
1068                # eval guard: Nominatim normally returns valid JSON, but a maintenance
1069                # page or rate-limit response could return HTML with a 200 OK.  Dying
1070                # inside _fetch_coordinates would bubble uncaught to add_marker / onload_render.
1071
11
11
53
22
                my $data = eval { decode_json($response->decoded_content()) };
1072
11
343
                if($@) {
1073
2
32
                        carp "_fetch_coordinates: failed to decode Nominatim response: $@";
1074
2
415
                        return (undef, undef);
1075                }
1076
9
16
                $data = $data->[0] if ref($data) eq 'ARRAY';
1077
9
26
                if(ref($data) eq 'HASH' && defined($data->{lat})) {
1078
8
23
                        $self->{'cache'}->set($cache_key, $data);
1079
8
1930
                        return ($data->{lat}, $data->{lon});
1080                }
1081        }
1082
1083
4
26
        return (undef, undef);
1084}
1085
1086# _validate: Check that a (lat, lon) pair is numeric and within WGS-84 bounds.
1087# Purpose: Single guard point for all coordinate ingestion; emits a carp when
1088#          both values are defined but wrong so the caller can log without dying.
1089# Entry:   ($lat, $lon) — any scalar, including undef.
1090# Exit:    1 if valid, 0 if not.
1091# Side Effects: carp when coordinates are defined but out-of-range/non-numeric.
1092sub _validate
1093{
1094
365
24652
        my ($lat, $lon) = @_;
1095
1096        # Require at least one digit (rejects empty string — unlike \d* which matches '').
1097        # Leading-decimal notation (e.g. -.5167) is valid per Changes 0.05.
1098        # \z (not $) prevents a trailing \n from sneaking through: $ matches before
1099        # a final newline, which would make "0\n" valid and embed a newline in JS.
1100
365
403
        my $numeric = qr/^-?(?:\d+(?:\.\d+)?|\.\d+)\z/;
1101
1102
365
3160
        my $ok = defined($lat) && defined($lon)
1103              && $lat =~ $numeric && $lon =~ $numeric
1104              && $lat >= $LAT_MIN && $lat <= $LAT_MAX
1105              && $lon >= $LON_MIN && $lon <= $LON_MAX;
1106
1107
365
845
        carp(sprintf 'Skipping invalid coordinate: (%s, %s)',
1108             $lat // 'undef', $lon // 'undef')
1109                if !$ok && defined($lat) && defined($lon);
1110
1111
365
5188
        return $ok ? 1 : 0;
1112}
1113
1114# _html_json: Encode data as JSON and make the result safe for embedding in a
1115# <script> block.  JSON encoders do not escape '/' by default, so a value like
1116# '</script>' would close the enclosing script tag and allow HTML injection.
1117# Escaping every '</' as '<\/' prevents this without altering the decoded value.
1118# Entry:   Any Perl data structure accepted by encode_json.
1119# Exit:    JSON string safe for direct insertion inside a <script> block.
1120sub _html_json
1121{
1122
114
103569
        my $j = encode_json(shift);
1123
113
1527
        $j =~ s|</|<\\/|g;
1124
113
103
        return $j;
1125}
1126
1127# _js_string: Escape a Perl string for safe embedding in a JS single-quoted literal.
1128# Purpose: Prevent JS injection via user-supplied labels, URLs, or property names.
1129# Entry:   Any scalar (undef becomes '').
1130# Exit:    Escaped string safe for insertion between JS single quotes.
1131sub _js_string
1132{
1133
175
116812
        my $s = shift // '';
1134
175
134
        $s =~ s/\\/\\\\/g;    # escape backslash first to avoid double-escaping
1135
175
120
        $s =~ s/'/\\'/g;      # escape our JS string delimiter
1136
175
126
        $s =~ s/\r?\n/\\n/g;  # newlines would break the JS string
1137
175
102
        $s =~ s|</|<\\/|g;    # prevent </script> injection
1138
175
145
        return $s;
1139}
1140
1141 - 1310
=head1 LIMITATIONS

=over 4

=item * B<Per-marker removal>: Markers added via C<add_marker()> cannot yet be
removed individually by clicking them.  The "Clear search markers" button only
removes markers added by the in-page Nominatim search box.

=item * B<Clone validation>: The clone path (C<< $obj->new(%overrides) >>)
bypasses the Params::Validate::Strict schema so subclasses and internal callers
can merge arbitrary state.  Callers are responsible for passing valid overrides.

=item * B<Config-file params unvalidated>: Keys injected by
L<Object::Configure> from a config file are not re-run through the schema, so
a malformed config file can introduce invalid types at runtime.

=item * B<Private-method encapsulation>: C<_fetch_coordinates>, C<_validate>,
and C<_js_string> are named with a leading underscore by convention only.
Using L<Sub::Private> in C<enforce> mode would make the contract explicit, but
that module is not yet listed as a dependency to avoid breaking white-box tests
in C<t/mock.t>.

=item * B<Routing>: Turn-by-turn routing (Leaflet Routing Machine / OSRM) is
explicitly out of scope for this module and will not be added here.

=back

=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

=head1 BUGS

Please report bugs at L<https://github.com/nigelhorne/HTML-OSM/issues>.

=head1 SEE ALSO

=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

=head1 LICENSE AND COPYRIGHT

Copyright 2025-2026 Nigel Horne.

This program is released under the following licence: GPL2
If you use it,
please let me know.

=cut
1311
13121;
1313