diff --git a/stac_ipyleaflet/core.py b/stac_ipyleaflet/core.py index a77c4c8..9d8ef92 100644 --- a/stac_ipyleaflet/core.py +++ b/stac_ipyleaflet/core.py @@ -14,10 +14,12 @@ from shapely.geometry import Polygon import xarray as xr +from stac_ipyleaflet.stac_discovery.requests import make_get_request from stac_ipyleaflet.constants import TITILER_ENDPOINT, TITILER_STAC_ENDPOINT from stac_ipyleaflet.stac_discovery.stac_widget import StacDiscoveryWidget from stac_ipyleaflet.widgets.basemaps import BasemapsWidget from stac_ipyleaflet.widgets.draw import DrawControlWidget +from stac_ipyleaflet.widgets.inspect import InspectControlWidget class StacIpyleaflet(Map): @@ -25,15 +27,11 @@ class StacIpyleaflet(Map): Stac ipyleaflet is an extension to ipyleaflet `Map`. """ - raw_input = input - - draw_control: DrawControl histogram_layer: Popup warning_layer: Popup = None loading_widget_layer: Popup = None bbox_centroid: list = [] - titiler_endpoint = TITILER_ENDPOINT titiler_stac_endpoint = TITILER_STAC_ENDPOINT def __init__(self, **kwargs): @@ -59,8 +57,12 @@ def __init__(self, **kwargs): self.selected_data = [] self.histogram_layer = None self.draw_control_added = False + self.point_control_added = False self.aoi_coordinates = [] self.aoi_bbox = () + self.applied_layers = [] + self.inspect_widget = None + self.marker_added = False gif_file = files("stac_ipyleaflet.data").joinpath("loading.gif") with open(gif_file, "rb") as f: @@ -80,16 +82,17 @@ def __init__(self, **kwargs): main_button_layout = Layout( width="120px", height="35px", border="1px solid #4682B4" ) - draw_btn = ToggleButton( - description="Draw", icon="square-o", layout=main_button_layout + # @NOTE: Break these button creations out... + interact_btn = ToggleButton( + description="Interact", icon="pencil", layout=main_button_layout ) - draw_btn.style.text_color = self.accent_color - draw_btn.style.button_color = "transparent" - draw_btn.tooltip = "Draw Area of Interest" - draw_btn.observe( - self.toggle_draw_widget_display, type="change", names=["value"] + interact_btn.style.text_color = self.accent_color + interact_btn.style.button_color = "transparent" + interact_btn.tooltip = "Interact with the map" + interact_btn.observe( + self.toggle_interact_widget_display, type="change", names=["value"] ) - self.buttons["draw"] = draw_btn + self.buttons["interact"] = interact_btn layers_btn = ToggleButton( description="Layers", icon="map-o", layout=main_button_layout @@ -126,12 +129,21 @@ def __init__(self, **kwargs): height="50px", ) buttons_box = HBox( - children=[draw_btn, layers_btn, stac_btn], layout=buttons_box_layout + children=[interact_btn, layers_btn, stac_btn], + layout=buttons_box_layout, ) display(buttons_box) - self.add_biomass_layers() + self.add_biomass_layers_options() self.add_custom_tools() + + self.point_control = InspectControlWidget.template( + self, + self.applied_layers, + self.interact_widget, + make_get_request, + TITILER_ENDPOINT, + ) self.draw_control = DrawControlWidget.template(self) return None @@ -142,12 +154,15 @@ def toggle_layers_widget_display(self, b): if self.layers_widget.layout.display == "none": self.layers_widget.layout.display = "block" self.stac_widget.layout.display = "none" - self.aoi_widget.layout.display = "none" + self.interact_widget.layout.display = "none" self.buttons["stac"].value = False - self.buttons["draw"].value = False + self.buttons["interact"].value = False if self.draw_control_added: self.remove(self.draw_control) self.draw_control_added = False + if self.point_control_added: + self.remove(self.point_control) + self.point_control_added = False if not b["new"]: if self.layers_widget.layout.display == "block": self.layers_widget.layout.display = "none" @@ -157,44 +172,47 @@ def toggle_stac_widget_display(self, b): if self.stac_widget.layout.display == "none": self.stac_widget.layout.display = "block" self.layers_widget.layout.display = "none" - self.aoi_widget.layout.display = "none" + self.interact_widget.layout.display = "none" self.buttons["layers"].value = False - self.buttons["draw"].value = False + self.buttons["interact"].value = False if self.draw_control_added: self.remove(self.draw_control) self.draw_control_added = False + if self.point_control_added: + self.remove(self.point_control) + self.point_control_added = False if not b["new"]: if self.stac_widget.layout.display == "block": self.stac_widget.layout.display = "none" - def toggle_draw_widget_display(self, b): + def toggle_interact_widget_display(self, b): if b["new"]: - if self.aoi_widget.layout.display == "none": - self.aoi_widget.layout.display = "block" - self.add_control(self.draw_control) - self.draw_control_added = True + if self.interact_widget.layout.display == "none": + self.interact_widget.layout.display = "block" self.stac_widget.layout.display = "none" self.layers_widget.layout.display = "none" self.buttons["stac"].value = False self.buttons["layers"].value = False + selected_tab = self.interact_widget.children[0].titles[ + self.interact_widget.children[0].selected_index + ] + if selected_tab == "Point": + self.add_control(self.point_control) + self.point_control_added = True + elif selected_tab == "Area": + self.add_control(self.draw_control) + self.draw_control_added = True if not b["new"]: - if self.aoi_widget.layout.display == "block": - self.aoi_widget.layout.display = "none" + if self.interact_widget.layout.display == "block": + self.interact_widget.layout.display = "none" if self.draw_control_added: self.remove(self.draw_control) self.draw_control_added = False + if self.point_control_added: + self.remove(self.point_control) + self.point_control_added = False - def create_aoi_widget(self): - aoi_widget = HBox( - layout=Layout( - width="300px", padding="0px 6px 2px 6px", margin="0px 2px 2px 2px" - ) - ) - aoi_widget.layout.flex_flow = "column" - aoi_widget.layout.min_width = "300px" - aoi_widget.layout.max_height = "360px" - aoi_widget.layout.overflow = "auto" - + def create_aoi_tab(self): aoi_widget_desc = HTML( value="

Polygon

", ) @@ -209,12 +227,88 @@ def create_aoi_widget(self): disabled=True, # layout=Layout(margin="4px 0 8px 0") ) - - aoi_widget.children = [aoi_widget_desc, aoi_html, aoi_clear_button] - aoi_widget.layout.display = "none" + aoi_widget = HBox([aoi_widget_desc, aoi_html, aoi_clear_button]) + aoi_widget.layout.flex_flow = "column" return aoi_widget + # @NOTE: Create dynamic widget function + def create_inspect_tab(self): + inspect_widget_desc = HTML( + value="

Marker

", + ) + inspect_widget_html = HTML( + value="Waiting for points of interest...", + description="", + ) + + inspect_widget_button = Button( + description="Clear Markers", + tooltip="Clear Markers", + icon="trash", + disabled=True, + ) + + inspect_widget = HBox( + [ + inspect_widget_desc, + inspect_widget_html, + inspect_widget_button, + ] + ) + + inspect_widget.layout.flex_flow = "column" + + return inspect_widget + + def create_interact_widget(self): + interact_widget = Box(style={"max-width: 420px"}) + interact_widget.layout.flex_flow = "column" + interact_widget.layout.max_height = "360px" + interact_widget.layout.overflow = "auto" + + tab_headers = ["Point", "Area"] + tab_children = [] + tab_widget = Tab() + + out = Output() + display(out) + + def toggle_interact_tab_change(event): + selected_tab = self.interact_widget.children[0].titles[ + event.owner.selected_index + ] + if selected_tab == "Point": # Inspect Control + if self.draw_control_added: + self.remove(self.draw_control) + self.draw_control_added = False + self.add_control(self.point_control) + self.point_control_added = True + elif selected_tab == "Area": # Draw Control + if self.point_control_added: + self.remove(self.point_control) + self.point_control_added = False + self.add_control(self.draw_control) + self.draw_control_added = True + + for tab in tab_headers: + tab_content = VBox() + if tab == "Point": + hbox = self.create_inspect_tab() + tab_content.children = [VBox([hbox])] + tab_children.append(tab_content) + elif tab == "Area": + hbox = self.create_aoi_tab() + tab_content.children = [VBox([hbox])] + tab_children.append(tab_content) + tab_widget.children = tab_children + tab_widget.titles = tab_headers + interact_widget.children = [tab_widget] + interact_widget.layout.display = "none" + tab_widget.observe(toggle_interact_tab_change, names="selected_index") + return interact_widget + + # @NOTE: Possibly move into its own child class file def create_layers_widget(self): layers_widget = Box(style={"max-width: 420px"}) layers_widget.layout.flex_flow = "column" @@ -230,6 +324,15 @@ def create_layers_widget(self): opacity_values = [i * 10 for i in range(10 + 1)] # [0.001, 0.002, ...] + def layer_checkbox_changed(change): + layer = next( + (x for x in self.layers if x.name == change.owner.description), None + ) + if change.owner.value: + self.applied_layers.append(layer) + if not change.owner.value: + self.applied_layers.remove(layer) + def handle_basemap_opacity_change(change): selected_bm = self.basemap_selection_dd.value for l in self.layers: @@ -239,6 +342,8 @@ def handle_basemap_opacity_change(change): def handle_layer_opacity_change(change): selected_layer = change.owner.description + if selected_layer not in self.applied_layers: + return for l in self.layers: if l.name == selected_layer: l.opacity = change["new"] @@ -260,11 +365,14 @@ def handle_layer_opacity_change(change): value=layer.visible, description=layer.name, indent=False ) jslink((layer_checkbox, "value"), (layer, "visible")) + layer_checkbox.observe( + layer_checkbox_changed, names="value", type="change" + ) hbox = HBox([layer_checkbox]) layer_opacity_slider = SelectionSlider( value=1, options=[("%g" % i, i / 100) for i in opacity_values], - description=f"{layer.name}", + description=layer.name, continuous_update=False, orientation="horizontal", layout=Layout(margin="-12px 0 4px 0"), @@ -336,11 +444,11 @@ def add_custom_tools(self): # Create custom map widgets self.layers_widget = self.create_layers_widget() self.stac_widget = StacDiscoveryWidget.template(self) - self.aoi_widget = self.create_aoi_widget() + self.interact_widget = self.create_interact_widget() layers_widget = VBox([self.layers_widget]) stac_widget = VBox([self.stac_widget]) - aoi_widget = VBox([self.aoi_widget]) + interact_widget = VBox([self.interact_widget]) layers_control = WidgetControl( widget=layers_widget, position="topright", id="layers_widget" @@ -348,15 +456,15 @@ def add_custom_tools(self): stack_control = WidgetControl( widget=stac_widget, position="topright", id="stac_widget" ) - aoi_control = WidgetControl( - widget=aoi_widget, position="topright", id="aoi_widget" + interact_control = WidgetControl( + widget=interact_widget, position="topright", id="interact_widget" ) self.add(layers_control) self.add(stack_control) - self.add(aoi_control) + self.add(interact_control) - def add_biomass_layers(self): + def add_biomass_layers_options(self): biomass_file = files("stac_ipyleaflet.data").joinpath("biomass-layers.csv") with open(biomass_file, newline="") as f: csv_reader = csv.reader(f) @@ -413,6 +521,7 @@ def add_tile_layer( **kwargs, ) self.add_layer(tile_layer) + return tile_layer except Exception as e: logging.error("Failed to add the specified TileLayer.") diff --git a/stac_ipyleaflet/stac_discovery/stac_widget.py b/stac_ipyleaflet/stac_discovery/stac_widget.py index 1de03c6..11a9f5a 100644 --- a/stac_ipyleaflet/stac_discovery/stac_widget.py +++ b/stac_ipyleaflet/stac_discovery/stac_widget.py @@ -645,7 +645,7 @@ def query_collection_items(selected_collection): max_items=20, # intersects=geometries[0], datetime=_datetime, - titiler_endpoint=self.titiler_endpoint, + titiler_endpoint=TITILER_ENDPOINT, get_info=True, ) result_items = list(collection_items.values()) @@ -825,11 +825,12 @@ def button_clicked(change): if self.stac_data["layer_added"] == True: self.layers = self.layers[: len(self.layers) - 1] self.stac_data["layer_added"] = False - self.add_tile_layer( + applied_tile_layer = self.add_tile_layer( url=tile_url, - name=items_dropdown.value, + name=f'{collections_dropdown.value}, {items_dropdown.value}', attribution=items_dropdown.value, ) + self.applied_layers.append(applied_tile_layer) stac_opacity_slider.observe( handle_stac_layer_opacity, names="value" ) diff --git a/stac_ipyleaflet/widgets/draw.py b/stac_ipyleaflet/widgets/draw.py index 7cad1a9..ba97b3d 100644 --- a/stac_ipyleaflet/widgets/draw.py +++ b/stac_ipyleaflet/widgets/draw.py @@ -1,24 +1,57 @@ from ipyleaflet import DrawControl, GeoJSON from ipywidgets import Box, Output +# @NOTE: This should be an extension of the IPYLEAFLET Class. Currently it is just being passed +# in instead due to import errors +# @TODO: Fix linting errors caused by inferred inheritance and just pass in params instead + + +# @TODO: Break out shared logic between widgets into a utilities directory class DrawControlWidget: def template(self, **kwargs) -> Box(style={"max_height: 200px"}): main = self bbox_out = Output() # Set unwanted draw controls to False or empty objects + # @TODO-CLEANUP: Create only one DrawControl and pass in the attributes instead draw_control = DrawControl( edit=False, remove=False, circlemarker={}, polygon={}, polyline={}, + marker={}, ) - aoi_coords = main.aoi_widget.children[1] - aoi_clear_button = main.aoi_widget.children[2] + # Add rectangle draw control for bounding box + draw_control.rectangle = { + "shapeOptions": { + "fillColor": "transparent", + "color": "#333", + "fillOpacity": 1.0, + }, + "repeatMode": False, + } + + tabs = {} + + for i in range(2): + tabs[f"child{i}"] = ( + main.interact_widget.children[0] + .children[i] + .children[0] + .children[0] + .children + ) + + point_tab_children = tabs["child0"] + area_tab_children = tabs["child1"] + aoi_coords = area_tab_children[1] + aoi_clear_button = area_tab_children[2] + + # @TODO-CLEANUP: Duplication between tabs, pull logic out into a common utilities file def handle_clear(self): draw_layer = main.find_layer("draw_layer") main.remove_layer(draw_layer) @@ -29,6 +62,11 @@ def handle_draw(self, action, geo_json, **kwargs): main.aoi_coordinates = [] main.aoi_bbox = () + if "Coordinates" in point_tab_children[1].value: + area_tab_children[ + 1 + ].value = "Waiting for points of interest..." + if action == "created": if geo_json["geometry"]: geojson_layer = GeoJSON( @@ -64,21 +102,7 @@ def bounding_box(points): return - def value_changed(change): - print("CHANGE", change) - draw_control.on_draw(callback=handle_draw) - draw_control.observe(value_changed, names=["value"]) - - # Add rectangle draw control for bounding box - draw_control.rectangle = { - "shapeOptions": { - "fillColor": "transparent", - "color": "#333", - "fillOpacity": 1.0, - }, - "repeatMode": False, - } draw_control.output = bbox_out return draw_control diff --git a/stac_ipyleaflet/widgets/inspect.py b/stac_ipyleaflet/widgets/inspect.py new file mode 100644 index 0000000..72473eb --- /dev/null +++ b/stac_ipyleaflet/widgets/inspect.py @@ -0,0 +1,151 @@ +from ipyleaflet import DrawControl, MarkerCluster, Marker, DrawControl, GeoJSON +from urllib.parse import urlparse, parse_qs +from typing import List + + +class COGRequestedData: + coordinates: List[float] + values: List[float] + band_names: List[str] + + +class LayerData: + layer_name: str + data: COGRequestedData + + +# @NOTE: This should be an extension of the IPYLEAFLET Class. Currently it is just being passed +# in instead due to import errors + + +# @TODO: Break out shared logic between widgets into a utilities directory +class InspectControlWidget: + def template( + self, applied_layers, interact_widget, make_get_request, titiler_endpoint + ): + main = self + + # @TODO-CLEANUP: Create only one DrawControl and pass in the attributes instead + draw_control = DrawControl( + edit=False, + remove=False, + circlemarker={}, + polygon={}, + polyline={}, + rectangle={}, + ) + + draw_control.marker = { + "repeatMode": False, + } + + tabs = {} + + for i in range(2): + tabs[f"child{i}"] = ( + main.interact_widget.children[0] + .children[i] + .children[0] + .children[0] + .children + ) + + point_tab_children = tabs["child0"] + area_tab_children = tabs["child1"] + + point_data = point_tab_children[1] + clear_button = point_tab_children[2] + + def get_visible_layers_data(coordinates) -> List[LayerData]: + visible_layers_data = [] + cog_partial_request_path = ( + f"{titiler_endpoint}/cog/point/{coordinates[0]},{coordinates[1]}?url=" + ) + for layer in applied_layers: + if "/cog" in layer.url: + parsed_url = urlparse(layer.url) + parsed_query = parse_qs(parsed_url.query) + url = parsed_query["url"][0] + data = make_get_request(f"{cog_partial_request_path}{url}") + if data: + visible_layers_data.append( + {"layer_name": layer.name, "data": data.json()} + ) + # @TODO: Add logic to grab point data for "/mosaics" layers here + # @NOTE: Blocked until Impact Titiler is updated to access /mosaicsjson/point + return visible_layers_data + + def display_layer_data(layers_data: LayerData): + point_data.value = "" + + def create_layer_data_html(layer_name, coordinates, values, band_names): + template = f""" +

+ + {layer_name} + +

+ + """ + return template + + for layer in layers_data: + point_data.value += create_layer_data_html( + layer["layer_name"], + layer["data"]["coordinates"], + layer["data"]["values"], + layer["data"]["band_names"], + ) + return + + def handle_interaction(self, action, geo_json, **kwargs): + # @TODO-CLEANUP: Duplication between tabs, pull logic out into a common utilities file + def handle_clear(event): + draw_layer = main.find_layer("draw_layer") + main.remove_layer(draw_layer) + point_data.value = "Waiting for points of interest..." + clear_button.disabled = True + return + + self.coordinates = [] + if "Coordinates" in area_tab_children[1].value: + area_tab_children[ + 1 + ].value = "Waiting for area of interest..." + + if action == "created": + if geo_json["geometry"] and geo_json["geometry"]["type"] == "Point": + geojson_layer = GeoJSON( + name="draw_layer", + data=geo_json, + ) + main.add_layer(geojson_layer) + self.coordinates = geo_json["geometry"]["coordinates"] + + if len(applied_layers): + layers_data = get_visible_layers_data(self.coordinates) + if layers_data: + display_layer_data(layers_data) + else: + point_data.value = f"

Coordinates:

{self.coordinates}
" + elif not len(applied_layers): + point_data.value = f"

Coordinates:

{self.coordinates}
" + + self.clear() + clear_button.disabled = False + clear_button.on_click(handle_clear) + return + + draw_control.on_draw(callback=handle_interaction) + + return draw_control