diff --git a/docs/maplibre/add_icon.ipynb b/docs/maplibre/add_icon.ipynb index 6bfaf628fb..6b3f15cbe4 100644 --- a/docs/maplibre/add_icon.ipynb +++ b/docs/maplibre/add_icon.ipynb @@ -62,7 +62,7 @@ "m.add_layer_control()\n", "image = \"https://i.imgur.com/ZMMvXuT.png\"\n", "m.add_symbol(\n", - " image, source=\"Streams\", icon_size=0.1, symbol_placement=\"line\", minzoom=10\n", + " source=\"Streams\", image=image, icon_size=0.1, symbol_placement=\"line\", minzoom=10\n", ")\n", "m" ] @@ -79,7 +79,11 @@ "m.add_layer_control()\n", "image = \"https://i.imgur.com/ZMMvXuT.png\"\n", "m.add_symbol(\n", - " image, source=\"Hiking Trail\", icon_size=0.1, symbol_placement=\"line\", minzoom=10\n", + " source=\"Hiking Trail\",\n", + " image=image,\n", + " icon_size=0.1,\n", + " symbol_placement=\"line\",\n", + " minzoom=10,\n", ")\n", "m" ] diff --git a/leafmap/maplibregl.py b/leafmap/maplibregl.py index 4aaa953b18..81c8ee321f 100644 --- a/leafmap/maplibregl.py +++ b/leafmap/maplibregl.py @@ -2164,8 +2164,8 @@ def add_image( def add_symbol( self, - image: str, source: str, + image: str, icon_size: int = 1, symbol_placement: str = "line", minzoom: Optional[float] = None, @@ -2178,8 +2178,9 @@ def add_symbol( Adds a symbol to the map. Args: - image (str): The URL or local file path to the image. source (str): The source of the symbol. + image (str): The URL or local file path to the image. Default to the arrow image. + at https://assets.gishub.org/images/arrow.png. icon_size (int, optional): The size of the symbol. Defaults to 1. symbol_placement (str, optional): The placement of the symbol. Defaults to "line". minzoom (Optional[float], optional): The minimum zoom level for the symbol. Defaults to None. @@ -2192,6 +2193,7 @@ def add_symbol( Returns: None """ + id = f"image_{common.random_string(3)}" self.add_image(id, image) @@ -2221,6 +2223,34 @@ def add_symbol( self.add_layer(layer) + def add_arrow( + self, + source: str, + image: Optional[str] = None, + icon_size: int = 0.1, + minzoom: Optional[float] = 19, + **kwargs: Any, + ) -> None: + """ + Adds an arrow symbol to the map. + + Args: + source (str): The source layer to which the arrow symbol will be added. + image (Optional[str], optional): The URL of the arrow image. + Defaults to "https://assets.gishub.org/images/arrow.png". + icon_size (int, optional): The size of the icon. Defaults to 0.1. + minzoom (Optional[float], optional): The minimum zoom level at which + the arrow symbol will be visible. Defaults to 19. + **kwargs: Additional keyword arguments to pass to the add_symbol method. + + Returns: + None + """ + if image is None: + image = "https://assets.gishub.org/images/arrow.png" + + self.add_symbol(source, image, icon_size, minzoom=minzoom, **kwargs) + def to_streamlit( self, width: Optional[int] = None, @@ -2727,11 +2757,9 @@ def add_legend( else: labels = list(legend_dict.keys()) colors = list(legend_dict.values()) - if all(isinstance(item, tuple) for item in colors): - try: - colors = [common.rgb_to_hex(x) for x in colors] - except Exception as e: - print(e) + if isinstance(colors[0], tuple) and len(colors[0]) == 2: + labels = [color[0] for color in colors] + colors = [color[1] for color in colors] allowed_positions = [ "top-left", @@ -3523,10 +3551,10 @@ def add_nlcd(self, years: list = [2023], add_legend: bool = True, **kwargs) -> N def add_gps_trace( self, data: Union[str, List[Dict[str, Any]]], - x: str = "longitude", - y: str = "latitude", + x: str = None, + y: str = None, columns: Optional[List[str]] = None, - color_column: Optional[str] = None, + ann_column: Optional[str] = None, colormap: Optional[Dict[str, str]] = None, radius: int = 5, circle_color: Optional[Union[str, List[Any]]] = None, @@ -3534,9 +3562,13 @@ def add_gps_trace( opacity: float = 1.0, paint: Optional[Dict[str, Any]] = None, name: str = "GPS Trace", - add_line: bool = False, + add_line: bool = True, sort_column: Optional[str] = None, line_args: Optional[Dict[str, Any]] = None, + add_draw_control: bool = True, + draw_control_args: Optional[Dict[str, Any]] = None, + add_legend: bool = True, + legend_args: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> None: """ @@ -3544,9 +3576,12 @@ def add_gps_trace( Args: data (Union[str, List[Dict[str, Any]]]): The GPS trace data. It can be a GeoJSON file path or a list of coordinates. - x (str, optional): The column name for the x coordinates. Defaults to "longitude". - y (str, optional): The column name for the y coordinates. Defaults to "latitude". + x (str, optional): The column name for the x coordinates. Defaults to None, + which assumes the x coordinates are in the "longitude", "lon", or "x" column. + y (str, optional): The column name for the y coordinates. Defaults to None, + which assumes the y coordinates are in the "latitude", "lat", or "y" column. columns (Optional[List[str]], optional): The list of columns to include in the GeoDataFrame. Defaults to None. + ann_column (Optional[str], optional): The column name to use for coloring the GPS trace points. Defaults to None. colormap (Optional[Dict[str, str]], optional): The colormap for the GPS trace. Defaults to None. radius (int, optional): The radius of the GPS trace points. Defaults to 5. circle_color (Optional[Union[str, List[Any]]], optional): The color of the GPS trace points. Defaults to None. @@ -3554,15 +3589,21 @@ def add_gps_trace( opacity (float, optional): The opacity of the GPS trace points. Defaults to 1.0. paint (Optional[Dict[str, Any]], optional): The paint properties for the GPS trace points. Defaults to None. name (str, optional): The name of the GPS trace layer. Defaults to "GPS Trace". - add_line (bool, optional): If True, adds a line connecting the GPS trace points. Defaults to False. + add_line (bool, optional): If True, adds a line connecting the GPS trace points. Defaults to True. sort_column (Optional[str], optional): The column name to sort the points before connecting them as a line. Defaults to None. - line_args (Optional[Dict[str, Any]], optional): Additional arguments for the line layer. Defaults to None. **kwargs (Any): Additional keyword arguments to pass to the add_geojson method. Returns: None """ - import geopandas as gpd + + if add_draw_control: + if draw_control_args is None: + draw_control_args = { + "controls": ["polygon", "trash"], + "position": "top-right", + } + self.add_draw_control(**draw_control_args) if isinstance(data, str): gdf = common.points_from_xy(data, x=x, y=y) @@ -3570,13 +3611,15 @@ def add_gps_trace( gdf = data else: raise ValueError( - "Invalid data type. Use a GeoDataFrame or a list of coordinates." + "Invalid data type. Use a GeoDataFrame or a file path to a CSV file." ) setattr(self, "gps_trace", gdf) if add_line: - line_gdf = common.connect_points_as_line(gdf, sort_column=sort_column) + line_gdf = common.connect_points_as_line( + gdf, sort_column=sort_column, single_line=True + ) else: line_gdf = None @@ -3589,36 +3632,51 @@ def add_gps_trace( "selected": "#FFFF00", } - if columns is None: + if add_legend: + if legend_args is None: + legend_args = { + "legend_dict": colormap, + "shape_type": "circle", + } + self.add_legend(**legend_args) + + if isinstance(list(colormap.values())[0], tuple): + keys = list(colormap.keys()) + values = [value[1] for value in colormap.values()] + colormap = dict(zip(keys, values)) + + if ann_column is None: if "annotation" in gdf.columns: - if color_column is None: - color_column = "category" - gdf[color_column] = gdf["annotation"] - columns = [ - "latitude", - "longitude", - "annotation", - color_column, - "geometry", + ann_column = "annotation" + else: + raise ValueError( + "Please specify the ann_column parameter or add an 'annotation' column to the GeoDataFrame." + ) + + ann_column_bk = f"{ann_column}_bk" + gdf[ann_column_bk] = gdf[ann_column] + + if columns is None: + columns = [ + ann_column, + ann_column_bk, + "geometry", + ] + gdf = gdf[columns] + setattr(self, "gdf", gdf) + if circle_color is None: + circle_color = [ + "match", + ["to-string", ["get", ann_column_bk]], ] - gdf = gdf[columns] - setattr(self, "gdf", gdf) - if circle_color is None: - circle_color = [ - "match", - ["get", color_column], - "doorstep", - colormap["doorstep"], - "indoor", - colormap["indoor"], - "outdoor", - colormap["outdoor"], - "parked", - colormap["parked"], - "selected", - colormap["selected"], - "#CCCCCC", # Default color if annotation does not match - ] + # Add the color matches from the colormap + for key, color in colormap.items(): + circle_color.extend([str(key), color]) + + # Add the default color + circle_color.append( + "#CCCCCC" + ) # Default color if annotation does not match if circle_color is None: circle_color = "#3388ff" @@ -3637,7 +3695,7 @@ def add_gps_trace( if line_gdf is not None: if line_args is None: line_args = {} - self.add_gdf(line_gdf, name="GPS Trace Line", **line_args) + self.add_gdf(line_gdf, name=f"{name} Line", **line_args) self.add_geojson(geojson, layer_type="circle", paint=paint, name=name, **kwargs) @@ -3880,9 +3938,10 @@ def maptiler_3d_style( def edit_gps_trace( filename: str, m: Any, + ann_column: str, colormap: Dict[str, str], layer_name: str, - default_feature: str = "max_signal_strength", + default_feature: str = None, rows: int = 11, fig_width: str = "1550px", fig_height: str = "300px", @@ -3895,10 +3954,15 @@ def edit_gps_trace( Args: filename (str): The path to the GPS trace CSV file. m (Any): The map object containing the GPS trace. + ann_column (str): The annotation column in the GPS trace. colormap (Dict[str, str]): The colormap for the GPS trace annotations. layer_name (str): The name of the GPS trace layer. + default_feature (Optional[str], optional): The default feature to display. Defaults to None. + rows (int, optional): The number of rows to display in the table. Defaults to 11. fig_width (str, optional): The width of the figure. Defaults to "1550px". - fig_height (str, optional): The height of the figure. Defaults to "400px". + fig_height (str, optional): The height of the figure. Defaults to "300px". + time_format (str, optional): The time format for the timestamp. Defaults to "%Y-%m-%d %H:%M:%S". + **kwargs: Additional keyword arguments. Returns: Any: The main widget containing the map and the editing interface. @@ -3918,24 +3982,34 @@ def edit_gps_trace( y_sc = LinearScale() features = sorted(list(m.gps_trace.columns)[1:-3]) + if "max_signal_strength" in features: + default_feature = "max_signal_strength" + else: + default_feature = features[0] default_index = features.index(default_feature) feature = widgets.Dropdown( options=features, index=default_index, description="Primary" ) column = feature.value - category_column = "annotation" # Replace with your categorical column name + ann_column_bk = f"{ann_column}_bk" x = m.gps_trace.index y = m.gps_trace[column] # Create scatter plots for each annotation category with the appropriate colors and labels scatters = [] additonal_scatters = [] + + if isinstance(list(colormap.values())[0], tuple): + keys = list(colormap.keys()) + values = [value[1] for value in colormap.values()] + colormap = dict(zip(keys, values)) + for cat, color in colormap.items(): if ( cat != "selected" ): # Exclude 'selected' from data points (only for highlighting selection) - mask = m.gps_trace[category_column] == cat + mask = m.gps_trace[ann_column] == cat scatter = Scatter( x=x[mask], y=y[mask], @@ -3947,7 +4021,7 @@ def edit_gps_trace( selected_style={"opacity": 1.0}, default_size=48, # Set a smaller default marker size display_legend=False, - labels=[cat], # Add the category label for the legend + labels=[str(cat)], # Add the category label for the legend ) scatters.append(scatter) @@ -3992,7 +4066,7 @@ def on_select(*args): scas[index].selected = selected_indices selected_idx = sorted(list(set(selected_idx))) - m.gdf.loc[selected_idx, "category"] = "selected" + m.gdf.loc[selected_idx, ann_column_bk] = "selected" m.set_data(layer_name, m.gdf.__geo_interface__) # Register the callback for each scatter plot @@ -4034,7 +4108,7 @@ def clear_selection(b): scatter.selected = None # Clear selected points fig.interaction = selector # Re-enable the LassoSelector - m.gdf["category"] = m.gdf["annotation"] + m.gdf[ann_column_bk] = m.gdf[ann_column] m.set_data(layer_name, m.gdf.__geo_interface__) # Button to clear selection and switch between interactions @@ -4055,7 +4129,7 @@ def toggle_interaction(button): def feature_change(change): if change["new"]: - categories = m.gdf["annotation"].value_counts() + categories = m.gdf[ann_column].value_counts() keys = list(colormap.keys())[:-1] for index, cat in enumerate(keys): @@ -4064,7 +4138,7 @@ def feature_change(change): bq.Axis(scale=y_sc, orientation="vertical", label=feature.value), ] - mask = m.gdf["annotation"] == cat + mask = m.gdf[ann_column] == cat scatters[index].x = m.gps_trace.index[mask] scatters[index].y = m.gps_trace[feature.value][mask] scatters[index].colors = [colormap[cat]] * categories[cat] @@ -4080,17 +4154,17 @@ def draw_change(lng_lat): "type": "FeatureCollection", "features": m.draw_features_selected, } - m.gdf["category"] = m.gdf["annotation"] + m.gdf[ann_column_bk] = m.gdf[ann_column] gdf_draw = gpd.GeoDataFrame.from_features(features) points_within_polygons = gpd.sjoin( m.gdf, gdf_draw, how="left", predicate="within" ) points_within_polygons.loc[ - points_within_polygons["index_right"].notna(), "category" + points_within_polygons["index_right"].notna(), ann_column_bk ] = "selected" with output: selected = points_within_polygons.loc[ - points_within_polygons["category"] == "selected" + points_within_polygons[ann_column_bk] == "selected" ] sel_idx = selected.index.tolist() select_points_by_common_x(sel_idx) @@ -4109,7 +4183,7 @@ def draw_change(lng_lat): scatter.selected = None fig.interaction = selector # Re-enable the LassoSelector - m.gdf["category"] = m.gdf["annotation"] + m.gdf[ann_column_bk] = m.gdf[ann_column] m.set_data(layer_name, m.gdf.__geo_interface__) m.observe(draw_change, names="draw_features_selected") @@ -4165,7 +4239,7 @@ def features_change(change): if ( cat != "selected" ): # Exclude 'selected' from data points (only for highlighting selection) - mask = m.gps_trace[category_column] == cat + mask = m.gps_trace[ann_column] == cat scatter = Scatter( x=x[mask], y=y[mask], @@ -4214,20 +4288,20 @@ def features_change(change): def on_save_click(b): output.clear_output() download_widget.clear_output() - m.gdf.loc[m.gdf["category"] == "selected", "annotation"] = dropdown.value - m.gdf.loc[m.gdf["category"] == "selected", "category"] = dropdown.value + m.gdf.loc[m.gdf[ann_column_bk] == "selected", ann_column] = dropdown.value + m.gdf.loc[m.gdf[ann_column_bk] == "selected", ann_column_bk] = dropdown.value m.set_data(layer_name, m.gdf.__geo_interface__) - categories = m.gdf["annotation"].value_counts() + categories = m.gdf[ann_column].value_counts() keys = list(colormap.keys())[:-1] for index, cat in enumerate(keys): - mask = m.gdf["annotation"] == cat + mask = m.gdf[ann_column] == cat scatters[index].x = m.gps_trace.index[mask] scatters[index].y = m.gps_trace[feature.value][mask] scatters[index].colors = [colormap[cat]] * categories[cat] for idx, scas in enumerate(additonal_scatters): for index, cat in enumerate(keys): - mask = m.gdf["annotation"] == cat + mask = m.gdf[ann_column] == cat scas[index].x = m.gps_trace.index[mask] scas[index].y = m.gps_trace[multi_select.value[idx]][mask] scas[index].colors = [colormap[cat]] * categories[cat] @@ -4236,18 +4310,22 @@ def on_save_click(b): scatter.selected = None # Clear selected points fig.interaction = selector # Re-enable the LassoSelector - m.gdf["category"] = m.gdf["annotation"] + m.gdf[ann_column_bk] = m.gdf[ann_column] m.set_data(layer_name, m.gdf.__geo_interface__) save.on_click(on_save_click) def on_export_click(b): - changed_inx = m.gdf[m.gdf["annotation"] != m.gps_trace["annotation"]].index + output.clear_output() + download_widget.clear_output() + with output: + print("Exporting annotated GPS trace...") + changed_inx = m.gdf[m.gdf[ann_column] != m.gps_trace[ann_column]].index m.gps_trace.loc[changed_inx, "changed_timestamp"] = datetime.now().strftime( time_format ) - m.gps_trace["annotation"] = m.gdf["annotation"] - gdf = m.gps_trace.drop(columns=["category"]) + m.gps_trace[ann_column] = m.gdf[ann_column] + gdf = m.gps_trace.drop(columns=[ann_column_bk]) out_dir = kwargs.pop("out_dir", os.getcwd()) basename = os.path.basename(filename) @@ -4379,7 +4457,12 @@ def on_upload(change): if "icon" in kwargs: icon = kwargs.pop("icon") else: - icon = "https://i.imgur.com/ZMMvXuT.png" + icon = "https://assets.gishub.org/images/arrow.png" + + if "ann_column" in kwargs: + ann_column = kwargs.pop("ann_column") + else: + ann_column = "annotation" m.add_gps_trace( filename, @@ -4401,7 +4484,7 @@ def on_upload(change): ) edit_widget = edit_gps_trace( - filename, m, colormap, layer_name, **kwargs + filename, m, ann_column, colormap, layer_name, **kwargs ) main_widget.children = [ widgets.HBox([uploader, reset]),