diff --git a/geemap/geemap.py b/geemap/geemap.py index ddf9bbd7f3..4a2fbcf3d2 100644 --- a/geemap/geemap.py +++ b/geemap/geemap.py @@ -35,6 +35,72 @@ basemaps = Box(xyz_to_leaflet(), frozen_box=True) +class MapDrawControl(ipyleaflet.DrawControl, map_widgets.AbstractDrawControl): + """"Implements the AbstractDrawControl for the map.""" + _roi_start = False + _roi_end = False + + def __init__(self, host_map, **kwargs): + super(MapDrawControl,self).__init__(host_map=host_map, **kwargs) + + @property + def user_roi(self): + return self.last_geometry + + @property + def user_rois(self): + return self.collection + + # NOTE: Overridden for backwards compatibility, where edited geometries are + # added to the layer instead of modified in place. Remove when + # https://github.com/jupyter-widgets/ipyleaflet/issues/1119 is fixed to + # allow geometry edits to be reflected on the tile layer. + def _handle_geometry_edited(self, geo_json): + return self._handle_geometry_created(geo_json) + + def _get_synced_geojson_from_draw_control(self): + return [data.copy() for data in self.data] + + def _bind_to_draw_control(self): + # Handles draw events + def handle_draw(_, action, geo_json): + try: + self._roi_start = True + if action == "created": + self._handle_geometry_created(geo_json) + elif action == "edited": + self._handle_geometry_edited(geo_json) + elif action == "deleted": + self._handle_geometry_deleted(geo_json) + self._roi_end = True + self._roi_start = False + except Exception as e: + self.geometries = [] + self.properties = [] + self.last_geometry = None + self._roi_start = False + self._roi_end = False + print("There was an error creating Earth Engine Feature.") + raise Exception(e) + self.on_draw(handle_draw) + # NOTE: Uncomment the following code once + # https://github.com/jupyter-widgets/ipyleaflet/issues/1119 is fixed + # to allow edited geometries to be reflected instead of added. + # def handle_data_update(_): + # self._sync_geometries() + # self.observe(handle_data_update, 'data') + + def _remove_geometry_at_index_on_draw_control(self, index): + # NOTE: Uncomment the following code once + # https://github.com/jupyter-widgets/ipyleaflet/issues/1119 is fixed to + # remove drawn geometries with `remove_last_drawn()`. + # del self.data[index] + # self.send_state(key='data') + pass + + def _clear_draw_control(self): + return self.clear() + class Map(ipyleaflet.Map): """The Map class inherits the ipyleaflet Map class. The arguments you can pass to the Map initialization @@ -46,6 +112,23 @@ class Map(ipyleaflet.Map): object: ipyleaflet map object. """ + # Map attributes for drawing features + @property + def draw_features(self): + return self.draw_control.features if self.draw_control else [] + @property + def draw_last_feature(self): + return self.draw_control.last_feature if self.draw_control else None + @property + def draw_layer(self): + return self.draw_control.layer if self.draw_control else None + @property + def user_roi(self): + return self.draw_control.user_roi if self.draw_control else None + @property + def user_rois(self): + return self.draw_control.user_rois if self.draw_control else None + def __init__(self, **kwargs): """Initialize a map object. The following additional parameters can be passed in addition to the ipyleaflet.Map parameters: @@ -170,13 +253,6 @@ def __init__(self, **kwargs): if kwargs.get(control, True): self.add_controls(control, position="bottomright") - # Map attributes for drawing features - self.draw_features = [] - self.draw_last_feature = None - self.draw_layer = None - self.user_roi = None - self.user_rois = None - # Map attributes for layers self.geojson_layers = [] self.ee_layers = [] @@ -2500,8 +2576,8 @@ def add_draw_control(self, position="topleft"): Args: position (str, optional): The position of the draw control. Defaults to "topleft". """ - - draw_control = ipyleaflet.DrawControl( + draw_control = MapDrawControl( + host_map=self, marker={"shapeOptions": {"color": "#3388ff"}}, rectangle={"shapeOptions": {"color": "#3388ff"}}, # circle={"shapeOptions": {"color": "#3388ff"}}, @@ -2510,50 +2586,6 @@ def add_draw_control(self, position="topleft"): remove=True, position=position, ) - - # Handles draw events - def handle_draw(target, action, geo_json): - try: - self._roi_start = True - geom = geojson_to_ee(geo_json, False) - self.user_roi = geom - feature = ee.Feature(geom) - self.draw_last_feature = feature - if not hasattr(self, "_draw_count"): - self._draw_count = 0 - if action == "deleted" and len(self.draw_features) > 0: - self.draw_features.remove(feature) - self._draw_count -= 1 - else: - self.draw_features.append(feature) - self._draw_count += 1 - collection = ee.FeatureCollection(self.draw_features) - self.user_rois = collection - ee_draw_layer = EELeafletTileLayer( - collection, {"color": "blue"}, "Drawn Features", False, 0.5 - ) - draw_layer_index = self.find_layer_index("Drawn Features") - - if draw_layer_index == -1: - self.add(ee_draw_layer) - self.draw_layer = ee_draw_layer - else: - self.substitute_layer(self.draw_layer, ee_draw_layer) - self.draw_layer = ee_draw_layer - self._roi_end = True - self._roi_start = False - except Exception as e: - self._draw_count = 0 - self.draw_features = [] - self.draw_last_feature = None - self.draw_layer = None - self.user_roi = None - self._roi_start = False - self._roi_end = False - print("There was an error creating Earth Engine Feature.") - raise Exception(e) - - draw_control.on_draw(handle_draw) self.add(draw_control) self.draw_control = draw_control @@ -4060,46 +4092,36 @@ def add_remote_tile( else: raise Exception("The source must be a URL.") + def remove_draw_control(self): + controls = [] + old_draw_control = None + for control in self.controls: + if isinstance(control, MapDrawControl): + old_draw_control = control + + else: + controls.append(control) + + self.controls = tuple(controls) + if old_draw_control: + old_draw_control.close() + def remove_drawn_features(self): """Removes user-drawn geometries from the map""" - if self.draw_layer is not None: - self.remove_layer(self.draw_layer) - self._draw_count = 0 - self.draw_features = [] - self.draw_last_feature = None - self.draw_layer = None - self.user_roi = None - self.user_rois = None - self._chart_values = [] - self._chart_points = [] - self._chart_labels = None if self.draw_control is not None: - self.draw_control.clear() + self.draw_control.reset() def remove_last_drawn(self): - """Removes user-drawn geometries from the map""" - if self.draw_layer is not None: - collection = ee.FeatureCollection(self.draw_features[:-1]) - ee_draw_layer = EELeafletTileLayer( - collection, {"color": "blue"}, "Drawn Features", True, 0.5 - ) - if self._draw_count == 1: + """Removes last user-drawn geometry from the map""" + if self.draw_control is not None: + if self.draw_control.count == 1: self.remove_drawn_features() - else: - self.substitute_layer(self.draw_layer, ee_draw_layer) - self.draw_layer = ee_draw_layer - self._draw_count -= 1 - self.draw_features = self.draw_features[:-1] - self.draw_last_feature = self.draw_features[-1] - self.draw_layer = ee_draw_layer - self.user_roi = ee.Feature( - collection.toList(collection.size()).get( - collection.size().subtract(1) - ) - ).geometry() - self.user_rois = collection - self._chart_values = self._chart_values[:-1] - self._chart_points = self._chart_points[:-1] + elif self.draw_control.count: + self.draw_control.remove_geometry(self.draw_control.geometries[-1]) + if hasattr(self, '_chart_values'): + self._chart_values = self._chart_values[:-1] + if hasattr(self, '_chart_points'): + self._chart_points = self._chart_points[:-1] # self._chart_labels = None def extract_values_to_points(self, filename): diff --git a/geemap/map_widgets.py b/geemap/map_widgets.py index 892fe55d1b..337f1e2726 100644 --- a/geemap/map_widgets.py +++ b/geemap/map_widgets.py @@ -1,10 +1,13 @@ """Various ipywidgets that can be added to a map.""" +import enum + import ee import ipytree import ipywidgets from . import common +from .ee_tile_layers import EELeafletTileLayer class Colorbar(ipywidgets.Output): @@ -377,3 +380,177 @@ def _objects_info(self, latlon): nodes.append(tree_node) return self._root_node("Objects", nodes) + + +class DrawActions(enum.StrEnum): + CREATED='created' + EDITED='edited' + DELETED='deleted' + REMOVED_LAST='removed-last' + + +class AbstractDrawControl(object): + host_map = None + layer = None + geometries = [] + properties = [] + last_geometry = None + last_draw_action = None + _geometry_create_dispatcher = ipywidgets.CallbackDispatcher() + _geometry_edit_dispatcher = ipywidgets.CallbackDispatcher() + _geometry_delete_dispatcher = ipywidgets.CallbackDispatcher() + + def __init__(self, host_map): + self.host_map = host_map + self.layer = None + self.geometries = [] + self.properties = [] + self.last_geometry = None + self.last_draw_action = None + self._geometry_create_dispatcher = ipywidgets.CallbackDispatcher() + self._geometry_edit_dispatcher = ipywidgets.CallbackDispatcher() + self._geometry_delete_dispatcher = ipywidgets.CallbackDispatcher() + self._bind_to_draw_control() + + @property + def features(self): + if self.count: + return [ + ee.Feature(geometry, self.properties[i]) for i, geometry in enumerate(self.geometries) + ] + else: + return [] + + @property + def collection(self): + return ee.FeatureCollection(self.features) if self.count else None + + @property + def last_feature(self): + property = self.get_geometry_properties(self.last_geometry) + return ee.Feature(self.last_geometry, property) if self.last_geometry else None + + @property + def count(self): + return len(self.geometries) + + def reset(self): + """Resets the draw controls.""" + if self.layer is not None: + self.host_map.remove_layer(self.layer) + self.geometries = [] + self.properties = [] + self.last_geometry = None + self.layer = None + self._clear_draw_control() + + def remove_geometry(self, geometry): + index = self.geometries.index(geometry) + if index >= 0: + del self.geometries[index] + del self.properties[index] + self._remove_geometry_at_index_on_draw_control(index) + if index == self.count and geometry == self.last_geometry: + # Treat this like an "undo" of the last drawn geometry. + self.last_geometry = self.geometries[-1] + self.last_draw_action = DrawActions.REMOVED_LAST + if self.layer is not None: + self._redraw_layer() + + def get_geometry_properties(self, geometry): + index = self.geometries.index(geometry) + if index >= 0: + return self.properties[index] + else: + return None + + def set_geometry_properties(self, geometry, property): + index = self.geometries.index(geometry) + if index >= 0: + self.properties[index] = property + + def on_geometry_create(self, callback, remove=False): + self._geometry_create_dispatcher.register_callback(callback, remove=remove) + + def on_geometry_edit(self, callback, remove=False): + self._geometry_edit_dispatcher.register_callback(callback, remove=remove) + + def on_geometry_delete(self, callback, remove=False): + self._geometry_delete_dispatcher.register_callback(callback, remove=remove) + + def _bind_to_draw_control(self): + """Set up draw control event handling like create, edit, and delete.""" + raise NotImplementedError() + + def _remove_geometry_at_index_on_draw_control(self): + """Remove the geometry at the given index on the draw control.""" + raise NotImplementedError() + + def _clear_draw_control(self): + """Clears the geometries from the draw control.""" + raise NotImplementedError() + + def _get_synced_geojson_from_draw_control(self): + """Returns an up-to-date of GeoJSON from the draw control.""" + raise NotImplementedError() + + def _sync_geometries(self): + """Sync the local geometries with those from the draw control.""" + if not self.count: + return + # The current geometries from the draw_control. + test_geojsons = self._get_synced_geojson_from_draw_control() + i = 0 + while i < self.count and i < len(test_geojsons): + local_geometry = None + test_geometry = None + while i < self.count and i < len(test_geojsons): + local_geometry = self.geometries[i] + test_geometry = common.geojson_to_ee(test_geojsons[i], False) + if test_geometry == local_geometry: + i += 1 + else: + break + if i < self.count and test_geometry is not None: + self.geometries[i] = test_geometry + if self.layer is not None: + self._redraw_layer() + + def _redraw_layer(self): + layer = EELeafletTileLayer( + self.collection, {"color": "blue"}, "Drawn Features", False, 0.5 + ) + if self.host_map: + layer_index = self.host_map.find_layer_index("Drawn Features") + if layer_index == -1: + self.host_map.add_layer(layer) + else: + self.host_map.substitute(self.host_map.layers[layer_index], layer) + self.layer = layer + + def _handle_geometry_created(self, geo_json): + geometry = common.geojson_to_ee(geo_json, False) + self.last_geometry = geometry + self.last_draw_action = DrawActions.CREATED + self.geometries.append(geometry) + self.properties.append(None) + self._redraw_layer() + self._geometry_create_dispatcher(self, geometry=geometry) + + def _handle_geometry_edited(self, geo_json): + geometry = common.geojson_to_ee(geo_json, False) + self.last_geometry = geometry + self.last_draw_action = DrawActions.EDITED + self._sync_geometries() + self._redraw_layer() + self._geometry_edit_dispatcher(self, geometry=geometry) + + def _handle_geometry_deleted(self, geo_json): + geometry = common.geojson_to_ee(geo_json, False) + self.last_geometry = geometry + self.last_draw_action = DrawActions.DELETED + i = self.geometries.index(geometry) + del self.geometries[i] + del self.properties[i] + self._redraw_layer() + self._geometry_delete_dispatcher(self, geometry=geometry) diff --git a/geemap/toolbar.py b/geemap/toolbar.py index f9b46beac6..f2504b9015 100644 --- a/geemap/toolbar.py +++ b/geemap/toolbar.py @@ -21,6 +21,7 @@ from .common import * from .timelapse import * +from .geemap import MapDrawControl class Toolbar(widgets.VBox): @@ -2157,7 +2158,8 @@ def button_clicked(change): if change["new"] == "Apply": if len(color.value) != 7: color.value = "#3388ff" - draw_control = ipyleaflet.DrawControl( + draw_control = MapDrawControl( + host_map = m, marker={"shapeOptions": {"color": color.value}, "repeatMode": False}, rectangle={"shapeOptions": {"color": color.value}, "repeatMode": False}, polygon={"shapeOptions": {"color": color.value}, "repeatMode": False}, @@ -2166,19 +2168,8 @@ def button_clicked(change): edit=False, remove=False, ) - - controls = [] - old_draw_control = None - for control in m.controls: - if isinstance(control, ipyleaflet.DrawControl): - controls.append(draw_control) - old_draw_control = control - - else: - controls.append(control) - - m.controls = tuple(controls) - old_draw_control.close() + m.remove_draw_control() + m.add(draw_control) m.draw_control = draw_control train_props = {} @@ -2197,52 +2188,10 @@ def button_clicked(change): train_props["color"] = color.value # Handles draw events - def handle_draw(target, action, geo_json): - from .ee_tile_layers import EELeafletTileLayer - - try: - geom = geojson_to_ee(geo_json, False) - m.user_roi = geom - - if len(train_props) > 0: - feature = ee.Feature(geom, train_props) - else: - feature = ee.Feature(geom) - m.draw_last_feature = feature - if not hasattr(m, "_draw_count"): - m._draw_count = 0 - if action == "deleted" and len(m.draw_features) > 0: - m.draw_features.remove(feature) - m._draw_count -= 1 - else: - m.draw_features.append(feature) - m._draw_count += 1 - collection = ee.FeatureCollection(m.draw_features) - m.user_rois = collection - ee_draw_layer = EELeafletTileLayer( - collection, {"color": "blue"}, "Drawn Features", False, 0.5 - ) - draw_layer_index = m.find_layer_index("Drawn Features") - - if draw_layer_index == -1: - m.add_layer(ee_draw_layer) - m.draw_layer = ee_draw_layer - else: - m.substitute_layer(m.draw_layer, ee_draw_layer) - m.draw_layer = ee_draw_layer - - except Exception as e: - m._draw_count = 0 - m.draw_features = [] - m.draw_last_feature = None - m.draw_layer = None - m.user_roi = None - m._roi_start = False - m._roi_end = False - print("There was an error creating Earth Engine Feature.") - raise Exception(e) - - draw_control.on_draw(handle_draw) + def set_properties(_, geometry): + if len(train_props) > 0: + draw_control.set_geometry_properties(geometry, train_props) + draw_control.on_geometry_create(set_properties) elif change["new"] == "Clear": prop_text1.value = "" @@ -2255,6 +2204,9 @@ def handle_draw(target, action, geo_json): if m.training_ctrl is not None and m.training_ctrl in m.controls: m.remove_control(m.training_ctrl) full_widget.close() + # Restore default draw control. + m.remove_draw_control() + m.add_draw_control() buttons.value = None buttons.observe(button_clicked, "value")