diff --git a/hypercoast/common.py b/hypercoast/common.py index 9681b861..e9cb47dd 100644 --- a/hypercoast/common.py +++ b/hypercoast/common.py @@ -1,7 +1,118 @@ """The common module contains common functions and classes used by the other modules. """ +import os -def hello_world(): - """Prints "Hello World!" to the console.""" - print("Hello World!") + +def github_raw_url(url): + """Get the raw URL for a GitHub file. + + Args: + url (str): The GitHub URL. + Returns: + str: The raw URL. + """ + if isinstance(url, str) and url.startswith("https://github.com/") and "blob" in url: + url = url.replace("github.com", "raw.githubusercontent.com").replace( + "blob/", "" + ) + return url + + +def download_file( + url=None, + output=None, + quiet=False, + proxy=None, + speed=None, + use_cookies=True, + verify=True, + id=None, + fuzzy=False, + resume=False, + unzip=True, + overwrite=False, + subfolder=False, +): + """Download a file from URL, including Google Drive shared URL. + + Args: + url (str, optional): Google Drive URL is also supported. Defaults to None. + output (str, optional): Output filename. Default is basename of URL. + quiet (bool, optional): Suppress terminal output. Default is False. + proxy (str, optional): Proxy. Defaults to None. + speed (float, optional): Download byte size per second (e.g., 256KB/s = 256 * 1024). Defaults to None. + use_cookies (bool, optional): Flag to use cookies. Defaults to True. + verify (bool | str, optional): Either a bool, in which case it controls whether the server's TLS certificate is verified, or a string, + in which case it must be a path to a CA bundle to use. Default is True.. Defaults to True. + id (str, optional): Google Drive's file ID. Defaults to None. + fuzzy (bool, optional): Fuzzy extraction of Google Drive's file Id. Defaults to False. + resume (bool, optional): Resume the download from existing tmp file if possible. Defaults to False. + unzip (bool, optional): Unzip the file. Defaults to True. + overwrite (bool, optional): Overwrite the file if it already exists. Defaults to False. + subfolder (bool, optional): Create a subfolder with the same name as the file. Defaults to False. + + Returns: + str: The output file path. + """ + import zipfile + import tarfile + import gdown + + if output is None: + if isinstance(url, str) and url.startswith("http"): + output = os.path.basename(url) + + out_dir = os.path.abspath(os.path.dirname(output)) + if not os.path.exists(out_dir): + os.makedirs(out_dir) + + if isinstance(url, str): + if os.path.exists(os.path.abspath(output)) and (not overwrite): + print( + f"{output} already exists. Skip downloading. Set overwrite=True to overwrite." + ) + return os.path.abspath(output) + else: + url = github_raw_url(url) + + if "https://drive.google.com/file/d/" in url: + fuzzy = True + + output = gdown.download( + url, output, quiet, proxy, speed, use_cookies, verify, id, fuzzy, resume + ) + + if unzip: + if output.endswith(".zip"): + with zipfile.ZipFile(output, "r") as zip_ref: + if not quiet: + print("Extracting files...") + if subfolder: + basename = os.path.splitext(os.path.basename(output))[0] + + output = os.path.join(out_dir, basename) + if not os.path.exists(output): + os.makedirs(output) + zip_ref.extractall(output) + else: + zip_ref.extractall(os.path.dirname(output)) + elif output.endswith(".tar.gz") or output.endswith(".tar"): + if output.endswith(".tar.gz"): + mode = "r:gz" + else: + mode = "r" + + with tarfile.open(output, mode) as tar_ref: + if not quiet: + print("Extracting files...") + if subfolder: + basename = os.path.splitext(os.path.basename(output))[0] + output = os.path.join(out_dir, basename) + if not os.path.exists(output): + os.makedirs(output) + tar_ref.extractall(output) + else: + tar_ref.extractall(os.path.dirname(output)) + + return os.path.abspath(output) diff --git a/hypercoast/hypercoast.py b/hypercoast/hypercoast.py index 43959b31..8d43be2f 100644 --- a/hypercoast/hypercoast.py +++ b/hypercoast/hypercoast.py @@ -1,7 +1,9 @@ """Main module.""" +import ipyleaflet import leafmap - +import xarray as xr +from .common import download_file from .emit import read_emit, plot_emit, viz_emit, emit_to_netcdf, emit_to_image @@ -25,6 +27,23 @@ def __init__(self, **kwargs): """ super().__init__(**kwargs) + def add(self, obj, position="topright", **kwargs): + """Add a layer to the map. + + Args: + **kwargs: Arbitrary keyword arguments that are passed to the parent class's add_layer method. + """ + + if isinstance(obj, str): + if obj == "spectral": + from .ui import SpectralWidget + + SpectralWidget(self, position=position) + self.set_plot_options(add_marker_cluster=True) + + else: + super().add(obj, **kwargs) + def search_emit(self, default_datset="EMITL2ARFL"): """ Adds a NASA Earth Data search tool to the map with a default dataset for EMIT. @@ -109,7 +128,7 @@ def add_emit( vmax=None, nodata=None, attribution=None, - layer_name="Raster", + layer_name="EMIT", zoom_to_layer=True, visible=True, array_args={}, @@ -131,16 +150,20 @@ def add_emit( vmax (float, optional): The maximum value to use when colormapping the palette when plotting a single band. Defaults to None. nodata (float, optional): The value from the band to use to interpret as not valid data. Defaults to None. attribution (str, optional): Attribution for the source raster. This defaults to a message about it being a local file.. Defaults to None. - layer_name (str, optional): The layer name to use. Defaults to 'Raster'. + layer_name (str, optional): The layer name to use. Defaults to 'EMIT'. zoom_to_layer (bool, optional): Whether to zoom to the extent of the layer. Defaults to True. visible (bool, optional): Whether the layer is visible. Defaults to True. array_args (dict, optional): Additional arguments to pass to `array_to_memory_file` when reading the raster. Defaults to {}. """ + xds = None if isinstance(source, str): - ds = read_emit(source, wavelengths=wavelengths) - source = emit_to_image(ds, wavelengths=wavelengths) + xds = read_emit(source) + source = emit_to_image(xds, wavelengths=wavelengths) + elif isinstance(source, xr.Dataset): + xds = source + source = emit_to_image(xds, wavelengths=wavelengths) self.add_raster( source, @@ -156,3 +179,74 @@ def add_emit( array_args=array_args, **kwargs, ) + + self.cog_layer_dict[layer_name]["xds"] = xds + + def set_plot_options( + self, + add_marker_cluster=False, + plot_type=None, + overlay=False, + position="bottomright", + min_width=None, + max_width=None, + min_height=None, + max_height=None, + **kwargs, + ): + """Sets plotting options. + + Args: + add_marker_cluster (bool, optional): Whether to add a marker cluster. Defaults to False. + sample_scale (float, optional): A nominal scale in meters of the projection to sample in . Defaults to None. + plot_type (str, optional): The plot type can be one of "None", "bar", "scatter" or "hist". Defaults to None. + overlay (bool, optional): Whether to overlay plotted lines on the figure. Defaults to False. + position (str, optional): Position of the control, can be ‘bottomleft’, ‘bottomright’, ‘topleft’, or ‘topright’. Defaults to 'bottomright'. + min_width (int, optional): Min width of the widget (in pixels), if None it will respect the content size. Defaults to None. + max_width (int, optional): Max width of the widget (in pixels), if None it will respect the content size. Defaults to None. + min_height (int, optional): Min height of the widget (in pixels), if None it will respect the content size. Defaults to None. + max_height (int, optional): Max height of the widget (in pixels), if None it will respect the content size. Defaults to None. + + """ + plot_options_dict = {} + plot_options_dict["add_marker_cluster"] = add_marker_cluster + plot_options_dict["plot_type"] = plot_type + plot_options_dict["overlay"] = overlay + plot_options_dict["position"] = position + plot_options_dict["min_width"] = min_width + plot_options_dict["max_width"] = max_width + plot_options_dict["min_height"] = min_height + plot_options_dict["max_height"] = max_height + + for key in kwargs: + plot_options_dict[key] = kwargs[key] + + self._plot_options = plot_options_dict + + if not hasattr(self, "_plot_marker_cluster"): + self._plot_marker_cluster = ipyleaflet.MarkerCluster(name="Marker Cluster") + + if add_marker_cluster and (self._plot_marker_cluster not in self.layers): + self.add(self._plot_marker_cluster) + + def spectral_to_df(self, **kwargs): + """Converts the spectral data to a pandas DataFrame. + + Returns: + pd.DataFrame: The spectral data as a pandas DataFrame. + """ + import pandas as pd + + df = pd.DataFrame(self._spectral_data, **kwargs) + return df + + def spectral_to_csv(self, filename, index=True, **kwargs): + """Saves the spectral data to a CSV file. + + Args: + filename (str): The output CSV file. + index (bool, optional): Whether to write the index. Defaults to True. + """ + df = self.spectral_to_df() + df = df.rename_axis("band") + df.to_csv(filename, index=index, **kwargs) diff --git a/hypercoast/ui.py b/hypercoast/ui.py index cfe6039f..6579abe5 100644 --- a/hypercoast/ui.py +++ b/hypercoast/ui.py @@ -1,4 +1,212 @@ """This module contains the user interface for the hypercoast package. """ +import os +import ipyleaflet import ipywidgets as widgets +import numpy as np +from bqplot import pyplot as plt +from IPython.core.display import display +from ipyfilechooser import FileChooser + + +class SpectralWidget(widgets.HBox): + """ + A widget for spectral data visualization on a map. + + Attributes: + _host_map (Map): The map to host the widget. + on_close (function): Function to be called when the widget is closed. + _output_widget (widgets.Output): The output widget to display results. + _output_control (ipyleaflet.WidgetControl): The control for the output widget. + _on_map_interaction (function): Function to handle map interactions. + _spectral_widget (SpectralWidget): The spectral widget itself. + _spectral_control (ipyleaflet.WidgetControl): The control for the spectral widget. + """ + + def __init__(self, host_map, position="topright"): + """ + Initializes a new instance of the SpectralWidget class. + + Args: + host_map (Map): The map to host the widget. + position (str, optional): The position of the widget on the map. Defaults to "topright". + """ + self._host_map = host_map + self.on_close = None + + close_btn = widgets.Button( + icon="times", + tooltip="Close the widget", + button_style="primary", + layout=widgets.Layout(width="32px"), + ) + + reset_btn = widgets.Button( + icon="trash", + tooltip="Remove all markers", + button_style="primary", + layout=widgets.Layout(width="32px"), + ) + + def reset_btn_click(_): + if hasattr(self._host_map, "_plot_marker_cluster"): + self._host_map._plot_marker_cluster.markers = [] + self._host_map._plot_markers = [] + + if hasattr(self._host_map, "_spectral_data"): + self._host_map._spectral_data = {} + + self._output_widget.clear_output() + + reset_btn.on_click(reset_btn_click) + + save_btn = widgets.Button( + icon="floppy-o", + tooltip="Save the data to a CSV", + button_style="primary", + layout=widgets.Layout(width="32px"), + ) + + def chooser_callback(chooser): + if chooser.selected: + file_path = chooser.selected + self._host_map.spectral_to_csv(file_path) + if ( + hasattr(self._host_map, "_file_chooser_control") + and self._host_map._file_chooser_control in self._host_map.controls + ): + self._host_map.remove_control(self._host_map._file_chooser_control) + self._host_map._file_chooser.close() + + def save_btn_click(_): + if not hasattr(self._host_map, "_spectral_data"): + return + + self._output_widget.clear_output() + file_chooser = FileChooser( + os.getcwd(), layout=widgets.Layout(width="454px") + ) + file_chooser.filter_pattern = "*.csv" + file_chooser.use_dir_icons = True + file_chooser.title = "Save spectral data to a CSV file" + file_chooser.default_filename = "spectral_data.csv" + file_chooser.show_hidden = False + file_chooser.register_callback(chooser_callback) + file_chooser_control = ipyleaflet.WidgetControl( + widget=file_chooser, position="topright" + ) + self._host_map.add(file_chooser_control) + setattr(self._host_map, "_file_chooser", file_chooser) + setattr(self._host_map, "_file_chooser_control", file_chooser_control) + + save_btn.on_click(save_btn_click) + + def close_widget(_): + self.cleanup() + + close_btn.on_click(close_widget) + + layer_names = list(host_map.cog_layer_dict.keys()) + layers_widget = widgets.Dropdown(options=layer_names) + layers_widget.layout.width = "18ex" + super().__init__([layers_widget, reset_btn, save_btn, close_btn]) + + output = widgets.Output() + output_control = ipyleaflet.WidgetControl(widget=output, position="bottomright") + self._output_widget = output + self._output_control = output_control + self._host_map.add(output_control) + + if not hasattr(self._host_map, "_spectral_data"): + self._host_map._spectral_data = {} + + def handle_interaction(**kwargs): + + latlon = kwargs.get("coordinates") + lat = latlon[0] + lon = latlon[1] + if kwargs.get("type") == "click": + layer_name = layers_widget.value + with self._output_widget: + self._output_widget.clear_output() + + if not hasattr(self._host_map, "_plot_markers"): + self._host_map._plot_markers = [] + markers = self._host_map._plot_markers + marker_cluster = self._host_map._plot_marker_cluster + markers.append(ipyleaflet.Marker(location=latlon)) + marker_cluster.markers = markers + self._host_map._plot_marker_cluster = marker_cluster + + ds = self._host_map.cog_layer_dict[layer_name]["xds"] + da = ds.sel(latitude=lat, longitude=lon, method="nearest")[ + "reflectance" + ] + + if "wavelengths" not in self._host_map._spectral_data: + self._host_map._spectral_data["wavelengths"] = ds[ + "wavelengths" + ].values + + self._host_map._spectral_data[f"({lat:.4f} {lon:.4f})"] = da.values + + da[da < 0] = np.nan + # fig, ax = plt.subplots() + # da.plot.line(ax=ax) + # display(fig) + fig_margin = {"top": 20, "bottom": 35, "left": 50, "right": 20} + fig = plt.figure( + # title=None, + fig_margin=fig_margin, + layout={"width": "500px", "height": "300px"}, + ) + plt.plot(da.coords[da.dims[0]].values, da.values) + plt.xlabel("Wavelength (nm)") + plt.ylabel("Reflectance") + plt.show() + + self._host_map.default_style = {"cursor": "crosshair"} + + self._host_map.on_interaction(handle_interaction) + self._on_map_interaction = handle_interaction + + self._spectral_widget = self + self._spectral_control = ipyleaflet.WidgetControl( + widget=self, position=position + ) + self._host_map.add(self._spectral_control) + + def cleanup(self): + """Removes the widget from the map and performs cleanup.""" + if self._host_map: + self._host_map.default_style = {"cursor": "default"} + self._host_map.on_interaction(self._on_map_interaction, remove=True) + + if self._output_control: + self._host_map.remove_control(self._output_control) + + if self._output_widget: + self._output_widget.close() + self._output_widget = None + + if self._spectral_control: + self._host_map.remove_control(self._spectral_control) + self._spectral_control = None + + if self._spectral_widget: + self._spectral_widget.close() + self._spectral_widget = None + + if hasattr(self._host_map, "_plot_marker_cluster"): + self._host_map._plot_marker_cluster.markers = [] + self._host_map._plot_markers = [] + + if hasattr(self._host_map, "_spectral_data"): + self._host_map._spectral_data = {} + + if hasattr(self, "_output_widget") and self._output_widget is not None: + self._output_widget.clear_output() + + if self.on_close is not None: + self.on_close()