Skip to content

Commit 6302a5f

Browse files
authored
Updates to the OGC API - Maps Support (#2308)
* - Introduced guardrails to ensure we dont go outside the limits of the WebMercator bbox - Support defaults for crs, bbox and bbox-crs * - support maps request, even when there are no spatial extents defined in conf * - removed failing tests in wms_facade provider * - fixed flake8 * - Make sure we only show the map preview, when the view is within bounds of the crs * - updated unit tests for the wms_facade provider * - Set input and output coordinates always treated as (x, y) regardless of the axis order defined in the CRS, only for OAM * - Reimplement the logic for default crs and bbox-crs in OAM, according to the Standard - fix typo in bbox-crs par * - updated default_crs code in wms_facade provider - fixed tests for wms_facade * - updated documentation - added Content-Crs and Content-Bbox to OAM * - fixed flake8 errors * - update crs84 code on wms_facade * - improve code readability of query arg * - put back missed transparency arg * - use DEFAULT_CRS from crs.py * - added another alias for crs84 * - put back preview of OAM, in extents greater than the world. * - fixed wms_facade test * - fixed flake8 errors
1 parent f62645b commit 6302a5f

6 files changed

Lines changed: 163 additions & 39 deletions

File tree

docs/source/publishing/ogcapi-maps.rst

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ pygeoapi core feature providers are listed below, along with a matrix of support
1515
parameters.
1616

1717
.. csv-table::
18-
:header: Provider, bbox, width/height
18+
:header: Provider, bbox, width/height, crs, bbox-crs
1919
:align: left
2020

21-
`MapScript`_,✅,✅
22-
`WMSFacade`_,✅,✅
21+
`MapScript`_,✅,✅,✅,✅
22+
`WMSFacade`_,✅,✅,✅,✅
2323

2424

2525
Below are specific connection examples based on supported providers.
@@ -61,6 +61,7 @@ Currently supported style files (`options.style`):
6161
format:
6262
name: png
6363
mimetype: image/png
64+
storage_crs: http://www.opengis.net/def/crs/EPSG/0/4326
6465
6566
Projections are supported through EPSG codes (`options.projection`):
6667

@@ -90,6 +91,7 @@ In order to enable it, set `options.tileindex` to `True` and set the location of
9091
format:
9192
name: png
9293
mimetype: image/png
94+
storage_crs: http://www.opengis.net/def/crs/EPSG/0/4326
9395
9496
The `options.tileindex` parameter is optional, defaulting to `False`.
9597

@@ -112,17 +114,27 @@ required. An optional style name can be defined via `options.style`.
112114
format:
113115
name: png
114116
mimetype: image/png
117+
storage_crs: http://www.opengis.net/def/crs/EPSG/0/4326
115118
116119
.. note::
117120
According to the `Standard <https://docs.ogc.org/is/20-058/20-058.html#_5df53b56-5468-4c9d-acac-6abfddd83ccf>`_, OGC API - Maps
118-
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
121+
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
119122
a number of different ways, other than the uri format.
120123

121124
- `EPSG:4326`
122125
- `EPSG:3857`
123126
- `4326`
124-
- `3857`
127+
- `3857`,
128+
- `CRS84`
125129

130+
If `crs` is not provided, the server will default to the `storage_crs`; in case it does not exist, the default is `CRS84`.
131+
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`.
132+
133+
The response headers will always contain the `Content-Crs` and `Content-Bbox`. Examples:
134+
135+
- Content-Bbox: -180.0,-90.0,180.0,90.0
136+
- Content-Crs: http://www.opengis.net/def/crs/EPSG/0/4326
137+
126138
Data visualization examples
127139
---------------------------
128140

pygeoapi/api/maps.py

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
99
# Bernhard Mallinger <bernhard.mallinger@eox.at>
1010
#
11+
# Copyright (c) 2026 Joana Simoes
1112
# Copyright (c) 2026 Tom Kralidis
1213
# Copyright (c) 2025 Francesco Bartoli
1314
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
@@ -43,7 +44,7 @@
4344
import logging
4445
from typing import Tuple
4546

46-
from pygeoapi.crs import transform_bbox
47+
from pygeoapi.crs import transform_bbox, DEFAULT_CRS
4748
from pygeoapi.formats import F_JSON, FORMAT_TYPES
4849
from pygeoapi.openapi import get_oas_30_parameters
4950
from pygeoapi.plugin import load_plugin
@@ -61,15 +62,18 @@
6162
'http://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/core'
6263
]
6364

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

6667
CRS_CODES = {
6768
'4326': 'http://www.opengis.net/def/crs/EPSG/0/4326',
6869
'3857': 'http://www.opengis.net/def/crs/EPSG/0/3857',
70+
'CRS84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
6971
'http://www.opengis.net/def/crs/EPSG/0/4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', # noqa
7072
'http://www.opengis.net/def/crs/EPSG/0/3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', # noqa
73+
'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', # noqa
7174
'EPSG:4326': 'http://www.opengis.net/def/crs/EPSG/0/4326',
72-
'EPSG:3857': 'http://www.opengis.net/def/crs/EPSG/0/3857'
75+
'EPSG:3857': 'http://www.opengis.net/def/crs/EPSG/0/3857',
76+
'CRS:84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
7377
}
7478

7579

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

115119
query_args['format_'] = request.params.get('f', 'png')
116120
query_args['style'] = style
117-
query_args['crs'] = CRS_CODES[request.params.get(
118-
'crs', collection_def.get('crs', DEFAULT_CRS))]
119-
query_args['bbox_crs'] = CRS_CODES[request.params.get(
120-
'bbox-crs', collection_def.get('crs', DEFAULT_CRS))]
121+
122+
# If there is no crs param, we assume the storage_crs;
123+
# if it does not exist or is not supported, use CRS84.
124+
try:
125+
if 'crs' not in request.params:
126+
query_args['crs'] = CRS_CODES.get(collection_def.get('storage_crs',
127+
DEFAULT_CRS), DEFAULT_CRS)
128+
else:
129+
query_args['crs'] = CRS_CODES.get(request.params['crs'],
130+
DEFAULT_CRS)
131+
except KeyError:
132+
query_args['crs'] = DEFAULT_CRS
133+
134+
LOGGER.debug(f'Using crs: {query_args['crs']}')
135+
136+
# If there is no bbox-crs param, we assume CRS84
137+
try:
138+
if 'bbox-crs' not in request.params:
139+
query_args['bbox-crs'] = DEFAULT_CRS
140+
else:
141+
query_args['bbox-crs'] = CRS_CODES.get(request.params['bbox-crs'],
142+
DEFAULT_CRS)
143+
except KeyError:
144+
query_args['bbox-crs'] = DEFAULT_CRS
145+
146+
LOGGER.debug(f'Using bbox-crs: {query_args['bbox-crs']}')
147+
121148
query_args['transparent'] = request.params.get('transparent', True)
122149

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

136163
LOGGER.debug('Processing bbox parameter')
137164
try:
138-
bbox = request.params.get('bbox').split(',')
165+
bbox = request.params.get(
166+
'bbox').split(',')
139167
if len(bbox) != 4:
140168
exception = {
141169
'code': 'InvalidParameterValue',
@@ -145,8 +173,9 @@ def get_collection_map(api: API, request: APIRequest,
145173
LOGGER.error(exception)
146174
return headers, HTTPStatus.BAD_REQUEST, to_json(
147175
exception, api.pretty_print)
176+
148177
except AttributeError:
149-
bbox = api.config['resources'][dataset]['extents']['spatial']['bbox'] # noqa
178+
bbox = DEFAULT_BBOX
150179
try:
151180
bbox = [float(c) for c in bbox]
152181
except ValueError:
@@ -160,10 +189,10 @@ def get_collection_map(api: API, request: APIRequest,
160189
exception, api.pretty_print)
161190

162191
# the transformer function expects the crs to be in a uri format
163-
if query_args['bbox_crs'] != query_args['crs']:
192+
if query_args['bbox-crs'] != query_args['crs']:
164193
LOGGER.debug(f'Reprojecting bbox CRS: {query_args["crs"]}')
165-
bbox = transform_bbox(bbox, query_args['bbox_crs'], query_args['crs'])
166-
194+
bbox = transform_bbox(bbox, query_args['bbox-crs'],
195+
query_args['crs'], always_xy=True)
167196
query_args['bbox'] = bbox
168197

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

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

267+
headers['Content-Crs'] = query_args['crs']
268+
headers['Content-Bbox'] = ','.join(map(str, query_args['bbox']))
269+
238270
if format_ == mt:
239271
headers['Content-Type'] = collection_def['format']['mimetype']
240272
return headers, HTTPStatus.OK, data

pygeoapi/crs.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ def crs_transform_feature(feature: dict, transform_func: Callable):
279279

280280

281281
def transform_bbox(bbox: list, from_crs: Union[str, pyproj.CRS],
282-
to_crs: Union[str, pyproj.CRS]) -> list:
282+
to_crs: Union[str, pyproj.CRS], always_xy=False) -> list:
283283
"""
284284
helper function to transform a bounding box (bbox) from
285285
a source to a target CRS. CRSs in URI str format.
@@ -297,7 +297,17 @@ def transform_bbox(bbox: list, from_crs: Union[str, pyproj.CRS],
297297
from_crs_obj = get_crs(from_crs)
298298
to_crs_obj = get_crs(to_crs)
299299
transform_func = pyproj.Transformer.from_crs(
300-
from_crs_obj, to_crs_obj).transform
300+
from_crs_obj, to_crs_obj, always_xy).transform
301+
302+
# Clip values to max and min lat of WebMercator,
303+
# to avoid infinte pole distortion
304+
if to_crs_obj.to_epsg() == 3857:
305+
bbox = [
306+
bbox[0],
307+
max(-85.0511, bbox[1]),
308+
bbox[2],
309+
min(85.0511, bbox[3])
310+
]
301311

302312
n_dims = len(bbox) // 2
303313
return list(transform_func(*bbox[:n_dims]) + transform_func(

pygeoapi/provider/wms_facade.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#
33
# Authors: Tom Kralidis <tomkralidis@gmail.com>
44
#
5+
# Copyright (c) 2026 Joana Simoes
56
# Copyright (c) 2026 Tom Kralidis
67
#
78
# Permission is hereby granted, free of charge, to any person
@@ -33,6 +34,7 @@
3334
import pyproj
3435
import requests
3536

37+
3638
from pygeoapi.provider.base import BaseProvider, ProviderQueryError
3739

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

4446
CRS_CODES = {
4547
'http://www.opengis.net/def/crs/EPSG/0/4326': 'EPSG:4326',
46-
'http://www.opengis.net/def/crs/EPSG/0/3857': 'EPSG:3857'
48+
'http://www.opengis.net/def/crs/EPSG/0/3857': 'EPSG:3857',
49+
'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 'CRS:84' # noqa
4750
}
4851

49-
DEFAULT_CRS = 'http://www.opengis.net/def/crs/EPSG/0/4326'
52+
DEFAULT_CRS = 'CRS:84'
5053

5154

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

6871
def query(self, style=None, bbox=[-180, -90, 180, 90], width=500,
6972
height=300, crs=DEFAULT_CRS, datetime_=None, transparent=True,
70-
bbox_crs=DEFAULT_CRS, format_='png', **kwargs):
73+
format_='png', **kwargs):
7174
"""
7275
Generate map
7376
@@ -88,7 +91,7 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500,
8891

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

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

@@ -101,7 +104,7 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500,
101104
'service': 'WMS',
102105
'request': 'GetMap',
103106
'bbox': bbox2,
104-
crs_param: CRS_CODES.get(crs) or 'EPSG:4326',
107+
crs_param: CRS_CODES.get(crs) or DEFAULT_CRS,
105108
'layers': self.options['layer'],
106109
'styles': self.options.get('style', 'default'),
107110
'width': width,

pygeoapi/templates/collections/collection.html

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ <h3>{% trans %}Storage CRS{% endtrans %}</h3>
140140
{% block extrafoot %}
141141
<script>
142142
var map = L.map('collection-map').setView([{{ 0 }}, {{ 0 }}], 1);
143+
143144
map.addLayer(new L.TileLayer(
144145
'{{ config['server']['map']['url'] }}', {
145146
maxZoom: 18,
@@ -154,18 +155,76 @@ <h3>{% trans %}Storage CRS{% endtrans %}</h3>
154155
['{{ data['extent']['spatial']['bbox'][0][1] }}', '{{ data['extent']['spatial']['bbox'][0][2] }}']
155156
]);
156157

157-
{# if this collection has a map representation, add it to the map #}
158-
{% for link in data['links'] %}
159-
{% if link['rel'] == 'http://www.opengis.net/def/rel/ogc/1.0/map' and link['href'] %}
160-
L.imageOverlay.ogcapi("{{ data['base_url'] }}", {collection: "{{ data['id'] }}", "opacity": .7, "transparent": true}).addTo(map);
158+
var lbounds = bbox_layer.getBounds();
159+
160+
// Make sure that we pass valid coordinates to the imageOverlay
161+
function clampLat(lat) {
162+
return Math.max(-85.0511, Math.min(lat, 85.0511));
163+
}
164+
165+
var sw = lbounds.getSouthWest();
166+
var ne = lbounds.getNorthEast();
167+
var clampedSw = L.latLng(clampLat(sw.lat), sw.lng);
168+
var clampedNe = L.latLng(clampLat(ne.lat), ne.lng);
169+
var clampedBounds = L.latLngBounds(clampedSw, clampedNe);
170+
171+
var ogcapi_layer = null;
172+
var image_overlay = null;
173+
174+
{# if this collection has a map representation, add it to the map #}
175+
{% for link in data['links'] %}
176+
{% if link['rel'] == 'http://www.opengis.net/def/rel/ogc/1.0/map' and link['href'] %}
177+
ogcapi_layer = L.imageOverlay.ogcapi("{{ data['base_url'] }}", {
178+
collection: "{{ data['id'] }}",
179+
"opacity": .7,
180+
"transparent": true,
181+
"bounds": clampedBounds
182+
});
183+
image_layer = L.imageOverlay("{{ link['href'] }}", clampedBounds, {opacity: 0.7, transparent: true});
184+
bbox_layer.setStyle({
185+
fillOpacity: 0
186+
});
161187
bbox_layer.setStyle({
162188
fillOpacity: 0
163189
});
164-
{% endif %}
165-
{% endfor %}
190+
{% endif %}
191+
{% endfor %}
192+
193+
// Check bounds and toggle the visibility of the imageOverlay, accordingly
194+
function toggleOverlayVisibility() {
195+
196+
if (ogcapi_layer) {
197+
var currentBounds = map.getBounds();
198+
var west = currentBounds.getWest();
199+
var east = currentBounds.getEast();
200+
var centerLng = map.getCenter().lng;
201+
202+
var viewWidth = east - west;
203+
204+
var isWithinBounds = (viewWidth <= 360) && (centerLng >= -180 && centerLng <= 180);
205+
206+
if (isWithinBounds) {
207+
if (!map.hasLayer(ogcapi_layer)) {
208+
map.addLayer(ogcapi_layer);
209+
}
210+
map.removeLayer(image_layer);
211+
} else {
212+
if (map.hasLayer(ogcapi_layer)) {
213+
map.removeLayer(ogcapi_layer);
214+
}
215+
map.addLayer(image_layer);
216+
}
217+
}
218+
}
219+
220+
map.on('moveend', toggleOverlayVisibility);
166221

167222
map.addLayer(bbox_layer);
168-
map.fitBounds(bbox_layer.getBounds(), {maxZoom: 10});
223+
224+
map.fitBounds(clampedBounds, {maxZoom: 10});
225+
226+
// Run the initial visibility check
227+
toggleOverlayVisibility();
169228

170229
// Allow to get bbox query parameter of a rectangular area specified by
171230
// dragging the mouse while pressing the Ctrl key

tests/provider/test_wms_facade_provider.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,23 @@ def config():
5050
}
5151

5252

53-
def test_query(config):
53+
def check_is_PNG(results):
54+
assert isinstance(results, bytes)
55+
assert results[1:4] == b'PNG'
56+
57+
58+
def test_crs_query(config):
5459
p = WMSFacadeProvider(config)
5560

56-
results = p.query()
57-
assert len(results) > 0
61+
results1 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/4326')
62+
results2 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/3857')
63+
64+
print(results1)
65+
66+
check_is_PNG(results1)
67+
check_is_PNG(results2)
5868

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

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

0 commit comments

Comments
 (0)