Tiles Metadata provider refactor (#1482)

* - refactored mvt classes to support all implemented metadata formats, regardless of the provider

* - fixed formatting issues

* Implementing basic tile metadata methods

* Fixing yml models

* Adding additional format

* Fixing schema set on load

* Removing unused field from documentation

* Change method name to generic vendor

* Keeping extra metadata info for tippecanoe provider

* Fix flake validations error

---------

Co-authored-by: doublebyte <doublebyte@hushmail.com>
This commit is contained in:
Aontnio Cereicllo
2024-01-15 20:21:07 +01:00
committed by GitHub
parent c36f8ad9ba
commit 09cb2c07bd
10 changed files with 294 additions and 190 deletions
@@ -46,7 +46,6 @@ This code block shows how to configure pygeoapi to read Mapbox vector tiles gene
data: tests/data/tiles/ne_110m_lakes # local directory tree
# data: http://localhost:9000/ne_110m_lakes/{z}/{x}/{y}.pbf # tiles stored on a MinIO bucket
options:
metadata_format: default # default | tilejson
zoom:
min: 0
max: 5
@@ -77,7 +76,6 @@ This code block shows how to configure pygeoapi to read Mapbox vector tiles from
# if you don't use precision 0, you will be requesting for aggregations which are not supported in the
# free version of elastic
options:
metadata_format: default # default | tilejson
zoom:
min: 0
max: 5
+15 -36
View File
@@ -74,6 +74,7 @@ from pygeoapi.plugin import load_plugin, PLUGINS
from pygeoapi.provider.base import (
ProviderGenericError, ProviderConnectionError, ProviderNotFoundError,
ProviderTypeError)
from pygeoapi.models.provider.base import TilesMetadataFormat
from pygeoapi.models.cql import CQLModel
from pygeoapi.util import (dategetter, RequestedProcessExecutionMode,
@@ -85,8 +86,6 @@ from pygeoapi.util import (dategetter, RequestedProcessExecutionMode,
get_crs_from_uri, get_supported_crs_list,
CrsTransformSpec, transform_bbox)
from pygeoapi.models.provider.base import TilesMetadataFormat
LOGGER = logging.getLogger(__name__)
#: Return headers for requests (e.g:X-Powered-By)
@@ -2682,15 +2681,12 @@ class API:
tiles['tilesets'].append(tile_matrix)
metadata_format = p.options['metadata_format']
if request.format == F_HTML: # render
tiles['id'] = dataset
tiles['title'] = l10n.translate(
self.config['resources'][dataset]['title'], SYSTEM_LOCALE)
tiles['tilesets'] = [
scheme.tileMatrixSet for scheme in p.get_tiling_schemes()]
tiles['format'] = metadata_format
tiles['bounds'] = \
self.config['resources'][dataset]['extents']['spatial']['bbox']
tiles['minzoom'] = p.options['zoom']['min']
@@ -2785,7 +2781,7 @@ class API:
:returns: tuple of headers, status code, content
"""
if not request.is_valid():
if not request.is_valid([TilesMetadataFormat.TILEJSON]):
return self.get_format_exception(request)
headers = request.get_response_headers(**self.api_headers)
@@ -2821,47 +2817,30 @@ class API:
return self.get_exception(HTTPStatus.NOT_FOUND, headers,
request.format, 'NotFound', msg)
metadata_format = TilesMetadataFormat[
str(p.options['metadata_format']).upper()]
# Set response language to requested provider locale
# (if it supports language) and/or otherwise the requested pygeoapi
# locale (or fallback default locale)
l10n.set_response_language(headers, prv_locale, request.locale)
if request.format == F_HTML: # render
tiles_metadata = p.get_metadata(
dataset=dataset, server_url=self.base_url,
layer=p.get_layer(), tileset=matrix_id,
metadata_format=TilesMetadataFormat.TILEJSON,
language=prv_locale)
metadata = dict()
metadata['metadata'] = tiles_metadata
metadata['id'] = dataset
metadata['title'] = l10n.translate(
self.config['resources'][dataset]['title'], request.locale)
metadata['tileset'] = matrix_id
metadata['format'] = metadata_format.value
metadata['collections_path'] = self.get_collections_url()
tiles_metadata = p.get_metadata(
dataset=dataset, server_url=self.base_url,
layer=p.get_layer(), tileset=matrix_id,
metadata_format=request._format, title=l10n.translate(
self.config['resources'][dataset]['title'],
request.locale),
description=l10n.translate(
self.config['resources'][dataset]['description'],
request.locale),
language=prv_locale)
if request.format == F_HTML: # render
content = render_j2_template(self.tpl_config,
'collections/tiles/metadata.html',
metadata, request.locale)
tiles_metadata, request.locale)
return headers, HTTPStatus.OK, content
else:
tiles_metadata = p.get_metadata(
dataset=dataset, server_url=self.base_url,
layer=p.get_layer(), tileset=matrix_id,
metadata_format=metadata_format, title=l10n.translate(
self.config['resources'][dataset]['title'],
request.locale),
description=l10n.translate(
self.config['resources'][dataset]['description'],
request.locale),
language=prv_locale)
return headers, HTTPStatus.OK, tiles_metadata
return headers, HTTPStatus.OK, tiles_metadata
@gzip
@pre_process
+6 -5
View File
@@ -38,13 +38,14 @@ from typing import List, Optional
from pydantic import BaseModel
class TilesMetadataFormat(Enum):
class TilesMetadataFormat(str, Enum):
# Tile Set Metadata
DEFAULT = "Tile Set Metadata"
JSON = "JSON"
JSONLD = "JSONLD"
# TileJSON 3.0
TILEJSON = "TileJSON"
# Custom JSON
CUSTOMJSON = "Custom"
TILEJSON = "TILEJSON"
# HTML (default)
HTML = "HTML"
# Tile Set Metadata Enums
+85 -89
View File
@@ -29,19 +29,13 @@
#
# =================================================================
import json
import logging
import requests
from pathlib import Path
from urllib.parse import urlparse
from pygeoapi.provider.tile import BaseTileProvider
from pygeoapi.provider.base import ProviderConnectionError
from pygeoapi.models.provider.base import (
TileMatrixSetEnum, TilesMetadataFormat, TileSetMetadata, LinkType,
GeospatialDataType)
from pygeoapi.models.provider.mvt import MVTTilesJson
from pygeoapi.util import is_url, url_join
TileMatrixSetEnum, TilesMetadataFormat)
from pygeoapi.util import url_join
LOGGER = logging.getLogger(__name__)
@@ -125,11 +119,10 @@ class BaseMVTProvider(BaseTileProvider):
raise NotImplementedError()
def get_metadata(self, dataset, server_url, layer=None,
tileset=None, metadata_format=None, title=None,
description=None, keywords=None, **kwargs):
def get_html_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
"""
Gets tile metadata
Gets tile metadata informations in html format
:param dataset: dataset name
:param server_url: server base url
@@ -137,88 +130,91 @@ class BaseMVTProvider(BaseTileProvider):
:param tileset: mvt tileset name
:param metadata_format: format for metadata,
enum TilesMetadataFormat
:param title: title name
:param description: description name
:param keywords: keywords list
:returns: `dict` of JSON metadata
"""
if is_url(self.data):
url = urlparse(self.data)
base_url = f'{url.scheme}://{url.netloc}'
if metadata_format == TilesMetadataFormat.TILEJSON:
with requests.Session() as session:
session.get(base_url)
resp = session.get(f'{base_url}/{layer}/metadata.json')
resp.raise_for_status()
metadata_json_content = resp.json()
raise NotImplementedError()
def get_default_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
"""
Gets tile metadata in default Tile Set Metadata format
:param dataset: dataset name
:param server_url: server base url
:param layer: mvt tile layer name
:param tileset: mvt tileset name
:param metadata_format: format for metadata,
enum TilesMetadataFormat
:param title: title name
:param description: description name
:param keywords: keywords list
:returns: `dict` of JSON metadata
"""
raise NotImplementedError()
def get_vendor_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
"""
Gets tile metadata in Tilejson format
:param dataset: dataset name
:param server_url: server base url
:param layer: mvt tile layer name
:param tileset: mvt tileset name
:param metadata_format: format for metadata,
enum TilesMetadataFormat
:param title: title name
:param description: description name
:param keywords: keywords list
:returns: `dict` of JSON metadata
"""
raise NotImplementedError()
def get_metadata(self, dataset, server_url, layer=None,
tileset=None, metadata_format=None, title=None,
description=None, keywords=None, **kwargs):
"""
Gets tiles metadata
:param dataset: dataset name
:param server_url: server base url
:param layer: mvt tile layer name
:param tileset: mvt tileset name
:param metadata_format: format for metadata,
enum TilesMetadataFormat
:param title: title name
:param description: description name
:param keywords: keywords list
:returns: `dict` of JSON metadata
"""
if metadata_format.upper() == TilesMetadataFormat.JSON:
return self.get_default_metadata(dataset, server_url, layer,
tileset, title, description,
keywords, **kwargs)
elif metadata_format.upper() == TilesMetadataFormat.TILEJSON:
return self.get_vendor_metadata(dataset, server_url, layer,
tileset, title, description,
keywords, **kwargs)
elif metadata_format.upper() == TilesMetadataFormat.HTML:
return self.get_html_metadata(dataset, server_url, layer,
tileset, title, description,
keywords, **kwargs)
elif metadata_format.upper() == TilesMetadataFormat.JSONLD:
return self.get_default_metadata(dataset, server_url, layer,
tileset, title, description,
keywords, **kwargs)
else:
if not isinstance(self.service_metadata_url, Path):
msg = f'Wrong data path configuration: {self.service_metadata_url}' # noqa
LOGGER.error(msg)
raise ProviderConnectionError(msg)
if self.service_metadata_url.exists():
with open(self.service_metadata_url, 'r') as md_file:
metadata_json_content = json.loads(md_file.read())
service_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
content = {}
if metadata_format == TilesMetadataFormat.TILEJSON:
if 'metadata_json_content' in locals():
content = MVTTilesJson(**metadata_json_content)
content.tiles = service_url
content.vector_layers = json.loads(
metadata_json_content["json"])["vector_layers"]
return content.dict()
else:
msg = f'No tiles metadata json available: {self.service_metadata_url}' # noqa
LOGGER.error(msg)
raise ProviderConnectionError(msg)
elif metadata_format == TilesMetadataFormat.CUSTOMJSON:
if 'metadata_json_content' in locals():
content = metadata_json_content
if 'json' in metadata_json_content:
content['json'] = json.loads(metadata_json_content['json'])
return content
else:
msg = f'No custom JSON for tiles metadata available: {self.service_metadata_url}' # noqa
LOGGER.error(msg)
raise ProviderConnectionError(msg)
else:
tiling_schemes = self.get_tiling_schemes()
# Default values
tileMatrixSetURI = tiling_schemes[0].tileMatrixSetURI
crs = tiling_schemes[0].crs
# Checking the selected matrix in configured tiling_schemes
for schema in tiling_schemes:
if (schema.tileMatrixSet == tileset):
crs = schema.crs
tileMatrixSetURI = schema.tileMatrixSetURI
content = TileSetMetadata(title=title, description=description,
keywords=keywords, crs=crs,
tileMatrixSetURI=tileMatrixSetURI)
links = []
service_url_link_type = "application/vnd.mapbox-vector-tile"
service_url_link_title = f'{tileset} vector tiles for {layer}'
service_url_link = LinkType(href=service_url, rel="item",
type=service_url_link_type,
title=service_url_link_title)
links.append(service_url_link)
content.links = links
if 'metadata_json_content' in locals():
vector_layers = json.loads(
metadata_json_content["json"])["vector_layers"]
layers = []
for vector_layer in vector_layers:
layers.append(GeospatialDataType(id=vector_layer['id']))
content.layers = layers
return content.dict(exclude_none=True)
raise NotImplementedError(f"_{metadata_format.upper()}_ is not supported") # noqa
def get_tms_links(self):
"""
+60 -17
View File
@@ -33,6 +33,8 @@ from urllib.parse import urlparse
from pygeoapi.provider.base_mvt import BaseMVTProvider
from pygeoapi.provider.base import ProviderConnectionError
from pygeoapi.models.provider.base import (
TileSetMetadata, LinkType)
from pygeoapi.util import is_url, url_join
LOGGER = logging.getLogger(__name__)
@@ -170,22 +172,63 @@ class MVTElasticProvider(BaseMVTProvider):
LOGGER.error(msg)
raise ProviderConnectionError(msg)
def get_metadata(self, dataset, server_url, layer=None,
tileset=None, metadata_format=None, title=None,
description=None, keywords=None, **kwargs):
def get_html_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
service_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
metadata_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/metadata')
metadata = dict()
metadata['id'] = dataset
metadata['title'] = title
metadata['tileset'] = tileset
metadata['collections_path'] = service_url
metadata['json_url'] = f'{metadata_url}?f=json'
return metadata
def get_default_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
service_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
content = {}
tiling_schemes = self.get_tiling_schemes()
# Default values
tileMatrixSetURI = tiling_schemes[0].tileMatrixSetURI
crs = tiling_schemes[0].crs
# Checking the selected matrix in configured tiling_schemes
for schema in tiling_schemes:
if (schema.tileMatrixSet == tileset):
crs = schema.crs
tileMatrixSetURI = schema.tileMatrixSetURI
content = TileSetMetadata(title=title, description=description,
keywords=keywords, crs=crs,
tileMatrixSetURI=tileMatrixSetURI)
links = []
service_url_link_type = "application/vnd.mapbox-vector-tile"
service_url_link_title = f'{tileset} vector tiles for {layer}'
service_url_link = LinkType(href=service_url, rel="item",
type=service_url_link_type,
title=service_url_link_title)
links.append(service_url_link)
content.links = links
return content.dict(exclude_none=True)
def get_vendor_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
"""
Gets tile metadata
:param dataset: dataset name
:param server_url: server base url
:param layer: mvt tile layer name
:param tileset: mvt tileset name
:param metadata_format: format for metadata,
enum TilesMetadataFormat
:returns: `dict` of JSON metadata
Gets tile metadata in tilejson format
"""
return super().get_metadata(dataset, server_url, layer,
tileset, metadata_format, title,
description, keywords, **kwargs)
LOGGER.debug("Get tilejson metadata")
return ""
+102 -17
View File
@@ -27,14 +27,19 @@
#
# =================================================================
import json
import logging
from pathlib import Path
from urllib.parse import urlparse
from pygeoapi.provider.tile import (
ProviderTileNotFoundError)
from pygeoapi.provider.base_mvt import BaseMVTProvider
from pygeoapi.provider.base import ProviderConnectionError
from pygeoapi.provider.base_mvt import BaseMVTProvider
from pygeoapi.models.provider.base import (
TileSetMetadata, LinkType)
from pygeoapi.models.provider.mvt import MVTTilesJson
from pygeoapi.util import is_url, url_join
LOGGER = logging.getLogger(__name__)
@@ -169,22 +174,102 @@ class MVTTippecanoeProvider(BaseMVTProvider):
except FileNotFoundError as err:
raise ProviderTileNotFoundError(err)
def get_metadata(self, dataset, server_url, layer=None,
tileset=None, metadata_format=None, title=None,
description=None, keywords=None, **kwargs):
def get_html_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
service_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
metadata_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/metadata')
metadata = dict()
metadata['id'] = dataset
metadata['title'] = title
metadata['tileset'] = tileset
metadata['collections_path'] = service_url
metadata['json_url'] = f'{metadata_url}?f=json'
# Some providers may not implement tilejson metadata
metadata['tilejson_url'] = f'{metadata_url}?f=tilejson'
if not isinstance(self.service_metadata_url, Path):
msg = f'Wrong data path configuration: {self.service_metadata_url}' # noqa
LOGGER.warning(msg)
elif self.service_metadata_url.exists():
with open(self.service_metadata_url, 'r') as md_file:
metadata_json_content = json.loads(md_file.read())
if 'metadata_json_content' in locals():
content = MVTTilesJson(**metadata_json_content)
content.tiles = service_url
content.vector_layers = json.loads(
metadata_json_content["json"])["vector_layers"]
metadata['metadata'] = content.model_dump()
return metadata
def get_default_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
service_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
content = {}
tiling_schemes = self.get_tiling_schemes()
# Default values
tileMatrixSetURI = tiling_schemes[0].tileMatrixSetURI
crs = tiling_schemes[0].crs
# Checking the selected matrix in configured tiling_schemes
for schema in tiling_schemes:
if (schema.tileMatrixSet == tileset):
crs = schema.crs
tileMatrixSetURI = schema.tileMatrixSetURI
content = TileSetMetadata(title=title, description=description,
keywords=keywords, crs=crs,
tileMatrixSetURI=tileMatrixSetURI)
links = []
service_url_link_type = "application/vnd.mapbox-vector-tile"
service_url_link_title = f'{tileset} vector tiles for {layer}'
service_url_link = LinkType(href=service_url, rel="item",
type=service_url_link_type,
title=service_url_link_title)
links.append(service_url_link)
content.links = links
return content.model_dump(exclude_none=True)
def get_vendor_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
"""
Gets tile metadata
:param dataset: dataset name
:param server_url: server base url
:param layer: mvt tile layer name
:param tileset: mvt tileset name
:param metadata_format: format for metadata,
enum TilesMetadataFormat
:returns: `dict` of JSON metadata
Gets tile metadata in tilejson format
"""
return super().get_metadata(dataset, server_url, layer,
tileset, metadata_format, title,
description, keywords, **kwargs)
if not isinstance(self.service_metadata_url, Path):
msg = f'Wrong data path configuration: {self.service_metadata_url}' # noqa
LOGGER.error(msg)
raise ProviderConnectionError(msg)
if self.service_metadata_url.exists():
with open(self.service_metadata_url, 'r') as md_file:
metadata_json_content = json.loads(md_file.read())
service_url = url_join(
server_url,
f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
content = {}
if 'metadata_json_content' in locals():
content = MVTTilesJson(**metadata_json_content)
content.tiles = service_url
content.vector_layers = json.loads(
metadata_json_content["json"])["vector_layers"]
return content.model_dump()
else:
msg = f'No tiles metadata json available: {self.service_metadata_url}' # noqa
LOGGER.error(msg)
raise ProviderConnectionError(msg)
@@ -39,25 +39,23 @@
</div>
<br/>
<div class="row">
{% if data['format']=='tilejson' %}
<div class="col-md-2 col-sm-12">{% trans %}Metadata{% endtrans %}</div>
<div class="col-md-8"><a id="tilejson" href="" target="_blank">Tiles metadata in {{ data['format'] }} format</a></div>
{% endif %} </div>
<div class="col-md-8"><a id="metadata_link" href="" target="_blank">Metadata</a></div>
</div>
<script>
var select = document.getElementById('tilingScheme');
var tileset = select.value;
let params = (new URL(document.location)).searchParams;
var scheme = params.get('scheme');
var scheme = params.get('scheme') ?? select.value;
if (scheme) {
select.value = scheme;
document.getElementById("metadata_link").href = "{{ config['server']['url'] }}/collections/{{ data['id'] }}/tiles/" + scheme + "/metadata";
}
select.addEventListener('change', ev => {
var scheme = ev.target.value;
console.log(scheme);
document.location.search = `scheme=${scheme}`;
document.getElementById("metadata_link").href = "{{ config['server']['url'] }}/collections/{{ data['id'] }}/tiles/" + scheme + "/metadata";
});
if (document.getElementById("tilejson")){
document.getElementById("tilejson").href = "{{ config['server']['url'] }}/collections/{{ data['id'] }}/tiles/" + tileset + "/metadata";
}
</script>
<br/>
<div class="row">
@@ -86,7 +84,7 @@
{% endif %}
{% endfor %}
var url = tilesUrl.replace('{dataset}', '{{ data["id"] }}').replace('{tileMatrixSetId}', tileset).replace("tileMatrix", "z").replace("tileRow", "x").replace("tileCol", "y");
var url = tilesUrl.replace('{dataset}', '{{ data["id"] }}').replace('{tileMatrixSetId}', scheme).replace("tileMatrix", "z").replace("tileRow", "x").replace("tileCol", "y");
var VectorTileOptions = {
interactive: true,
@@ -2,25 +2,31 @@
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
{% block crumbs %}{{ super() }}
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
/ <a href="../../{{ data['id'] }}">{{ data['title'] }}</a>
/ <a href="../../{{ data['id'] }}/tiles">{% trans %}Tiles{% endtrans %}</a>
/ <a href="../../{{ data['id'] }}/tiles/{{ data['tileset'] }}">Tile Metadata</a>
/ <a href="../../../{{ data['id'] }}">{{ data['title'] }}</a>
/ <a href="../../../{{ data['id'] }}/tiles">{% trans %}Tiles{% endtrans %}</a>
/ <a href="../../../{{ data['id'] }}/tiles/{{ data['tileset'] }}">Tile Metadata</a>
{% endblock %}
{% block body %}
<section id="collection">
<h1>{{ data['title'] }}</h1>
<p>{{ data['description'] }}</p>
<h1>{{ data['tileset'] }} map tileset for {{ data['title'] }}</h1>
<p>
{% for kw in data['keywords'] %}
<span class="badge text-bg-primary bg-primary">{{ kw }}</span>
{% endfor %}
(View
{% if data['tilejson_url'] %}
<a href="{{ data['tilejson_url'] }}">{% trans %}TileJSON{% endtrans %}</a> or
{% endif %}
<a href="{{ data['json_url'] }}">{% trans %}JSON{% endtrans %}</a> representation)
</p>
<h3>{% trans %}Tiles metadata{% endtrans %} - {{ data['format'] }} {% trans %}format{% endtrans %}</h3>
<h4>{% trans %}Tileset{% endtrans %} {{ data['tileset'] }}</h4>
<p>
<h2>Tile URL template:</h2>
<b>{{ data['collections_path'] }}</b>
</p>
{% if data['metadata'] %}
<h2>Metadata:</h2>
<ul>
{% for key in data['metadata'].keys() %}
<li>{{ key }} (<code>{{ data['metadata'][key] }}</code>)</li>
{% endfor %}
{% for key in data['metadata'].keys() %}
<li>{{ key }} (<code>{{ data['metadata'][key] }}</code>)</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% endblock %}
-1
View File
@@ -216,7 +216,6 @@ resources:
# data: http://localhost:9000/ne_110m_lakes/{z}/{x}/{y}
data: tests/data/tiles/ne_110m_lakes
options:
metadata_format: raw # default | tilejson
bounds: [[-124.953634,-16.536406],[109.929807,66.969298]]
zoom:
min: 0
-1
View File
@@ -250,7 +250,6 @@ resources:
# data: http://localhost:9000/ne_110m_lakes/{z}/{x}/{y}
data: tests/data/tiles/ne_110m_lakes
options:
metadata_format: default # default | tilejson
bounds: [[-124.953634,-16.536406],[109.929807,66.969298]]
zoom:
min: 0