"""Customized ``Control`` to display the value of all available layers on a specific pixel."""
import json
from pathlib import Path
from typing import Optional, Sequence, Union
import ee
import geopandas as gpd
import ipyvuetify as v
import rasterio as rio
import rioxarray
from deprecated.sphinx import deprecated
from ipyleaflet import GeoJSON, Map, Marker
from rasterio.crs import CRS
from shapely import geometry as sg
from traitlets import Bool
from sepal_ui import color
from sepal_ui import sepalwidgets as sw
from sepal_ui.frontend import styles as ss
from sepal_ui.mapping.layer import EELayer
from sepal_ui.mapping.menu_control import MenuControl
from sepal_ui.message import ms
from sepal_ui.scripts import decorator as sd
[docs]
class InspectorControl(MenuControl):
m: Optional[Map] = None
"the map on which he vinspector is displayed to interact with it's layers"
w_loading: Optional[v.ProgressLinear] = None
"The progress bar on top of the Card"
menu: Optional[v.Menu] = None
"The menu displayed when the map btn is clicked"
text: Optional[v.CardText] = None
"The text element from the card that is edited when the user click on the map"
open_tree: Bool = Bool(True).tag(sync=True)
"Either or not the tree should be opened automatically"
marker: Optional[Marker] = None
"The marker of the last visited point"
[docs]
def __init__(self, m: Map, open_tree: bool = True, **kwargs) -> None:
"""Widget control displaying a btn on the map.
When clicked the menu expand to show the values of each layer available on the map. The menu values will be change when the user click on a location on the map. It can digest any Layer added on a SepalMap.
Args:
m: the map on which he vinspector is displayed to interact with it's layers
"""
# set traits
self.open_tree = open_tree
# set some default parameters
kwargs.setdefault("position", "topleft")
kwargs["m"] = m
# create a loading to place it on top of the card. It will always be visible
# even when the card is scrolled
p_style = json.loads((ss.JSON_DIR / "progress_bar.json").read_text())
self.w_loading = sw.ProgressLinear(
indeterminate=False,
background_color=color.menu,
color=p_style["color"][v.theme.dark],
)
# set up the content
title = sw.CardTitle(children=[ms.inspector_control.title])
self.text = sw.CardText(children=[ms.inspector_control.landing])
# create the menu widget
super().__init__("fa-solid fa-crosshairs", self.text, title, **kwargs)
# avoid closing the inspector when clicking on the map
self.menu.close_on_click = False
# create a marker outside of the map [91, 181] and hide it
self.marker = Marker(location=[91, 181], draggable=False, visible=False)
self.m.add_layer(self.marker)
# adapt the size
self.set_size(min_height=0)
# add js behaviour
self.menu.observe(self.toggle_cursor, "v_model")
self.m.on_interaction(self.read_data)
[docs]
def toggle_cursor(self, *args) -> None:
"""Toggle the cursor and marker display.
Toggle the cursor on the map to notify to the user that the inspector
mode is activated. also activate previous marker if the inspector already include data.
"""
cursors = [{"cursor": "grab"}, {"cursor": "crosshair"}]
self.m.default_style = cursors[self.menu.v_model]
self.marker.visible = self.menu.v_model
return
[docs]
def read_data(self, **kwargs) -> None:
"""Read the data when the map is clicked with the vinspector activated.
Args:
kwargs: any arguments from the map interaction
"""
# check if the v_inspector is active
is_click = kwargs.get("type") == "click"
is_active = self.menu.v_model is True
if not (is_click and is_active):
return
# set the loading mode. Cannot be done as a decorator to avoid
# flickering while moving the cursor on the map
self.w_loading.indeterminate = True
self.m.default_style = {"cursor": "wait"}
# init the text children
children = []
# get the coordinates as (x, y)
lng, lat = coords = [c for c in reversed(kwargs.get("coordinates"))]
# write the coordinates and the scale
txt = ms.inspector_control.coords.format(round(self.m.get_scale()))
children.append(sw.Html(tag="h4", children=[txt]))
children.append(sw.Html(tag="p", children=[f"[{lng:.3f}, {lat:.3f}]"]))
# wrap layer data in a treeview widget
tree_view = sw.Treeview(hoverable=True, dense=True, open_on_click=True)
children.append(sw.Html(tag="h4", children=[ms.inspector_control.layers]))
children.append(tree_view)
# write the layers data
items, layers = [], [lyr for lyr in self.m.layers if not lyr.base]
for i, lyr in enumerate(layers):
if isinstance(lyr, EELayer):
data = self._from_eelayer(lyr.ee_object, coords)
elif isinstance(lyr, GeoJSON):
data = self._from_geojson(lyr.data, coords)
elif type(lyr).__name__ == "BoundTileLayer":
data = self._from_raster(lyr.raster, coords)
elif isinstance(lyr, Marker):
continue
else:
data = {
ms.inspector_control.info.header: ms.inspector_control.info.text
}
items.append(
{
"id": str(i),
"name": lyr.name,
"children": [{"name": f"{k}: {v}"} for k, v in data.items()],
}
)
tree_view.items = items
tree_view.open_ = "0" if self.open_tree else ""
# set them in the card
self.text.children = children
# place a marker on the right coordinates
self.marker.location = [lat, lng]
# set back the cursor to crosshair
self.w_loading.indeterminate = False
self.m.default_style = {"cursor": "crosshair"}
# one last flicker to replace the menu next to the btn
# if not it goes below the map
# I've try playing with the styles but it didn't worked out well
# lost hours on this issue : 2h
self.menu.v_model = False
self.menu.v_model = True
return
@sd.need_ee
def _from_eelayer(self, ee_obj: ee.ComputedObject, coords: Sequence[float]) -> dict:
"""Extract the values of the ee_object for the considered point.
Args:
ee_obj: the ee object to reduce to a single point
coords: the coordinates of the point (lng, lat).
Returns:
tke value associated to the image/feature names
"""
# create a gee point
ee_point = ee.Geometry.Point(*coords)
if isinstance(ee_obj, ee.FeatureCollection):
# filter all the value to the point
features = ee_obj.filterBounds(ee_point)
# if there is none, print non for every property
if features.size().getInfo() == 0:
cols = ee_obj.first().propertyNames().getInfo()
pixel_values = {c: None for c in cols if c not in ["system:index"]}
# else simply return all the values of the first element
else:
pixel_values = features.first().toDictionary().getInfo()
elif isinstance(ee_obj, ee.Image):
# reduce the layer region using mean
pixel_values = ee_obj.reduceRegion(
geometry=ee_point,
scale=self.m.get_scale(),
reducer=ee.Reducer.mean(),
).getInfo()
else:
raise ValueError(
f'the layer object is a "{type(ee_obj)}" which is not accepted.'
)
return pixel_values
def _from_geojson(self, data: dict, coords: Sequence[float]) -> dict:
"""Extract the values of the data for the considered point.
Args:
data: the shape to reduce to a single point
coords: the coordinates of the point (lng, lat).
Returns:
The value associated to the feature names
"""
# extract the coordinates as a poin
point = sg.Point(*coords)
# filter the data to 1 point
gdf = gpd.GeoDataFrame.from_features(data)
gdf_filtered = gdf[gdf.contains(point)]
skip_cols = ["geometry", "style"]
# only display the columns name if empty
if len(gdf_filtered) == 0:
cols = gdf.columns.to_list()
return {c: None for c in cols if c not in skip_cols}
# else print the values of the first element
else:
return gdf_filtered.iloc[0, ~gdf.columns.isin(skip_cols)].to_dict()
def _from_raster(self, raster: Union[str, Path], coords: Sequence[float]) -> dict:
"""Extract the values of the data-array for the considered point.
Args:
raster: the path to the image to reduce to a single point
coords: the coordinates of the point (lng, lat).
Returns:
The value associated to the feature names
"""
# extract the coordinates as a point
point = sg.Point(*coords)
# extract the pixel size in degrees (equatorial approximation)
scale = self.m.get_scale() * 0.00001
# open the image and unproject it
da = rioxarray.open_rasterio(raster, masked=True)
da = da.chunk((1000, 1000))
if da.rio.crs != CRS.from_string("EPSG:4326"):
da = da.rio.reproject("EPSG:4326")
# sample is not available for da so I do as in GEE a mean reducer around 1px
# is it an overkill ? yes
if sg.box(*da.rio.bounds()).contains(point):
bounds = point.buffer(scale).bounds
window = rio.windows.from_bounds(*bounds, transform=da.rio.transform())
da_filtered = da.rio.isel_window(window)
means = da_filtered.mean(axis=(1, 2)).to_numpy()
pixel_values = {
ms.inspector_control.band.format(i + 1): v for i, v in enumerate(means)
}
# if the point is out of the image display None
else:
pixel_values = {
ms.inspector_control.band.format(i + 1): None
for i in range(da.rio.count)
}
return pixel_values
[docs]
@deprecated(
version="2.15.1", reason="ValueInspector class is now renamed InspectorControl"
)
class ValueInspector(InspectorControl):
pass