diff --git a/leafmap/leafmap.py b/leafmap/leafmap.py index 27a3e26dc9..98a351db11 100644 --- a/leafmap/leafmap.py +++ b/leafmap/leafmap.py @@ -51,6 +51,7 @@ def __init__(self, **kwargs): self.draw_features = [] self.api_keys = {} self.geojson_layers = [] + self.edit_mode = False # sandbox path for Voila app to restrict access to system directories. if "sandbox_path" not in kwargs: @@ -101,12 +102,33 @@ def __init__(self, **kwargs): self.add_control(draw_control) self.draw_control = draw_control + draw_output = widgets.Output() + control = ipyleaflet.WidgetControl( + widget=draw_output, position="bottomright" + ) + self.add_control(control) + def handle_draw(target, action, geo_json): + if "style" in geo_json["properties"]: + del geo_json["properties"]["style"] self.user_roi = geo_json - if action == "deleted" and len(self.draw_features) > 0: - self.draw_features.remove(geo_json) - else: + + if action in ["created", "edited"]: + feature = { + "type": "Feature", + "geometry": geo_json["geometry"], + } self.draw_features.append(geo_json) + elif action == "deleted": + geometries = [ + feature["geometry"] for feature in self.draw_control.data + ] + for geom in geometries: + if geom == geo_json["geometry"]: + geometries.remove(geom) + for feature in self.draw_features: + if feature["geometry"] not in geometries: + self.draw_features.remove(feature) self.user_rois = { "type": "FeatureCollection", "features": self.draw_features, @@ -2731,7 +2753,7 @@ def get_pc_collections(self): if not hasattr(self, "pc_collections"): setattr(self, "pc_collections", get_pc_collections()) - def save_draw_features(self, out_file, indent=4): + def save_draw_features(self, out_file, indent=4, **kwargs): """Save the draw features to a file. Args: @@ -2740,10 +2762,101 @@ def save_draw_features(self, out_file, indent=4): """ import json + self.update_draw_features() out_file = check_file_path(out_file) - + geojson = { + "type": "FeatureCollection", + "features": self.draw_features, + } with open(out_file, "w") as f: - json.dump(self.user_rois, f, indent=indent) + if indent is None: + json.dump(geojson, f, **kwargs) + else: + json.dump(geojson, f, indent=indent, **kwargs) + + def last_edit_data(self): + + import pandas as pd + + df = pd.DataFrame( + { + "Key": [""], + "Value": [""], + } + ) + if self.draw_control.last_action == "edited": + properties = self.draw_control.last_draw["properties"] + if "style" in properties: + properties.pop("style") + print(properties) + df = pd.DataFrame( + {"Key": list(properties.keys()), "Value": list(properties.values())} + ) + elif ( + self.draw_control.last_action == "created" + and len(self.draw_control.data) > 1 + ): + print(len(self.draw_control.data)) + print(self.draw_control.data) + properties = self.draw_control.data[-2]["properties"] + if "style" in properties: + properties.pop("style") + print(properties) + df = pd.DataFrame( + {"Key": list(properties.keys()), "Value": [""] * len(properties)} + ) + return df + + def update_draw_features(self): + """Update the draw features by removing features that have been edited and no longer exist.""" + + geometries = [feature["geometry"] for feature in self.draw_control.data] + + for feature in self.draw_features: + if feature["geometry"] not in geometries: + self.draw_features.remove(feature) + + def get_draw_props(self, n=None, return_df=False): + """Get the properties of the draw features. + + Args: + n (int, optional): The maximum number of attributes to return. Defaults to None. + return_df (bool, optional): If True, return a pandas dataframe. Defaults to False. + + Returns: + pd.DataFrame: A pandas dataframe containing the properties of the draw features. + """ + + import pandas as pd + + props = None + if self.draw_control.last_action == "edited": + self.update_draw_features() + if len(self.draw_features) > 0: + keys = self.draw_features[-1]["properties"].keys() + if len(keys) > 0: + props = list(keys) + + if props is not None: + if n is not None and n <= len(props): + n = len(props) + elif n is not None and n > len(props): + props = props + [""] * (n - len(props)) + + if not return_df: + return props + else: + + df = pd.DataFrame({"Key": props, "Value": [""] * len(props)}) + df.index += 1 + return df + else: + if not return_df: + return [] + else: + df = pd.DataFrame({"Key": [""] * n, "Value": [""] * n}) + df.index += 1 + return df # The functions below are outside the Map class. diff --git a/leafmap/toolbar.py b/leafmap/toolbar.py index c40215f34c..4954cccb10 100644 --- a/leafmap/toolbar.py +++ b/leafmap/toolbar.py @@ -264,10 +264,14 @@ def main_toolbar(m): "name": "attribute_table", "tooltip": "Open attribute table", }, - "smile-o": { - "name": "placeholder2", - "tooltip": "This is a placeholder", + "pencil-square-o": { + "name": "edit_vector", + "tooltip": "Edit vector data attribute table", }, + # "smile-o": { + # "name": "placeholder2", + # "tooltip": "This is a placeholder", + # }, "spinner": { "name": "placeholder2", "tooltip": "This is a placeholder", @@ -372,6 +376,8 @@ def tool_callback(change): search_geojson_gui(m) elif tool_name == "attribute_table": select_table_gui(m) + elif tool_name == "edit_vector": + edit_draw_gui(m) elif tool_name == "help": import webbrowser @@ -4053,3 +4059,190 @@ def close_btn_click(change): m.table_control = table_control else: return toolbar_widget + + +def edit_draw_gui(m): + """Generates a tool GUI for editing vector data attribute table. + + Args: + m (leafmap.Map, optional): The leaflet Map object. Defaults to None. + + Returns: + ipywidgets: The tool GUI widget. + """ + import ipysheet + import pandas as pd + + widget_width = "250px" + padding = "0px 0px 0px 5px" # upper, right, bottom, left + style = {"description_width": "initial"} + m.edit_mode = True + + n_props = len(m.get_draw_props()) + if n_props == 0: + n_props = 1 + + toolbar_button = widgets.ToggleButton( + value=False, + tooltip="Edit attribute table", + icon="pencil-square-o", + layout=widgets.Layout(width="28px", height="28px", padding="0px 0px 0px 4px"), + ) + + close_button = widgets.ToggleButton( + value=False, + tooltip="Close the tool", + icon="times", + button_style="primary", + layout=widgets.Layout(height="28px", width="28px", padding="0px 0px 0px 4px"), + ) + + save_button = widgets.ToggleButton( + value=False, + tooltip="Save to file", + icon="floppy-o", + # button_style="primary", + layout=widgets.Layout(height="28px", width="28px", padding="0px 0px 0px 4px"), + ) + + int_slider = widgets.IntSlider( + min=n_props, + max=n_props + 10, + description="Attributes:", + readout=False, + continuous_update=True, + layout=widgets.Layout(width="135px", padding=padding), + style=style, + ) + + int_slider_label = widgets.Label() + widgets.jslink((int_slider, "value"), (int_slider_label, "value")) + + def int_slider_changed(change): + if change["new"]: + with output: + output.clear_output() + sheet = ipysheet.from_dataframe( + m.get_draw_props(n=int_slider.value, return_df=True) + ) + display(sheet) + + int_slider.observe(int_slider_changed, "value") + + buttons = widgets.ToggleButtons( + value=None, + options=["Apply", "Reset", "Close"], + tooltips=["Apply", "Reset", "Close"], + button_style="primary", + ) + buttons.style.button_width = "60px" + + output = widgets.Output(layout=widgets.Layout(width=widget_width, padding=padding)) + m.edit_output = output + + with output: + sheet = ipysheet.from_dataframe( + m.get_draw_props(n=int_slider.value, return_df=True) + ) + output.clear_output() + display(sheet) + + toolbar_widget = widgets.VBox() + toolbar_widget.children = [toolbar_button] + toolbar_header = widgets.HBox() + toolbar_header.children = [ + close_button, + toolbar_button, + save_button, + int_slider, + int_slider_label, + ] + toolbar_footer = widgets.VBox() + toolbar_footer.children = [ + output, + buttons, + ] + + toolbar_event = ipyevents.Event( + source=toolbar_widget, watched_events=["mouseenter", "mouseleave"] + ) + + def handle_toolbar_event(event): + + if event["type"] == "mouseenter": + toolbar_widget.children = [toolbar_header, toolbar_footer] + elif event["type"] == "mouseleave": + if not toolbar_button.value: + toolbar_widget.children = [toolbar_button] + toolbar_button.value = False + close_button.value = False + + toolbar_event.on_dom_event(handle_toolbar_event) + + def toolbar_btn_click(change): + if change["new"]: + close_button.value = False + toolbar_widget.children = [toolbar_header, toolbar_footer] + else: + if not close_button.value: + toolbar_widget.children = [toolbar_button] + + toolbar_button.observe(toolbar_btn_click, "value") + + def close_btn_click(change): + if change["new"]: + toolbar_button.value = False + if m is not None: + m.toolbar_reset() + if m.tool_control is not None and m.tool_control in m.controls: + m.remove_control(m.tool_control) + m.tool_control = None + m.edit_mode = False + toolbar_widget.close() + + close_button.observe(close_btn_click, "value") + + def save_btn_click(change): + if change["new"]: + save_button.value = False + m.save_draw_features("roi.geojson", indent=None) + with output: + output.clear_output() + print("Saved to 'roi.geojson'") + + save_button.observe(save_btn_click, "value") + + def button_clicked(change): + if change["new"] == "Apply": + with output: + output.clear_output() + sheet = ipysheet.sheet( + rows=m.num_attributes, columns=2, column_headers=["Key", "Value"] + ) + display(sheet) + elif change["new"] == "Reset": + output.clear_output() + elif change["new"] == "Close": + if m is not None: + m.toolbar_reset() + if m.tool_control is not None and m.tool_control in m.controls: + m.remove_control(m.tool_control) + m.tool_control = None + m.edit_mode = False + toolbar_widget.close() + + buttons.value = None + + buttons.observe(button_clicked, "value") + + toolbar_button.value = True + if m is not None: + toolbar_control = ipyleaflet.WidgetControl( + widget=toolbar_widget, position="topright" + ) + + if toolbar_control not in m.controls: + m.add_control(toolbar_control) + m.tool_control = toolbar_control + else: + return toolbar_widget