diff options
author | Daniel Schadt <kingdread@gmx.de> | 2022-11-17 22:46:42 +0100 |
---|---|---|
committer | Daniel Schadt <kingdread@gmx.de> | 2022-11-17 22:46:42 +0100 |
commit | 5607aa64cac1f67169e742d397c2ac8d9c9057a8 (patch) | |
tree | 72ab0eff61556d03cd26ffe6f543b429372804c6 | |
parent | c1f8b0e55db852e4cfffd75af34bd6f146b35b77 (diff) | |
download | fietsboek-5607aa64cac1f67169e742d397c2ac8d9c9057a8.tar.gz fietsboek-5607aa64cac1f67169e742d397c2ac8d9c9057a8.tar.bz2 fietsboek-5607aa64cac1f67169e742d397c2ac8d9c9057a8.zip |
enable custom map layers
This change shuffles around quite a bit because we no longer hardcode
our map layers in osm-monkeypatch.js, but instead can pass them through
from the Python code. This allows us to dynamically define extra layers,
for example to disable layers the admin doesn't want, or to add extra
layers that are not yet available.
This change for example allows to embed thunderforest maps by adding the
following to the config:
fietsboek.tile_layers.tf_cycling = TF Cycling
fietsboek.tile_layers.tf_cycling.url = https://thunderforest.com/...
fietsboek.tile_layers.tf_cycling.access = restricted
The next step is to make the Thunderforest maps a bit easier to access
(by providing special support for those), but for now, this seems like a
good first step and the necessary groundwork.
-rw-r--r-- | fietsboek/__init__.py | 8 | ||||
-rw-r--r-- | fietsboek/jinja2.py | 29 | ||||
-rw-r--r-- | fietsboek/static/osm-monkeypatch.js | 74 | ||||
-rw-r--r-- | fietsboek/templates/layout.jinja2 | 1 | ||||
-rw-r--r-- | fietsboek/views/tileproxy.py | 207 |
5 files changed, 244 insertions, 75 deletions
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py index 6dc435a..21674f5 100644 --- a/fietsboek/__init__.py +++ b/fietsboek/__init__.py @@ -51,7 +51,8 @@ def locale_negotiator(request): def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - # pylint: disable=unused-argument + # pylint: disable=unused-argument, import-outside-toplevel, cyclic-import + from .views import tileproxy if settings.get('session_key', '<EDIT THIS>') == '<EDIT THIS>': raise ValueError("Please set a session signing key (session_key) in your settings!") @@ -71,6 +72,10 @@ def main(global_config, **settings): settings.get('available_locales', 'en')) settings['fietsboek.pages'] = aslist( settings.get('fietsboek.pages', '')) + settings['fietsboek.default_tile_layers'] = aslist( + settings.get('fietsboek.default_tile_layers', + 'osm satellite osmde opentopo topplusopen opensea cycling hiking')) + settings['fietsboek.tile_layers'] = tileproxy.extract_tile_layers(settings) # Load the pages page_manager = Pages() @@ -104,5 +109,6 @@ def main(global_config, **settings): jinja2_env.filters['format_decimal'] = fiets_jinja2.filter_format_decimal jinja2_env.filters['format_datetime'] = fiets_jinja2.filter_format_datetime jinja2_env.filters['local_datetime'] = fiets_jinja2.filter_local_datetime + jinja2_env.globals['embed_tile_layers'] = fiets_jinja2.global_embed_tile_layers return config.make_wsgi_app() diff --git a/fietsboek/jinja2.py b/fietsboek/jinja2.py index 6043791..ff60660 100644 --- a/fietsboek/jinja2.py +++ b/fietsboek/jinja2.py @@ -1,5 +1,6 @@ """Custom filters for Jinja2.""" import datetime +import json import jinja2 from markupsafe import Markup @@ -77,3 +78,31 @@ def filter_local_datetime(ctx, value): return Markup( f'<span class="fietsboek-local-datetime" data-utc-timestamp="{timestamp}">{fallback}</span>' ) + + +def global_embed_tile_layers(request): + """Renders the available tile servers for the current user, as a JSON object. + + The returned value is wrapped as a :class:`~markupsafe.Markup` so that it + won't get escaped by jinja. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The available tile servers. + :rtype: markupsafe.Markup + """ + # pylint: disable=import-outside-toplevel,cyclic-import + from .views import tileproxy + tile_sources = tileproxy.sources_for(request) + return Markup(json.dumps([ + { + "name": source.name, + "url": request.route_url("tile-proxy", provider=source.key, x="{x}", y="{y}", z="{z}") + .replace("%7Bx%7D", "{x}") + .replace("%7By%7D", "{y}") + .replace("%7Bz%7D", "{z}"), + "attribution": source.attribution, + "type": source.layer_type.value, + } + for source in tile_sources + ])) diff --git a/fietsboek/static/osm-monkeypatch.js b/fietsboek/static/osm-monkeypatch.js index 203a4ca..a8310a2 100644 --- a/fietsboek/static/osm-monkeypatch.js +++ b/fietsboek/static/osm-monkeypatch.js @@ -19,77 +19,31 @@ const mycp = '<a href="https://www.j-berkemeier.de/GPXViewer" title="GPX Viewer '+JB.GPX2GM.ver+'">GPXViewer</a> | '; - this.baseLayers = {}; - - this.baseLayers["OSM"] = L.tileLayer(BASE_URL + 'tile/osm/{z}/{x}/{y}', { - maxZoom: 19, - attribution: mycp+'Map data © <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>' - }); - - this.baseLayers["Satellit"] = L.tileLayer(BASE_URL + 'tile/satellite/{z}/{x}/{y}', { - maxZoom: 21, - attribution: mycp+'Map data © <a href="https://www.esri.com/">Esri</a>, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' - }); - - this.baseLayers["OSMDE"] = L.tileLayer(BASE_URL + 'tile/osmde/{z}/{x}/{y}', { - maxZoom: 19, - attribution: mycp+'Map data © <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>' - }); - - this.baseLayers["Open Topo"] = L.tileLayer(BASE_URL + 'tile/opentopo/{z}/{x}/{y}', { - maxZoom: 17, - attribution: mycp+'Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | Kartendarstellung: © <a href="https://opentopomap.org/about">OpenTopoMap</a> (CC-BY-SA)' - }); + this.baseLayers = {}; + this.overlayLayers = {}; - this.baseLayers["TopPlusOpen"] = L.tileLayer(BASE_URL + 'tile/topplusopen/{z}/{x}/{y}', { - maxZoom: 18, - attribution: mycp+'Kartendaten: © <a href="https://www.bkg.bund.de/SharedDocs/Produktinformationen/BKG/DE/P-2017/170922-TopPlus-Web-Open.html" target==_blank"">Bundesamt für Kartographie und Geodäsie</a>' - }); + for (let layer of TILE_LAYERS) { + if (layer.type === "base") { + this.baseLayers[layer.name] = L.tileLayer(layer.url, { + maxZoom: layer.zoom, + attribution: layer.attribution, + }); + } else if (layer.type === "overlay") { + this.overlayLayers[layer.name] = L.tileLayer(layer.url, { + attribution: layer.attribution, + }); + } + } // https://tileserver.4umaps.com/${z}/${x}/${y}.png // zoomlevel 16 // https://www.4umaps.com/ - if(JB.GPX2GM.OSM_Cycle_Api_Key && JB.GPX2GM.OSM_Cycle_Api_Key.length>0) { - this.baseLayers["Cycle"] = L.tileLayer('https://{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey='+JB.GPX2GM.OSM_Cycle_Api_Key, { - maxZoom: 22, - attribution: mycp+'Map data © <a href="https://www.thunderforest.com/" target="_blank">OpenCycleMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>' - }); - } - - if(JB.GPX2GM.OSM_Landscape_Api_Key && JB.GPX2GM.OSM_Landscape_Api_Key.length>0) { - this.baseLayers["Landscape"] = L.tileLayer('https://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png?apikey='+JB.GPX2GM.OSM_Landscape_Api_Key, { - maxZoom: 22, - attribution: mycp+'Map data © <a href="https://www.thunderforest.com/" target="_blank">OpenLandscapeMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>' - }); - } - - if(JB.GPX2GM.OSM_Outdoors_Api_Key && JB.GPX2GM.OSM_Outdoors_Api_Key.length>0) { - this.baseLayers["Outdoors"] = L.tileLayer('https://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png?apikey='+JB.GPX2GM.OSM_Outdoors_Api_Key, { - maxZoom: 22, - attribution: mycp+'Map data © <a href="https://www.thunderforest.com/" target="_blank">OpenOutdoorsMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>' - }); - } - this.baseLayers[JB.GPX2GM.strings[JB.GPX2GM.parameters.doclang].noMap]= L.tileLayer(JB.GPX2GM.Path+"Icons/Grau256x256.png", { maxZoom: 22, attribution: mycp }); - this.overlayLayers = {}; - - this.overlayLayers["Open Sea"] = L.tileLayer(BASE_URL + 'tile/opensea/{z}/{x}/{y}', { - attribution: 'Kartendaten: © <a href="http://www.openseamap.org">OpenSeaMap</a> contributors' - }); - - this.overlayLayers["Hiking"] = L.tileLayer(BASE_URL + 'tile/hiking/{z}/{x}/{y}', { - attribution: '© <a href="http://waymarkedtrails.org">Sarah Hoffmann</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)', - }); - - this.overlayLayers["Cycling"] = L.tileLayer(BASE_URL + 'tile/cycling/{z}/{x}/{y}', { - attribution: '© <a href="http://waymarkedtrails.org">Sarah Hoffmann</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)', - }); - this.layerNameTranslate = { satellit: "Satellit", satellite: "Satellit", diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2 index bbd84f8..bf30143 100644 --- a/fietsboek/templates/layout.jinja2 +++ b/fietsboek/templates/layout.jinja2 @@ -20,6 +20,7 @@ <script> const FRIENDS_URL = {{ request.route_url('json-friends') | tojson }}; +const TILE_LAYERS = {{ embed_tile_layers(request) }}; const BASE_URL = {{ request.route_url('home') | tojson }}; const LOCALE = {{ request.localizer.locale_name.replace('_', '-') | tojson }}; {% if request.registry.settings.get("thunderforest.api_key") %} diff --git a/fietsboek/views/tileproxy.py b/fietsboek/views/tileproxy.py index 5195c91..42e1a67 100644 --- a/fietsboek/views/tileproxy.py +++ b/fietsboek/views/tileproxy.py @@ -9,6 +9,10 @@ Additionally, this protects the users' IP, as only fietsboek can see it. import datetime import random import logging +import re +from enum import Enum +from typing import NamedTuple +from itertools import chain from pyramid.view import view_config from pyramid.response import Response @@ -20,23 +24,152 @@ from requests.exceptions import ReadTimeout from .. import __VERSION__ +class LayerType(Enum): + """Enum to distinguish base layers and overlay layers.""" + BASE = "base" + OVERLAY = "overlay" + + +class LayerAccess(Enum): + """Enum discerning whether a layer is publicly accessible or restriced to + logged-in users. + + Note that in the future, a finer-grained distinction might be possible. + """ + PUBLIC = "public" + RESTRICTED = "restricted" + + +class TileSource(NamedTuple): + """Represents a remote server that can provide tiles to us.""" + key: str + """Key to indicate this source in URLs.""" + name: str + """Human-readable name of the source.""" + url_template: str + """URL with placeholders.""" + layer_type: LayerType + """Type of this layer.""" + zoom: int + """Max zoom of this layer.""" + access: LayerAccess + """Access restrictions to use this layer.""" + attribution: str + """Attribution string.""" + + LOGGER = logging.getLogger(__name__) -TILE_SOURCES = { + +def _href(url, text): + return f'<a href="{url}" target="_blank">{text}</a>' + + +_jb_copy = _href("https://www.j-berkemeier.de/GPXViewer", "GPXViewer") + + +DEFAULT_TILE_LAYERS = [ # Main base layers - 'osm': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - 'osmde': 'https://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png', - 'opentopo': 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', - 'topplusopen': 'https://sgx.geodatenzentrum.de/wmts_topplus_open/tile/' - '1.0.0/web/default/WEBMERCATOR/{z}/{y}/{x}.png', - 'satellite': 'https://server.arcgisonline.com/ArcGIS/rest/services/' - 'World_Imagery/MapServer/tile/{z}/{y}/{x}', + TileSource( + 'osm', + 'OSM', + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + LayerType.BASE, + 19, + LayerAccess.PUBLIC, + ''.join([ + _jb_copy, ' | Map data © ', + _href("https://www.openstreetmap.org/", "OpenStreetMap"), ' and contributors ', + _href("https://creativecommons.org/licenses/by-sa/2.0/", "CC-BY-SA"), + ]), + ), + TileSource( + 'satellite', + 'Satellit', + 'https://server.arcgisonline.com/ArcGIS/rest/services/' + 'World_Imagery/MapServer/tile/{z}/{y}/{x}', + LayerType.BASE, + 21, + LayerAccess.PUBLIC, + ''.join([ + _jb_copy, ' | Map data © ', _href("https://www.esri.com", "Esri"), + ', i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, ', + 'IGP, UPR-EGP, and the GIS User Community', + ]), + ), + TileSource( + 'osmde', + 'OSMDE', + 'https://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png', + LayerType.BASE, + 19, + LayerAccess.PUBLIC, + ''.join([ + _jb_copy, ' | Map data © ', + _href("https://www.openstreetmap.org/", "OpenStreetMap"), ' and contributors ', + _href("https://creativecommons.org/licenses/by-sa/2.0/", "CC-BY-SA") + ]), + ), + TileSource( + 'opentopo', + 'Open Topo', + 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', + LayerType.BASE, + 17, + LayerAccess.PUBLIC, + ''.join([ + _jb_copy, + ' | Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | Kartendarstellung: © ', + _href("https://opentopomap.org/about", "OpenTopoMap"), ' (CC-BY-SA)', + ]), + ), + TileSource( + 'topplusopen', + 'TopPlusOpen', + 'https://sgx.geodatenzentrum.de/wmts_topplus_open/tile/' + '1.0.0/web/default/WEBMERCATOR/{z}/{y}/{x}.png', + LayerType.BASE, + 18, + LayerAccess.PUBLIC, + ''.join([ + _jb_copy, ' | Kartendaten: © ', + _href("https://www.bkg.bund.de/SharedDocs/Produktinformationen" + "/BKG/DE/P-2017/170922-TopPlus-Web-Open.html", + "Bundesamt für Kartographie und Geodäsie"), + ]), + ), # Overlay layers - 'opensea': 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', - 'hiking': 'https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png', - 'cycling': 'https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png', -} + TileSource( + 'opensea', + 'OpenSea', + 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', + LayerType.OVERLAY, + None, + LayerAccess.PUBLIC, + 'Kartendaten: © <a href="http://www.openseamap.org">OpenSeaMap</a> contributors', + ), + TileSource( + 'hiking', + 'Hiking', + 'https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png', + LayerType.OVERLAY, + None, + LayerAccess.PUBLIC, + f'© {_href("http://waymarkedtrails.org", "Sarah Hoffmann")} ' + f'({_href("https://creativecommons.org/licenses/by-sa/3.0/", "CC-BY-SA")})', + ), + TileSource( + 'cycling', + 'Cycling', + 'https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png', + LayerType.OVERLAY, + None, + LayerAccess.PUBLIC, + f'© {_href("http://waymarkedtrails.org", "Sarah Hoffmann")} ' + f'({_href("https://creativecommons.org/licenses/by-sa/3.0/", "CC-BY-SA")})', + ), +] TTL = datetime.timedelta(days=7) """Time to live of cached tiles.""" @@ -61,7 +194,8 @@ def tile_proxy(request): :rtype: pyramid.response.Response """ provider = request.matchdict['provider'] - if provider not in TILE_SOURCES: + tile_sources = {source.key: source for source in sources_for(request)} + if provider not in tile_sources: return HTTPBadRequest("Invalid provider") x, y, z = (int(request.matchdict['x']), int(request.matchdict['y']), @@ -81,7 +215,7 @@ def tile_proxy(request): LOGGER.debug("Aborted attempt to contact %s due to previous timeouts", provider) return HTTPGatewayTimeout(f"Avoiding request to {provider}") - url = TILE_SOURCES[provider].format(x=x, y=y, z=z, s=random.choice("abc")) + url = tile_sources[provider].url_template.format(x=x, y=y, z=z, s=random.choice("abc")) headers = { "user-agent": f"Fietsboek-Tile-Proxy/{__VERSION__}", } @@ -99,3 +233,48 @@ def tile_proxy(request): else: request.redis.set(cache_key, resp.content, ex=TTL) return Response(resp.content, content_type=resp.headers.get("Content-type", content_type)) + + +def sources_for(request): + """Returns all eligible tile sources for the given request. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: A list of tile sources. + :rtype: list[TileSource] + """ + settings = request.registry.settings + return [ + source for source in chain( + (default_layer for default_layer in DEFAULT_TILE_LAYERS + if default_layer.key in settings["fietsboek.default_tile_layers"]), + settings["fietsboek.tile_layers"] + ) + if source.access == LayerAccess.PUBLIC or request.identity is not None + ] + + +def extract_tile_layers(settings): + """Extract all defined tile layers from the settings. + + :param settings: The application settings. + :type settings: dict + :return: A list of extracted tile sources. + :rtype: list[TileSource] + """ + layers = [] + for key in settings.keys(): + match = re.match("^fietsboek\\.tile_layer\\.([A-Za-z0-9_-]+)$", key) + if not match: + continue + + provider_id = match.group(1) + name = settings[key] + url = settings[f"{key}.url"] + layer_type = LayerType(settings.get(f"{key}.type", "base")) + zoom = int(settings.get(f"{key}.zoom", 22)) + attribution = settings.get(f"{key}.attribution", _jb_copy) + access = LayerAccess(settings.get(f"{key}.access", "public")) + + layers.append(TileSource(provider_id, name, url, layer_type, zoom, access, attribution)) + return layers |