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
952564
8
128
use strict;
6
12
12
12
15
9
232
use warnings;
7
12
12
12
922
29290
36
use autodie qw(:all);
8
9
12
12
12
49087
15
266
use Carp qw(carp croak);
10
12
12
12
2535
287712
168
use CHI;
11
12
12
12
27
9
318
use JSON::MaybeXS qw(decode_json encode_json);
12
12
12
12
4066
236661
215
use LWP::UserAgent;
13
12
12
12
3497
617486
223
use Object::Configure 0.15;
14
12
12
12
30
51
203
use Params::Get 0.13;
15
12
12
12
19
65
156
use Params::Validate::Strict 0.28;
16
12
12
12
16
13
179
use Readonly;
17
12
12
12
15
7
734
use Scalar::Util qw(blessed);
18
12
12
12
19
14
30
use Time::HiRes qw(time);
19
12
12
12
495
10
21135
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
4186
1334161
        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
4186
11960
        if(defined($class) && !blessed($class) && !UNIVERSAL::isa($class, __PACKAGE__)) {
150
3
5
                unshift @_, $class;
151
3
4
                $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
4186
4044
        if(blessed($class)) {
157
10
17
                my $extra = Params::Get::get_params(undef, \@_) || {};
158
10
10
10
129
18
42
                return bless { %{$class}, %{$extra} }, ref($class);
159        }
160
161
4176
3359
        $class //= __PACKAGE__;
162
163
4176
5442
        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
2326
764172
        $params = Object::Configure::configure($class, $params);
190
191        # Inject the resolved cache so the bless hash spreads it correctly.
192
2326
3911740
        $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
2326
2326
1594180
13707
                %{$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
1961
        my $self = shift;
277
182
138
        my ($params, $point);
278
279
182
350
        if(ref($_[0]) eq 'ARRAY') {
280
150
109
                $point  = shift;
281
150
200
                $params = Params::Get::get_params(undef, \@_) || {};
282                # Single-element arrayref is a wrapped address string
283
150
150
1553
171
                $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
19
                $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
12
                $point  = shift;
293
17
24
                $params = Params::Get::get_params(undef, \@_) || {};
294        } else {
295
10
20
                $params = Params::Get::get_params('point', @_);
296
9
139
                $point  = $params->{'point'};
297        }
298
299
181
346
        my ($lat, $lon);
300
301
181
229
        if(ref($point) eq 'ARRAY') {
302
146
146
79
167
                return 0 if scalar(@{$point}) != 2;
303
140
140
87
126
                ($lat, $lon) = @{$point};
304        } elsif(!ref($point)) {
305
26
44
                ($lat, $lon) = $self->_fetch_coordinates($point);
306        } elsif($point->can('latitude')) {
307
5
8
                ($lat, $lon) = ($point->latitude(), $point->longitude());
308        } else {
309
4
13
                my $msg = 'add_marker(): unknown point type: ' . ref($point);
310
4
18
                $self->{logger}->error($msg) if $self->{logger};
311
4
1107
                croak $msg;
312        }
313
314
169
372
        return 0 unless defined($lat) && defined($lon);
315
159
167
        return 0 unless _validate($lat, $lon);
316
317
149
149
99
238
        push @{$self->{coordinates}}, [$lat, $lon, $params->{'html'}, $params->{'icon'}];
318
149
235
        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
228
        my $self   = shift;
369
46
37
        my $data   = shift;
370
46
57
        my $params = Params::Get::get_params(undef, \@_) || {};
371
372        # Accept either a pre-parsed structure or a raw JSON string
373
46
548
        $data = decode_json($data) if !ref($data);
374
375
41
41
31
66
        push @{$self->{geojson}}, { data => $data, opts => $params };
376
41
53
        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
939
        my $self   = shift;
428
33
25
        my $points = shift;
429
33
39
        my $params = Params::Get::get_params(undef, \@_) || {};
430
431
33
449
        croak 'add_heatmap: points must be an arrayref' unless ref($points) eq 'ARRAY';
432
433
21
21
16
35
        push @{$self->{heatmap_layers}}, { points => $points, opts => $params };
434
21
48
        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
194
        my $self   = shift;
477
26
35
        my $params = Params::Get::get_params('url', \@_);
478
22
255
        my $url    = $params->{'url'};
479
480
22
75
        croak 'add_gpx: url is required' unless $url;
481
482
18
18
13
22
        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
1601
        my $self     = shift;
547
49
35
        my $features = shift;
548
49
32
        my $values   = shift;
549
49
58
        my $params   = Params::Get::get_params(undef, \@_) || {};
550
551
49
593
        croak 'add_choropleth: features must be an arrayref' unless ref($features) eq 'ARRAY';
552
42
99
        croak 'add_choropleth: values must be a hashref'     unless ref($values)   eq 'HASH';
553
554
37
61
        my $key   = $params->{key}   || 'name';
555
37
62
        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
27
37
30
28
60
        my @sorted_vals = sort { $a <=> $b } values %{$values};
560
37
71
        my ($min, $max) = ($sorted_vals[0] // 0, $sorted_vals[-1] // 0);
561
37
41
        $max = $min + 1 if $max == $min;    # avoid division by zero for single-value sets
562
563
37
22
        my %colors;
564
37
92
31
112
        while(my ($k, $v) = each %{$values}) {
565
55
55
45
63
                my $idx = int(($v - $min) / ($max - $min) * $#{$scale});
566
55
1
55
28
1
50
                $idx = $#{$scale} if $idx > $#{$scale};
567
55
58
                $colors{$k} = $scale->[$idx];
568        }
569
570
37
37
25
73
        push @{$self->{choropleth_layers}}, {
571                features => $features,
572                values   => $values,
573                colors   => \%colors,
574                key      => $key,
575        };
576
37
66
        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
1846
        my $self   = shift;
628
67
98
        my $params = Params::Get::get_params('point', \@_);
629
62
792
        my $point  = $params->{'point'};
630
631
62
102
        croak 'center(): usage: point => [ latitude, longitude ]' unless defined($point);
632
633
60
97
        my ($lat, $lon);
634
635
60
118
        if(ref($point) eq 'ARRAY') {
636                croak 'center(): point must have latitude and longitude'
637
49
49
43
149
                        if scalar(@{$point}) != 2;
638
42
42
30
40
                ($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
5
                my $msg = 'center(): unknown point type: ' . ref($point);
645
4
15
                $self->{logger}->error($msg) if $self->{logger};
646
4
992
                croak $msg;
647        }
648
649
49
136
        return 0 unless defined($lat) && defined($lon);
650
46
55
        return 0 unless _validate($lat, $lon);
651
652
42
54
        $self->{'center'} = [$lat, $lon];
653
42
68
        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
1268
4461
        my $self = shift;
701
702
1268
1195
        if(scalar(@_)) {
703
1237
1481
                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
493
47445
                $self->{'zoom'} = $params->{'zoom'} if defined($params->{'zoom'});
710        }
711
712
524
992
        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
3323
        my $self = shift;
800
801
149
169
        my $height            = $self->{'height'} || '400px';
802
149
155
        my $width             = $self->{'width'}  || '600px';
803
149
137
        my $coordinates       = $self->{coordinates}        || [];
804
149
211
        my $geojson_layers    = $self->{geojson}             || [];
805
149
187
        my $heatmap_layers    = $self->{heatmap_layers}      || [];
806
149
180
        my $gpx_tracks        = $self->{gpx_tracks}          || [];
807
149
174
        my $choropleth_layers = $self->{choropleth_layers}   || [];
808
809
149
255
        unless(@$coordinates || @$geojson_layers || @$heatmap_layers
810                             || @$gpx_tracks     || @$choropleth_layers) {
811
8
32
                $self->{logger}->error('No map data provided') if $self->{logger};
812
8
2399
                croak 'No map data provided';
813        }
814
815        # Geocode address strings; validate and discard bad numeric pairs.
816
141
89
        my @valid_coordinates;
817
141
126
        for my $coord (@$coordinates) {
818
125
120
                my ($lat, $lon, $label, $icon_url) = @$coord;
819
125
188
                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
195
                next unless defined($lat) && defined($lon) && _validate($lat, $lon);
826
119
148
                push @valid_coordinates, [$lat, $lon, $label, $icon_url];
827        }
828
829        # Determine map centre: caller-set wins; else compute from marker bounds.
830
141
89
        my ($center_lat, $center_lon);
831
141
158
        if($self->{'center'}) {
832
35
35
22
37
                ($center_lat, $center_lon) = @{$self->{'center'}};
833        } elsif(@valid_coordinates) {
834
98
94
                my ($min_lat, $min_lon, $max_lat, $max_lon) = (90, 180, -90, -180);
835
98
69
                for my $c (@valid_coordinates) {
836
113
162
                        $min_lat = $c->[0] if $c->[0] < $min_lat;
837
113
113
                        $max_lat = $c->[0] if $c->[0] > $max_lat;
838
113
94
                        $min_lon = $c->[1] if $c->[1] < $min_lon;
839
113
119
                        $max_lon = $c->[1] if $c->[1] > $max_lon;
840                }
841
98
79
                $center_lat = ($min_lat + $max_lat) / 2;
842
98
69
                $center_lon = ($min_lon + $max_lon) / 2;
843        } else {
844
8
91
                croak 'center() must be called when no point markers are provided';
845        }
846
847        # --- <head> ---
848
133
145
        my $head = qq{
849                <link rel="stylesheet" href="$self->{css_url}" />
850                <script src="$self->{js_url}"></script>
851        };
852
853
133
121
        if($self->{cluster}) {
854
8
13
                $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
133
        $head .= qq{\t\t<script src="$self->{heatmap_js_url}"></script>\n} if @$heatmap_layers;
861
133
99
        $head .= qq{\t\t<script src="$self->{gpx_js_url}"></script>\n}     if @$gpx_tracks;
862
863
133
127
        $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
309
        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
128
        if(@valid_coordinates) {
888
102
93
                $body .= "\t\t\tvar clusterGroup = L.markerClusterGroup();\n" if $self->{cluster};
889
890
102
66
                for my $coord (@valid_coordinates) {
891
119
102
                        my ($lat, $lon, $label, $icon_url) = @$coord;
892
119
101
                        my $js_label = _js_string($label);
893
119
126
                        if($icon_url) {
894
4
5
                                my $js_icon = _js_string($icon_url);
895                                my $add_cmd = $self->{cluster}
896
4
41
                                        ? 'clusterGroup.addLayer(m);'
897                                        : 'm.addTo(map);';
898
4
13
                                $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
23
                                $body .= "\t\t\tclusterGroup.addLayer(L.marker([$lat, $lon]).bindPopup('$js_label'));\n";
907                        } else {
908
104
182
                                $body .= "\t\t\tL.marker([$lat, $lon]).addTo(map).bindPopup('$js_label');\n";
909                        }
910                }
911
912
102
89
                $body .= "\t\t\tmap.addLayer(clusterGroup);\n" if $self->{cluster};
913        }
914
915        # GeoJSON layers.
916
133
100
        for my $layer (@$geojson_layers) {
917
23
35
                my $json     = _html_json($layer->{data});
918
22
30
                my $opts     = $layer->{opts} || {};
919
22
19
                my $style_js = '';
920
22
13
                my $popup_js = '';
921
22
26
                if(my $style = $opts->{style}) {
922
11
10
                        $style_js = 'style: ' . _html_json($style) . ',';
923                }
924
22
27
                if(my $prop = $opts->{popup}) {
925
4
4
                        my $js_prop = _js_string($prop);
926
4
6
                        $popup_js = "onEachFeature: function(f,l){ if(f.properties && f.properties['$js_prop']){ l.bindPopup(String(f.properties['$js_prop'])); } },";
927                }
928
22
33
                $body .= "\t\t\tL.geoJSON($json, { $style_js $popup_js }).addTo(map);\n";
929        }
930
931        # Heatmap layers.
932
132
105
        for my $layer (@$heatmap_layers) {
933
12
13
                my $pts    = _html_json($layer->{points});
934
12
17
                my $opts   = $layer->{opts} || {};
935
12
20
                my $radius = $opts->{radius} || 25;
936
12
20
                my $blur   = $opts->{blur}   || 15;
937
12
23
                $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
102
        for my $url (@$gpx_tracks) {
942
10
8
                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
39
                my $fc_json     = _html_json({ type => 'FeatureCollection', features => $layer->{features} });
949
21
27
                my $colors_json = _html_json($layer->{colors});
950
21
24
                my $values_json = _html_json($layer->{values});
951
21
23
                my $js_key      = _js_string($layer->{key});
952
21
72
                $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
358
        $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
280
        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
5499
        my ($self, $location) = @_;
1021
1022
55
123
        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
116
        if(my $geocoder = $self->{'geocoder'}) {
1026
33
42
                my $rc = $geocoder->geocode($location);
1027
33
224
                return (undef, undef) unless defined $rc;
1028
1029
25
61
                if(blessed($rc) && $rc->can('latitude')) {
1030
2
4
                        return ($rc->latitude(), $rc->longitude());
1031                }
1032
23
34
                if(ref($rc) eq 'HASH') {
1033                        return ($rc->{lat}, $rc->{lon})
1034
20
81
                                if defined($rc->{lat}) && defined($rc->{lon});
1035                        return ($rc->{geometry}{location}{lat}, $rc->{geometry}{location}{lng})
1036
4
10
                                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
42
                carp '_fetch_coordinates: unrecognised geocoder result type: ' . ref($rc);
1043
3
603
                return (undef, undef);
1044        }
1045
1046        # Direct Nominatim path: apply caching and rate-limiting.
1047
18
33
        my $cache_key = 'osm:' . uri_escape_utf8($location);
1048
18
289
        if(my $cached = $self->{cache}->get($cache_key)) {
1049
4
484
                return ($cached->{lat}, $cached->{lon});
1050        }
1051
1052        # Honour the minimum interval between outbound requests.
1053
14
684
        my $elapsed = time() - $self->{last_request};
1054
14
25
        if($elapsed < $self->{min_interval}) {
1055
3
999339
                Time::HiRes::sleep($self->{min_interval} - $elapsed);
1056        }
1057
1058
14
60
        my $ua = $self->{'ua'}
1059                || LWP::UserAgent->new(agent => __PACKAGE__ . "/$VERSION");
1060
14
57
        $ua->default_header(accept_encoding => 'gzip,deflate');
1061
14
141
        $ua->env_proxy(1);
1062
1063
14
2572
        my $url      = 'https://' . $self->{'host'} . '?format=json&q=' . uri_escape_utf8($location);
1064
14
160
        my $response = $ua->get($url);
1065
14
173
        $self->{'last_request'} = time();
1066
1067
14
28
        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
27
17
                my $data = eval { decode_json($response->decoded_content()) };
1072
11
287
                if($@) {
1073
2
30
                        carp "_fetch_coordinates: failed to decode Nominatim response: $@";
1074
2
398
                        return (undef, undef);
1075                }
1076
9
30
                $data = $data->[0] if ref($data) eq 'ARRAY';
1077
9
27
                if(ref($data) eq 'HASH' && defined($data->{lat})) {
1078
8
22
                        $self->{'cache'}->set($cache_key, $data);
1079
8
1701
                        return ($data->{lat}, $data->{lon});
1080                }
1081        }
1082
1083
4
12
        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
22273
        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
427
        my $numeric = qr/^-?(?:\d+(?:\.\d+)?|\.\d+)\z/;
1101
1102
365
2910
        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
807
        carp(sprintf 'Skipping invalid coordinate: (%s, %s)',
1108             $lat // 'undef', $lon // 'undef')
1109                if !$ok && defined($lat) && defined($lon);
1110
1111
365
4888
        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
94035
        my $j = encode_json(shift);
1123
113
1491
        $j =~ s|</|<\\/|g;
1124
113
89
        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
109010
        my $s = shift // '';
1134
175
112
        $s =~ s/\\/\\\\/g;    # escape backslash first to avoid double-escaping
1135
175
114
        $s =~ s/'/\\'/g;      # escape our JS string delimiter
1136
175
96
        $s =~ s/\r?\n/\\n/g;  # newlines would break the JS string
1137
175
122
        $s =~ s|</|<\\/|g;    # prevent </script> injection
1138
175
149
        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