Source code for Map

"""Map widget for interactive display of geospatial datasets"""
# Author(s): Davide.De-Marchi@ec.europa.eu, Edoardo.Ramalli@ec.europa.eu
# Copyright © European Union 2024
# 
# Licensed under the EUPL, Version 1.2 or as soon they will be approved by 
# the European Commission subsequent versions of the EUPL (the "Licence");
# 
# You may not use this work except in compliance with the Licence.
# 
# You may obtain a copy of the Licence at:
# https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12

# Unless required by applicable law or agreed to in writing, software
# distributed under the Licence is distributed on an "AS IS"
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
# express or implied.
# 
# See the Licence for the specific language governing permissions and
# limitations under the Licence.

# Imports
from ipywidgets import widgets, HTML, Layout, CallbackDispatcher
import ipyleaflet
from ipyleaflet import SearchControl, ScaleControl, FullScreenControl, WidgetControl, DrawControl, Icon
import ipyvuetify as v
from threading import Timer

# Vois imports
from vois.vuetify import settings, toggle, switch
from vois.geo import mapUtils
from vois.templates import PageConfigurator

# Name of layers
LAYERNAME_BACKGROUND  = 'Background'
LAYERNAME_LABELS      = 'Labels'


#####################################################################################################################################################
# Map class
#####################################################################################################################################################
[docs] class Map(ipyleaflet.Map): """ Map widget for interactive display of geospatial datasets. This class inherits from the `ipyleaflet.Map class <https://ipyleaflet.readthedocs.io/en/latest/map_and_basemaps/map.html>`_ and adds methods and controls to simplify the Map customisation. Parameters ---------- width : str, optional Width of the Map widget (default is '100%'). height : str, optional Height of the Map widget (default is '600px'). show_fullscreen : bool, optional Show or Hide the fullscreen control (default is True). show_search : bool, optional Show or Hide the Search control (default is True). show_scale : bool, optional Show or Hide the Scale control (default is True). show_coordinates : bool, optional Show or hide the Coordinates control (default is True). show_overview : bool, optional Show or hide the Overview control (default is False). show_basemaps : bool, optional Show or hide the Basemaps toggle control (default is True). color_first : str, optional Main color of the overlapped widgets (default is settings.color_first). color_second : str, optional Secondary color of the overlapped widgets (default is settings.color_second). dark : bool, optional Dark flag (default is settings.dark_mode). basemapindex : int, optional Initial basemap index (0=EC, 1=Esri, 2=Google), default is 0. onclick : python function, optional Callback function to call on click (receives as parameter: map, lon, lat, zoom), default is None. Example ------- Creation of a map instance displaying an overview window:: from IPython.display import display from vois.geo import Map m = Map(show_overview=True, center=[52,12], zoom=4, basemapindex=1) display(m) .. figure:: figures/Map.png :scale: 70 % :alt: Map widget Example of a Map with overview """ # Initialization def __init__(self, width='100%', height='600px', center=[50, 12], zoom=5, show_fullscreen=True, # Show or Hide the fullscreen control show_search=True, # Show or Hide the Search control show_scale=True, # Show or Hide the Scale control show_coordinates=True, # Show or hide the Coordinates control show_overview=False, # Show or hide the Overview control show_basemaps=True, # Show or hide the Basemaps toggle control color_first=None, # Main color color_second=None, # Secondary color dark=None, # Dark flag basemapindex=0, # Initial basemap index (0=EC, 1=Esri, 2=Google) onclick=None, # Callback function to call on click (receives as parameter: map, lon, lat, zoom) **kwargs): self._width = width self._height = height self._show_fullscreen = show_fullscreen self._show_search = show_search self._show_scale = show_scale self._show_coordinates = show_coordinates self._show_overview = show_overview self._show_basemaps = show_basemaps self._onclick = onclick self._color_first = color_first if self._color_first is None: self._color_first = settings.color_first self._color_second = color_second if self._color_second is None: self._color_second = settings.color_second self._dark = dark if self._dark is None: self._dark = settings.dark_mode self._basemapindex = basemapindex # Initial center and zoom of the map self.center = center self.zoom = zoom # List of WKT strings edited using the DrawControl self.wktstrings = [] # DrawControl instance self._drawctrl = None # Card containing the widgets to configure the map appearance self.configure_card = None self.s1 = self.s2 = self.s3 = self.s4 = self.s5 = self.s6 = None self.update_properties = True # Map widget super().__init__(max_zoom=21, center=self.center, zoom=self.zoom, scroll_wheel_zoom=True, basemap=mapUtils.EmptyBasemap(), attribution_control=False, layout=Layout(width=self._width, height=self._height, margin='0px 0px 0px 0px'), **kwargs) mapUtils.addLayer(self, mapUtils.OSM_EC(), LAYERNAME_BACKGROUND) mapUtils.addLayer(self, mapUtils.CartoLabels(), LAYERNAME_LABELS) layer = mapUtils.getLayer(self, LAYERNAME_LABELS) layer.opacity = 0.0 self.onSelectBasemap(self._basemapindex) self.toggleBasemap = None self.cardCoordinates = None # Add FullScreen control self.show_fullscreen = self._show_fullscreen # Add Search control self.show_search = self._show_search # Add Scale control self.show_scale = self._show_scale # Add widget control to select basemaps self.show_basemaps = self._show_basemaps # Add overview self.show_overview = self._show_overview # Add widget control to display geographic coordinates at mouse move self.show_coordinates = self._show_coordinates # Manage interaction events self._interaction_callbacks = CallbackDispatcher() self.on_interaction(self.handleMapInteraction) # Fake "draw" method def draw(self): return self # Remove all layers from map
[docs] def clear(self): """ Remove all the additional layers from the map. """ mapUtils.clear(self) mapUtils.addLayer(self, mapUtils.EmptyBasemap(), LAYERNAME_BACKGROUND) mapUtils.addLayer(self, mapUtils.CartoLabels(), LAYERNAME_LABELS) self.onSelectBasemap(self.basemapindex)
# Add a ipyleaflet.TileLayer to the map
[docs] def addLayer(self, tileLayer, name=None, opacity=1.0): """ Add a new layer to the map. Parameters ---------- tileLayer : ipyleaflet.TileLayer or a class that has a tileLayer() callable Layer to add to the map name : str, optional Name of the layer (default is None) opacity : float, optional Opacity of the layer in the range [0.0,1.0] (default is 1.0) Returns ------- layer : instance of ipyleafler.Layer The layer added to the map """ # if no name is passed, generate a layer name if name is None: count = len(self.layers) name = "layer%d"%(count+1) # In case a vectorlayer or rasterlayer is passed: call .tileLayer() to get the ipyleaflet.TileLayer instance! if isinstance(tileLayer, ipyleaflet.TileLayer): res = mapUtils.addLayer(self, tileLayer, name=name, opacity=opacity) else: res = mapUtils.addLayer(self, tileLayer.tileLayer(), name=name, opacity=opacity) return res
# Manage all user interaction on the map def handleMapInteraction(self, **kwargs): if self._show_coordinates: if kwargs.get('type') == 'mousemove': lon = kwargs.get('coordinates')[1] lat = kwargs.get('coordinates')[0] self.cardCoordinates.children = [v.Html(tag='div', children=[' %.4f° N, %.4f° E'%(lat,lon)], style_='color: black;', class_='pa-0 ma-0 ml-1 mr-1')] if self._onclick is not None: if kwargs.get('type') == 'click': lon = kwargs.get('coordinates')[1] lat = kwargs.get('coordinates')[0] self._onclick(self, lon, lat, int(self.zoom)) # Selection of a basemap def onSelectBasemap(self, index): layer = mapUtils.getLayer(self, LAYERNAME_LABELS) self._basemapindex = max(0, min(2, index)) if self._basemapindex == 1: basemaplayer = mapUtils.EsriWorldImagery() layer.opacity = 1.0 elif self._basemapindex == 2: basemaplayer = mapUtils.GoogleHybrid() layer.opacity = 0.0 else: basemaplayer = mapUtils.OSM_EC() layer.opacity = 0.0 mapUtils.addLayer(self, basemaplayer, LAYERNAME_BACKGROUND) # Change of configure widgets def change_fullscreen(self, flag): if self.update_properties: self.show_fullscreen = flag def change_coordinates(self, flag): if self.update_properties: self.show_coordinates = flag def change_search(self, flag): if self.update_properties: self.show_search = flag def change_scale(self, flag): if self.update_properties: self.show_scale = flag def change_basemaps(self, flag): if self.update_properties: self.show_basemaps = flag def change_overview(self, flag): if self.update_properties: self.show_overview = flag # Create a card instance to configure the Map appearance and returns it def configure(self): if self.configure_card is None: self.configure_card = v.Card(flat=True, class_='pa-2 ma-0') self.s1 = switch.switch(self.show_fullscreen, 'Add the Fullscreen control', inset=True, dense=True, onchange=self.change_fullscreen, color=self._color_first) self.s2 = switch.switch(self.show_coordinates, 'Add the Coordinates control', inset=True, dense=True, onchange=self.change_coordinates, color=self._color_first) self.s3 = switch.switch(self.show_search, 'Add the Search control', inset=True, dense=True, onchange=self.change_search, color=self._color_first) self.s4 = switch.switch(self.show_scale, 'Add the Scale control', inset=True, dense=True, onchange=self.change_scale, color=self._color_first) self.s5 = switch.switch(self.show_basemaps, 'Add the Basemaps control', inset=True, dense=True, onchange=self.change_basemaps, color=self._color_first) self.s6 = switch.switch(self.show_overview, 'Add the Overview control', inset=True, dense=True, onchange=self.change_overview, color=self._color_first) self.configure_card.children = [widgets.VBox([PageConfigurator.label('Map', color='black'), self.s1.draw(), self.s2.draw(), self.s3.draw(), self.s4.draw(), self.s5.draw(), self.s6.draw()])] return self.configure_card @property def content(self): return 'Map' @content.setter def content(self, c): pass @property def width(self): """ Get/Set the width of the Map widget. """ return self._width @width.setter def width(self, w): self._width = w self.layout.width = self._width @property def height(self): """ Get/Set the height of the Map widget. """ return self._height @height.setter def height(self, h): self._height = h self.layout.height = self._height @property def show_fullscreen(self): """ Display or hides the Fullscreen control. """ return self._show_fullscreen @show_fullscreen.setter def show_fullscreen(self, flag): self._show_fullscreen = flag if self.s1 is not None: self.update_properties = False self.s1.value = flag self.update_properties = True for control in self.controls: if isinstance(control, ipyleaflet.leaflet.FullScreenControl): self.remove(control) if self._show_fullscreen: self.add(FullScreenControl(position="topright")) @property def show_coordinates(self): """ Display or hides the Coordinates control. """ return self._show_coordinates @show_coordinates.setter def show_coordinates(self, flag): self._show_coordinates = flag if self.s2 is not None: self.update_properties = False self.s2.value = flag self.update_properties = True mapUtils.removeCoordinates(self) if self._show_coordinates: self.cardCoordinates = mapUtils.getCoordinatesCard(self) @property def show_search(self): """ Display or hides the Search control. """ return self._show_search @show_search.setter def show_search(self, flag): self._show_search = flag if self.s3 is not None: self.update_properties = False self.s3.value = flag self.update_properties = True for control in self.controls: if isinstance(control, ipyleaflet.leaflet.SearchControl): self.remove(control) if self._show_search: self.add(SearchControl(position="topleft",url='https://nominatim.openstreetmap.org/search?format=json&q={s}',zoom=12)) @property def show_scale(self): """ Display or hides the Scale control. """ return self._show_scale @show_scale.setter def show_scale(self, flag): self._show_scale = flag if self.s4 is not None: self.update_properties = False self.s4.value = flag self.update_properties = True for control in self.controls: if isinstance(control, ipyleaflet.leaflet.ScaleControl): self.remove(control) if self._show_scale: self.add(ScaleControl(position='topleft')) @property def show_basemaps(self): """ Display or hides the Basemaps selection control. """ return self._show_basemaps @show_basemaps.setter def show_basemaps(self, flag): self._show_basemaps = flag if self.s5 is not None: self.update_properties = False self.s5.value = flag self.update_properties = True mapUtils.removeCardByName(self, 'Basemaps') if self._show_basemaps: c = mapUtils.getCardByName(self, 'Basemaps', position='bottomright') c.tile = True self.toggleBasemap = toggle.toggle(self._basemapindex, ['Gisco', 'Esri', 'Google'], tooltips=['Select EC Gisco roadmap as background layer', 'Select ESRI WorldImagery as background layer', 'Select GOOGLE Satellite as background layer'], colorselected=self._color_first, colorunselected=self._color_second, rounded=False, dark=self._dark, onchange=self.onSelectBasemap, row=True, width=70, justify='start', paddingrow=0, tile=True) c.children = [self.toggleBasemap.draw()] @property def show_overview(self): """ Display or hides the Overview control. """ return self._show_overview @show_overview.setter def show_overview(self, flag): self._show_overview = flag mapUtils.removeOverview(self) if self.s6 is not None: self.update_properties = False self.s6.value = flag self.update_properties = True if self._show_overview: mapUtils.addOverview(self, position='bottomleft') @property def color_first(self): """ Get/Set the primary color. """ return self._color_first @color_first.setter def color_first(self, color): self._color_first = color if self.toggleBasemap is not None: self.toggleBasemap.colorselected = self._color_first if self.configure_card is not None: self.s1.color = self._color_first self.s2.color = self._color_first self.s3.color = self._color_first self.s4.color = self._color_first self.s5.color = self._color_first self.s6.color = self._color_first @property def color_second(self): """ Get/Set the secondary color. """ return self._color_second @color_second.setter def color_second(self, color): self._color_second = color if self.toggleBasemap is not None: self.toggleBasemap.colorunselected = self._color_second @property def dark(self): """ Get/Set the dark mode flag. """ return self._dark @dark.setter def dark(self, flag): self._dark = flag if self.toggleBasemap is not None: self.toggleBasemap.dark = self._dark @property def basemapindex(self): """ Get/Set the current basemap (index from 0 to 1). """ return self._basemapindex @basemapindex.setter def basemapindex(self, index): self.onSelectBasemap(index) if self.toggleBasemap is not None: self.toggleBasemap.value = self._basemapindex @property def state(self): return {x: getattr(self, x) for x in ['content', #'width', # Will inherit from content!!! #'height', 'show_fullscreen', 'show_coordinates', 'show_search', 'show_scale', 'show_basemaps', 'show_overview', 'color_first', 'color_second', 'dark', 'basemapindex', 'center', 'zoom' ]} @state.setter def state(self, statusdict): for key, value in statusdict.items(): setattr(self, key, value) @property def onclick(self): """ Get/Set the python function to call when the user clicks on the map. """ return self._onclick @onclick.setter def onclick(self, callback): self._onclick = callback ##################################################################################################################################################### # Feature draw ##################################################################################################################################################### @property def drawctrl(self): """ Display or hides the Feature Draw control. The wktstrings member of the Map class contains the list of all the features added in WKT format. """ if self._drawctrl is None: return False else: return True @drawctrl.setter def drawctrl(self, flag): if flag: if self._drawctrl is None: self._drawctrl = DrawControl() self._drawctrl.marker = { "shapeOptions": { "color": self._color_first, "weight": 4, "opacity": 1.0 } } self._drawctrl.polyline = { "shapeOptions": { "color": self._color_first, "weight": 4, "opacity": 1.0 } } self._drawctrl.polygon = { "shapeOptions": { "fillColor": self._color_first, "color": self._color_first, "fillOpacity": 0.2 }, "drawError": { "color": "#dd253b", "message": "Error!" }, "allowIntersection": True } self._drawctrl.circle = {} self._drawctrl.circlemarker = {} self._drawctrl.rectangle = { "shapeOptions": { "fillColor": self._color_first, "color": self._color_first, "fillOpacity": 0.2 } } self._drawctrl.on_draw(callback=self.on_draw) self.add(self._drawctrl) else: if self._drawctrl is not None: self.remove(self._drawctrl) self._drawctrl = None def updateWKT(self): self.wktstrings = [geojson2WKT(x) for x in self._drawctrl.data] def on_draw(self, control, action, geo_json): t = Timer(1.0, self.updateWKT) t.start()
##################################################################################################################################################### # Utility: convert geojson to WKT ##################################################################################################################################################### def geojson2WKT(geojson): # Force a longitude in [0,360] def lon(x): while x > 360: x -= 360 while x < 0: x += 360 return x # Force a latitude in [0,90] def lat(y): while y > 90: y -= 90 while y < 0: y += 90 return y # From a list of x,y to lon,lat def c2latlon(c): return lon(c[0]), lat(c[1]) coords = geojson['geometry'] wkt = coords['type'].upper() + '(' if coords['type'] == 'Point': c = coords['coordinates'] wkt += '%f %f'%c2latlon(c) elif coords['type'] == 'LineString': wkt += ','.join(['%f %f'%c2latlon(x) for x in coords['coordinates']]) else: for ring in coords['coordinates']: wkt += '(' + ','.join(['%f %f'%c2latlon(x) for x in ring]) + ')' break wkt += ')' return wkt