Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions docs/source/publishing/ogcapi-maps.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ pygeoapi core feature providers are listed below, along with a matrix of support
parameters.

.. csv-table::
:header: Provider, bbox, width/height
:header: Provider, bbox, width/height, crs, bbox-crs
:align: left

`MapScript`_,✅,✅
`WMSFacade`_,✅,✅
`MapScript`_,✅,✅,✅,✅
`WMSFacade`_,✅,✅,✅,✅


Below are specific connection examples based on supported providers.
Expand Down Expand Up @@ -61,6 +61,7 @@ Currently supported style files (`options.style`):
format:
name: png
mimetype: image/png
storage_crs: http://www.opengis.net/def/crs/EPSG/0/4326

Projections are supported through EPSG codes (`options.projection`):

Expand Down Expand Up @@ -90,6 +91,7 @@ In order to enable it, set `options.tileindex` to `True` and set the location of
format:
name: png
mimetype: image/png
storage_crs: http://www.opengis.net/def/crs/EPSG/0/4326

The `options.tileindex` parameter is optional, defaulting to `False`.

Expand All @@ -112,17 +114,27 @@ required. An optional style name can be defined via `options.style`.
format:
name: png
mimetype: image/png
storage_crs: http://www.opengis.net/def/crs/EPSG/0/4326

.. note::
According to the `Standard <https://docs.ogc.org/is/20-058/20-058.html#_5df53b56-5468-4c9d-acac-6abfddd83ccf>`_, OGC API - Maps
supports a `crs` parameter, expressed as an uri. Currently, this provider supports WGS84 and Web Mercator; for a matter of convenience, they can be expressed in
supports a `crs` parameter, expressed as an uri. Currently, this provider supports CRS84, WGS84 and Web Mercator; for a matter of convenience, they can be expressed in
a number of different ways, other than the uri format.

- `EPSG:4326`
- `EPSG:3857`
- `4326`
- `3857`
- `3857`,
- `CRS84`

If `crs` is not provided, the server will default to the `storage_crs`; in case it does not exist, the default is `CRS84`.
If `crs-bbox` is not provided, it will default to `CRS84`. If the `bbox` is not provided, it will default to `-180, -90, 180, 90`.

The response headers will always contain the `Content-Crs` and `Content-Bbox`. Examples:

- Content-Bbox: -180.0,-90.0,180.0,90.0
- Content-Crs: http://www.opengis.net/def/crs/EPSG/0/4326

Data visualization examples
---------------------------

Expand Down
56 changes: 44 additions & 12 deletions pygeoapi/api/maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2026 Joana Simoes
# Copyright (c) 2026 Tom Kralidis
# Copyright (c) 2025 Francesco Bartoli
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
Expand Down Expand Up @@ -43,7 +44,7 @@
import logging
from typing import Tuple

from pygeoapi.crs import transform_bbox
from pygeoapi.crs import transform_bbox, DEFAULT_CRS
from pygeoapi.formats import F_JSON, FORMAT_TYPES
from pygeoapi.openapi import get_oas_30_parameters
from pygeoapi.plugin import load_plugin
Expand All @@ -61,15 +62,18 @@
'http://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/core'
]

DEFAULT_CRS = 'http://www.opengis.net/def/crs/EPSG/0/4326'
DEFAULT_BBOX = [-180, -90, 180, 90] # CRS84

CRS_CODES = {
'4326': 'http://www.opengis.net/def/crs/EPSG/0/4326',
'3857': 'http://www.opengis.net/def/crs/EPSG/0/3857',
'CRS84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
'http://www.opengis.net/def/crs/EPSG/0/4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', # noqa
'http://www.opengis.net/def/crs/EPSG/0/3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', # noqa
'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', # noqa
'EPSG:4326': 'http://www.opengis.net/def/crs/EPSG/0/4326',
'EPSG:3857': 'http://www.opengis.net/def/crs/EPSG/0/3857'
'EPSG:3857': 'http://www.opengis.net/def/crs/EPSG/0/3857',
'CRS:84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
}


Expand Down Expand Up @@ -114,10 +118,33 @@ def get_collection_map(api: API, request: APIRequest,

query_args['format_'] = request.params.get('f', 'png')
query_args['style'] = style
query_args['crs'] = CRS_CODES[request.params.get(
'crs', collection_def.get('crs', DEFAULT_CRS))]
query_args['bbox_crs'] = CRS_CODES[request.params.get(
'bbox-crs', collection_def.get('crs', DEFAULT_CRS))]

# If there is no crs param, we assume the storage_crs;
# if it does not exist or is not supported, use CRS84.
try:
if 'crs' not in request.params:
query_args['crs'] = CRS_CODES.get(collection_def.get('storage_crs',
DEFAULT_CRS), DEFAULT_CRS)
else:
query_args['crs'] = CRS_CODES.get(request.params['crs'],
DEFAULT_CRS)
except KeyError:
query_args['crs'] = DEFAULT_CRS

LOGGER.debug(f'Using crs: {query_args['crs']}')

# If there is no bbox-crs param, we assume CRS84
try:
if 'bbox-crs' not in request.params:
query_args['bbox-crs'] = DEFAULT_CRS
else:
query_args['bbox-crs'] = CRS_CODES.get(request.params['bbox-crs'],
DEFAULT_CRS)
except KeyError:
query_args['bbox-crs'] = DEFAULT_CRS

LOGGER.debug(f'Using bbox-crs: {query_args['bbox-crs']}')

query_args['transparent'] = request.params.get('transparent', True)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep transparency arg parse?


try:
Expand All @@ -135,7 +162,8 @@ def get_collection_map(api: API, request: APIRequest,

LOGGER.debug('Processing bbox parameter')
try:
bbox = request.params.get('bbox').split(',')
bbox = request.params.get(
'bbox').split(',')
if len(bbox) != 4:
exception = {
'code': 'InvalidParameterValue',
Expand All @@ -145,8 +173,9 @@ def get_collection_map(api: API, request: APIRequest,
LOGGER.error(exception)
return headers, HTTPStatus.BAD_REQUEST, to_json(
exception, api.pretty_print)

except AttributeError:
bbox = api.config['resources'][dataset]['extents']['spatial']['bbox'] # noqa
bbox = DEFAULT_BBOX
try:
bbox = [float(c) for c in bbox]
except ValueError:
Expand All @@ -160,10 +189,10 @@ def get_collection_map(api: API, request: APIRequest,
exception, api.pretty_print)

# the transformer function expects the crs to be in a uri format
if query_args['bbox_crs'] != query_args['crs']:
if query_args['bbox-crs'] != query_args['crs']:
LOGGER.debug(f'Reprojecting bbox CRS: {query_args["crs"]}')
bbox = transform_bbox(bbox, query_args['bbox_crs'], query_args['crs'])

bbox = transform_bbox(bbox, query_args['bbox-crs'],
query_args['crs'], always_xy=True)
query_args['bbox'] = bbox

LOGGER.debug('Processing datetime parameter')
Expand Down Expand Up @@ -235,6 +264,9 @@ def get_collection_map(api: API, request: APIRequest,

mt = collection_def['format']['name']

headers['Content-Crs'] = query_args['crs']
headers['Content-Bbox'] = ','.join(map(str, query_args['bbox']))

if format_ == mt:
headers['Content-Type'] = collection_def['format']['mimetype']
return headers, HTTPStatus.OK, data
Expand Down
14 changes: 12 additions & 2 deletions pygeoapi/crs.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ def crs_transform_feature(feature: dict, transform_func: Callable):


def transform_bbox(bbox: list, from_crs: Union[str, pyproj.CRS],
to_crs: Union[str, pyproj.CRS]) -> list:
to_crs: Union[str, pyproj.CRS], always_xy=False) -> list:
"""
helper function to transform a bounding box (bbox) from
a source to a target CRS. CRSs in URI str format.
Expand All @@ -297,7 +297,17 @@ def transform_bbox(bbox: list, from_crs: Union[str, pyproj.CRS],
from_crs_obj = get_crs(from_crs)
to_crs_obj = get_crs(to_crs)
transform_func = pyproj.Transformer.from_crs(
from_crs_obj, to_crs_obj).transform
from_crs_obj, to_crs_obj, always_xy).transform

# Clip values to max and min lat of WebMercator,
# to avoid infinte pole distortion
if to_crs_obj.to_epsg() == 3857:
bbox = [
bbox[0],
max(-85.0511, bbox[1]),
bbox[2],
min(85.0511, bbox[3])
]

n_dims = len(bbox) // 2
return list(transform_func(*bbox[:n_dims]) + transform_func(
Expand Down
13 changes: 8 additions & 5 deletions pygeoapi/provider/wms_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# Copyright (c) 2026 Joana Simoes
# Copyright (c) 2026 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
Expand Down Expand Up @@ -33,6 +34,7 @@
import pyproj
import requests


from pygeoapi.provider.base import BaseProvider, ProviderQueryError

LOGGER = logging.getLogger(__name__)
Expand All @@ -43,10 +45,11 @@

CRS_CODES = {
'http://www.opengis.net/def/crs/EPSG/0/4326': 'EPSG:4326',
'http://www.opengis.net/def/crs/EPSG/0/3857': 'EPSG:3857'
'http://www.opengis.net/def/crs/EPSG/0/3857': 'EPSG:3857',
'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 'CRS:84' # noqa
}

DEFAULT_CRS = 'http://www.opengis.net/def/crs/EPSG/0/4326'
DEFAULT_CRS = 'CRS:84'


class WMSFacadeProvider(BaseProvider):
Expand All @@ -67,7 +70,7 @@ def __init__(self, provider_def):

def query(self, style=None, bbox=[-180, -90, 180, 90], width=500,
height=300, crs=DEFAULT_CRS, datetime_=None, transparent=True,
bbox_crs=DEFAULT_CRS, format_='png', **kwargs):
format_='png', **kwargs):
"""
Generate map

Expand All @@ -88,7 +91,7 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500,

version = self.options.get('version', '1.3.0')

if version == '1.3.0' and CRS_CODES.get(bbox_crs) == 'EPSG:4326':
if version == '1.3.0' and CRS_CODES.get(crs) == 'EPSG:4326':
bbox = [bbox[1], bbox[0], bbox[3], bbox[2]]
bbox2 = ','.join(map(str, bbox))

Expand All @@ -101,7 +104,7 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500,
'service': 'WMS',
'request': 'GetMap',
'bbox': bbox2,
crs_param: CRS_CODES.get(crs) or 'EPSG:4326',
crs_param: CRS_CODES.get(crs) or DEFAULT_CRS,
'layers': self.options['layer'],
'styles': self.options.get('style', 'default'),
'width': width,
Expand Down
73 changes: 66 additions & 7 deletions pygeoapi/templates/collections/collection.html
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ <h3>{% trans %}Storage CRS{% endtrans %}</h3>
{% block extrafoot %}
<script>
var map = L.map('collection-map').setView([{{ 0 }}, {{ 0 }}], 1);

map.addLayer(new L.TileLayer(
'{{ config['server']['map']['url'] }}', {
maxZoom: 18,
Expand All @@ -154,18 +155,76 @@ <h3>{% trans %}Storage CRS{% endtrans %}</h3>
['{{ data['extent']['spatial']['bbox'][0][1] }}', '{{ data['extent']['spatial']['bbox'][0][2] }}']
]);

{# if this collection has a map representation, add it to the map #}
{% for link in data['links'] %}
{% if link['rel'] == 'http://www.opengis.net/def/rel/ogc/1.0/map' and link['href'] %}
L.imageOverlay.ogcapi("{{ data['base_url'] }}", {collection: "{{ data['id'] }}", "opacity": .7, "transparent": true}).addTo(map);
var lbounds = bbox_layer.getBounds();

// Make sure that we pass valid coordinates to the imageOverlay
function clampLat(lat) {
return Math.max(-85.0511, Math.min(lat, 85.0511));
}

var sw = lbounds.getSouthWest();
var ne = lbounds.getNorthEast();
var clampedSw = L.latLng(clampLat(sw.lat), sw.lng);
var clampedNe = L.latLng(clampLat(ne.lat), ne.lng);
var clampedBounds = L.latLngBounds(clampedSw, clampedNe);

var ogcapi_layer = null;
var image_overlay = null;

{# if this collection has a map representation, add it to the map #}
{% for link in data['links'] %}
{% if link['rel'] == 'http://www.opengis.net/def/rel/ogc/1.0/map' and link['href'] %}
ogcapi_layer = L.imageOverlay.ogcapi("{{ data['base_url'] }}", {
collection: "{{ data['id'] }}",
"opacity": .7,
"transparent": true,
"bounds": clampedBounds
});
image_layer = L.imageOverlay("{{ link['href'] }}", clampedBounds, {opacity: 0.7, transparent: true});
bbox_layer.setStyle({
fillOpacity: 0
});
bbox_layer.setStyle({
fillOpacity: 0
});
Comment on lines +184 to 189
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplication of the bbox fill

{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

// Check bounds and toggle the visibility of the imageOverlay, accordingly
function toggleOverlayVisibility() {

if (ogcapi_layer) {
var currentBounds = map.getBounds();
var west = currentBounds.getWest();
var east = currentBounds.getEast();
var centerLng = map.getCenter().lng;

var viewWidth = east - west;

var isWithinBounds = (viewWidth <= 360) && (centerLng >= -180 && centerLng <= 180);

if (isWithinBounds) {
if (!map.hasLayer(ogcapi_layer)) {
map.addLayer(ogcapi_layer);
}
map.removeLayer(image_layer);
} else {
if (map.hasLayer(ogcapi_layer)) {
map.removeLayer(ogcapi_layer);
}
map.addLayer(image_layer);
}
}
}

map.on('moveend', toggleOverlayVisibility);

map.addLayer(bbox_layer);
map.fitBounds(bbox_layer.getBounds(), {maxZoom: 10});

map.fitBounds(clampedBounds, {maxZoom: 10});

// Run the initial visibility check
toggleOverlayVisibility();

// Allow to get bbox query parameter of a rectangular area specified by
// dragging the mouse while pressing the Ctrl key
Expand Down
24 changes: 16 additions & 8 deletions tests/provider/test_wms_facade_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,23 @@ def config():
}


def test_query(config):
def check_is_PNG(results):
assert isinstance(results, bytes)
assert results[1:4] == b'PNG'


def test_crs_query(config):
p = WMSFacadeProvider(config)

results = p.query()
assert len(results) > 0
results1 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/4326')
results2 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/3857')

print(results1)

check_is_PNG(results1)
check_is_PNG(results2)

# an invalid CRS should return the default bbox (4326)
results2 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/1111')
assert len(results2) == len(results)
# An invalid crs should default to default crs
results3 = p.query(crs='http://0000')

results3 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/3857')
assert len(results3) != len(results)
check_is_PNG(results3)
Loading