diff --git a/docs/source/publishing/ogcapi-maps.rst b/docs/source/publishing/ogcapi-maps.rst index a6b2aca1f..e68b4eec1 100644 --- a/docs/source/publishing/ogcapi-maps.rst +++ b/docs/source/publishing/ogcapi-maps.rst @@ -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. @@ -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`): @@ -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`. @@ -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 `_, 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 --------------------------- diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index c22991799..25ccd218b 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -8,6 +8,7 @@ # Ricardo Garcia Silva # Bernhard Mallinger # +# Copyright (c) 2026 Joana Simoes # Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn @@ -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 @@ -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', } @@ -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) try: @@ -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', @@ -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: @@ -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') @@ -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 diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index 188414fa1..c66287b70 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -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. @@ -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( diff --git a/pygeoapi/provider/wms_facade.py b/pygeoapi/provider/wms_facade.py index 467c12aba..4a69525eb 100644 --- a/pygeoapi/provider/wms_facade.py +++ b/pygeoapi/provider/wms_facade.py @@ -2,6 +2,7 @@ # # Authors: Tom Kralidis # +# Copyright (c) 2026 Joana Simoes # Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person @@ -33,6 +34,7 @@ import pyproj import requests + from pygeoapi.provider.base import BaseProvider, ProviderQueryError LOGGER = logging.getLogger(__name__) @@ -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): @@ -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 @@ -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)) @@ -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, diff --git a/pygeoapi/templates/collections/collection.html b/pygeoapi/templates/collections/collection.html index 4a3b0f7f5..786d9ff54 100644 --- a/pygeoapi/templates/collections/collection.html +++ b/pygeoapi/templates/collections/collection.html @@ -140,6 +140,7 @@

{% trans %}Storage CRS{% endtrans %}

{% block extrafoot %}