diff --git a/CHANGES.md b/CHANGES.md index ac970b4f5..f42a06cc0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -45,6 +45,12 @@ - Updated schema for the `num_levels` parameter which now explains the parameter in more detail. +* Improved the demos for the xcube Viewer server-side extensions in various ways (#1134): + - enhanced user input validation + - added error message label + - fixed bugs in code + - improved UI styles and general UX + ## Changes in 1.9.0 ### Enhancements diff --git a/examples/serve/panels-demo/demo_panels/panel_demo.py b/examples/serve/panels-demo/demo_panels/panel_demo.py index a792ae40c..711d569cb 100644 --- a/examples/serve/panels-demo/demo_panels/panel_demo.py +++ b/examples/serve/panels-demo/demo_panels/panel_demo.py @@ -30,6 +30,14 @@ def render_panel( label="Opaque", ) + # Will render the firstrow from viewer + # first_row = FirstRow(id="firstrow", required=[dataset_title, time_label], + # hostComponent= True) + + # Will render the firstrow as a box with typography from chartlets + # first_row = FirstRow(id="firstrow", required=[dataset_title, time_label], + # hostComponent=False) + color_select = Select( id="color", value=color, @@ -42,6 +50,15 @@ def render_panel( id="info_text", children=update_info_text(ctx, dataset_id, opaque, color) ) + instructions = Typography( + id="instructions", + children=[ + "This panel just demonstrates how server-side extensions work. " + "It has no useful functionality.", + ], + variant="body2", + ) + return Box( style={ "display": "flex", @@ -51,11 +68,10 @@ def render_panel( "gap": "6px", }, children=[ - "This panel just demonstrates how server-side extensions work. " - "It has no useful functionality.", + instructions, opaque_checkbox, color_select, - info_text + info_text, ], ) diff --git a/examples/serve/panels-demo/demo_panels/panel_histo2d.py b/examples/serve/panels-demo/demo_panels/panel_histo2d.py index 6a5b21348..e06d087a6 100644 --- a/examples/serve/panels-demo/demo_panels/panel_histo2d.py +++ b/examples/serve/panels-demo/demo_panels/panel_histo2d.py @@ -1,6 +1,7 @@ # Copyright (c) 2018-2025 by xcube team and contributors # Permissions are hereby granted under the terms of the MIT License: # https://opensource.org/licenses/MIT. +from typing import Any import altair as alt import numpy as np @@ -10,13 +11,22 @@ import shapely.geometry import shapely.ops from chartlets import Component, Input, Output, State -from chartlets.components import Box, Button, CircularProgress, Select, VegaChart +from chartlets.components import ( + Box, + Button, + CircularProgress, + Select, + VegaChart, + Typography, +) from xcube.constants import CRS_CRS84 from xcube.core.geom import mask_dataset_by_geometry, normalize_geometry from xcube.core.gridmapping import GridMapping from xcube.server.api import Context +from xcube.webapi.viewer.components import Markdown from xcube.webapi.viewer.contrib import Panel, get_dataset +from xcube.webapi.viewer.contrib.helpers import get_place_label panel = Panel(__name__, title="2D Histogram (Demo)", icon="equalizer", position=3) @@ -27,11 +37,37 @@ NUM_BINS_MAX = 64 -@panel.layout(State("@app", "selectedDatasetId")) -def render_panel(ctx: Context, dataset_id: str | None = None) -> Component: +@panel.layout( + State("@app", "selectedDatasetId"), + State("@app", "selectedDatasetTitle"), + State("@app", "selectedTimeLabel"), +) +def render_panel( + ctx: Context, + dataset_id: str | None = None, + dataset_title: str | None = None, + time_label: str | None = None, +) -> Component: dataset = get_dataset(ctx, dataset_id) - plot = VegaChart(id="plot", chart=None, style={"paddingTop": 6}) + plot = VegaChart( + id="plot", + chart=None, + style={ + "paddingTop": 6, + # Since for dynamic resizing we use `container` as width and height for + # this chart during updates, it is necessary that we provide the width + # and the height here. This is for the `container` div of VegaChart. + "width": "100%", + "height": 400, + }, + ) + + if time_label: + text = f"{dataset_title} / {time_label[0:-1]}" + else: + text = f"{dataset_title}" + place_text = Typography(id="text", children=[text], align="left") var_names, var_name_1, var_name_2 = get_var_select_options(dataset) @@ -42,7 +78,7 @@ def render_panel(ctx: Context, dataset_id: str | None = None) -> Component: id="select_var_2", label="Variable 2", value=var_name_2, options=var_names ) - button = Button(id="button", text="Update", style={"maxWidth": 100}) + button = Button(id="button", text="Update", style={"maxWidth": 100}, disabled=True) controls = Box( children=[select_var_1, select_var_2, button], @@ -54,18 +90,47 @@ def render_panel(ctx: Context, dataset_id: str | None = None) -> Component: }, ) + control_bar = Box( + children=[place_text, controls], + style={ + "display": "flex", + "flexDirection": "row", + "alignItems": "center", + "justifyContent": "space-between", + "width": "100%", + "gap": 6, + }, + ) + + error_message = Typography( + id="error_message", + style={"color": "red"}, + children=[""], + ) + + instructions_text = Markdown( + text="Create or select a region shape in the map, then select two " + "variables from the dropdowns, and press **Update** to create " + "a 2D histogram plot.", + ) + + instructions = Typography( + id="instructions", + children=[instructions_text], + variant="body2", + ) + return Box( children=[ - "Create or select a region shape in the map, then select two " - "variables from the dropdowns, and press 'Update' to create " - "a 2D histogram plot.", - controls, - plot + instructions, + control_bar, + plot, + error_message, ], style={ "display": "flex", "flexDirection": "column", - "alignItems": "center", + "alignItems": "left", "width": "100%", "height": "100%", "gap": 6, @@ -73,29 +138,29 @@ def render_panel(ctx: Context, dataset_id: str | None = None) -> Component: ) +error_message = "" + + @panel.callback( State("@app", "selectedDatasetId"), - State("@app", "selectedTimeLabel"), State("@app", "selectedPlaceGeometry"), State("select_var_1"), State("select_var_2"), + State("@app", "selectedTimeLabel"), Input("button", "clicked"), Output("plot", "chart"), ) def update_plot( ctx: Context, dataset_id: str | None = None, - time_label: float | None = None, place_geometry: str | None = None, var_1_name: str | None = None, var_2_name: str | None = None, + time_label: float | None = None, _clicked: bool | None = None, # trigger, will always be True ) -> alt.Chart | None: + global error_message dataset = get_dataset(ctx, dataset_id) - if dataset is None or not place_geometry or not var_1_name or not var_2_name: - # TODO: set error message in panel UI - print("panel disabled") - return None if "time" in dataset.coords: if time_label: @@ -111,19 +176,13 @@ def update_plot( ).transform place_geometry = shapely.ops.transform(project, place_geometry) - if ( - place_geometry is None - or place_geometry.is_empty - or isinstance(place_geometry, shapely.geometry.Point) - ): - # TODO: set error message in panel UI - print("2-D histogram only works for geometries with a non-zero extent.") - return + if place_geometry is None or isinstance(place_geometry, shapely.geometry.Point): + error_message = "Selected geometry must cover an area." + return None dataset = mask_dataset_by_geometry(dataset, place_geometry) if dataset is None: - # TODO: set error message in panel UI - print("dataset is None after masking, invalid geometry?") + error_message = "Selected geometry produces empty subset" return None var_1_data: np.ndarray = dataset[var_1_name].values.ravel() @@ -147,23 +206,22 @@ def update_plot( source = pd.DataFrame( {var_1_name: x.ravel(), var_2_name: y.ravel(), "z": z.ravel()} ) - # TODO: use edges or center coordinates as tick labels. x_centers = x_edges[0:-1] + np.diff(x_edges) / 2 y_centers = y_edges[0:-1] + np.diff(y_edges) / 2 - # TODO: limit number of ticks on axes to, e.g., 10. - # TODO: allow chart to be adjusted to available container (
) size. - # Get the tick values + # Limit number of ticks on axes x_num_ticks = 8 + y_num_ticks = 8 + + # Get the tick values using the center values x_tick_values = np.linspace(min(x_centers), max(x_centers), x_num_ticks) x_tick_values = np.array( - [min(x_centers, key=lambda x: abs(x - t)) for t in x_tick_values] + [min(x_centers, key=lambda xc: abs(xc - t)) for t in x_tick_values] ) - num_ticks = 8 - y_tick_values = np.linspace(min(y_centers), max(y_centers), num_ticks) + y_tick_values = np.linspace(min(y_centers), max(y_centers), y_num_ticks) y_tick_values = np.array( - [min(y_centers, key=lambda y: abs(y - t)) for t in y_tick_values] + [min(y_centers, key=lambda yc: abs(yc - t)) for t in y_tick_values] ) chart = ( @@ -199,8 +257,14 @@ def update_plot( color=alt.Color("z:Q", scale=alt.Scale(scheme="viridis"), title="Density"), tooltip=[var_1_name, var_2_name, "z:Q"], ) - ).properties(width=300, height=300) - + ).properties( + # allow chart to be adjusted to available container (
) size. Make sure + # that you add width and height to the style props while defining the Vega + # chart plot in render panel method + width="container", + height="container", + ) + error_message = "" return chart @@ -262,8 +326,30 @@ def get_var_select_options( return var_names, var_name_1, var_name_2 +@panel.callback( + State("@app", "selectedDatasetTitle"), + State("@app", "selectedPlaceId"), + State("@app", "selectedPlaceGroup"), + State("@app", "selectedTimeLabel"), + Input("button", "clicked"), + Output("text", "children"), +) +def update_text( + ctx: Context, + dataset_title: str, + place_id: str | None = None, + place_group: list[dict[str, Any]] | None = None, + time_label: str | None = None, + _clicked: bool | None = None, +) -> list | None: + place_name = get_place_label(place_id, place_group) + if time_label: + return [f"{dataset_title} / {time_label[0:-1]} / {place_name}"] + return [f"{dataset_title} "] + + # TODO: Doesn't work. We need to ensure that show_progress() returns -# before update_plot() +# before update_plot(). EDIT: This cannot work in its current form! # @panel.callback( # Input("button", "clicked"), # Output("button", ""), @@ -273,3 +359,36 @@ def show_progress( _clicked: bool | None = None, # trigger, will always be True ) -> alt.Chart | None: return CircularProgress(id="button", size=28) + + +@panel.callback( + Input("@app", "selectedDatasetId"), + Input("@app", "selectedPlaceGeometry"), + Input("@app", "selectedTimeLabel"), + State("select_var_1"), + State("select_var_2"), + Input("button", "clicked"), + Output("error_message", "children"), +) +def update_error_message( + ctx: Context, + dataset_id: str | None = None, + place_geometry: str | None = None, + _time_label: str | None = None, + var_1_name: str | None = None, + var_2_name: str | None = None, + _clicked: bool | None = None, +) -> str: + global error_message + + if error_message == "": + if dataset_id is None: + error_message = "Missing dataset selection" + + if not place_geometry: + error_message = "Missing place geometry selection" + + elif not var_1_name or not var_2_name: + error_message = "Missing variable selection" + + return error_message diff --git a/examples/serve/panels-demo/demo_panels/panel_spectrum.py b/examples/serve/panels-demo/demo_panels/panel_spectrum.py index 763dddc0a..924cdda77 100644 --- a/examples/serve/panels-demo/demo_panels/panel_spectrum.py +++ b/examples/serve/panels-demo/demo_panels/panel_spectrum.py @@ -1,3 +1,4 @@ +import math from typing import Any import altair as alt @@ -10,8 +11,15 @@ import xarray as xr from chartlets import Component, Input, State, Output -from chartlets.components import Box, Button, Typography, Select, VegaChart +from chartlets.components import ( + Box, + Typography, + VegaChart, + Radio, + RadioGroup, +) +from xcube.webapi.viewer.components import Markdown from xcube.webapi.viewer.contrib import Panel, get_dataset from xcube.server.api import Context from xcube.constants import CRS_CRS84 @@ -20,52 +28,52 @@ panel = Panel(__name__, title="Spectrum View (Demo)", icon="light", position=4) +_THROTTLE_TOTAL_SPECTRUM_PLOTS = 10 + @panel.layout( State("@app", "selectedDatasetId"), State("@app", "selectedTimeLabel"), - State("@app", "selectedPlaceGroup"), State("@app", "themeMode"), ) def render_panel( ctx: Context, dataset_id: str, time_label: str, - place_group: list[dict[str, Any]], theme_mode: str, ) -> Component: - if theme_mode == "light": theme_mode = "default" - plot = VegaChart(id="plot", chart=None, style={"paddingTop": 6}, theme=theme_mode) - - text = f"{dataset_id} " f"/ {time_label[0:-1]}" - place_text = Typography(id="text", children=[text], align="center") - - place_names = get_places(ctx, place_group) - select_places = Select( - id="select_places", - label="places (points)", - value="", - options=place_names, + plot = VegaChart( + id="plot", + chart=None, + style={"paddingTop": 6, "width": "100%", "height": 400}, + theme=theme_mode, ) + if time_label: + text = f"{dataset_id} / {time_label[0:-1]}" + else: + text = f"{dataset_id}" + place_text = Typography(id="text", children=[text], align="left") - button = Button(id="button", text="Update", style={"maxWidth": 100}) + update_radio = Radio(id="update_radio", value="update", label="Update") + add_radio = Radio(id="add_radio", value="add", label="Add") - controls = Box( - children=[select_places, button], + exploration_radio_group = RadioGroup( + id="exploration_radio_group", + children=[add_radio, update_radio], + label="Exploration Mode", style={ "display": "flex", "flexDirection": "row", - "alignItems": "center", - "gap": 6, - "padding": 6, }, + tooltip="Add: Current spectrum is added and new point selections will be " + "added as new spectra. 'Update': Clear the chart but the current selection if any. ", ) control_bar = Box( - children=[place_text, controls], + children=[place_text, exploration_radio_group], style={ "display": "flex", "flexDirection": "row", @@ -76,17 +84,41 @@ def render_panel( }, ) + error_message = Typography( + id="error_message", style={"color": "red"}, children=[""] + ) + + instructions = Typography( + id="instructions", + children=[ + "Choose an exploration mode and select points to visualize their spectral " + "reflectance across available wavelengths in this highly dynamic Spectrum View.", + ], + variant="body2", + ) + note_text = Markdown( + text="**NOTE**: Only 10 spectrum plots can be added at a time as older " + "ones are removed. When switching from **Add** to **Update** " + "mode, the existing bar plots will be cleared if any." + ) + note = Typography( + id="note", + children=[note_text], + variant="body2", + ) + return Box( children=[ - "Select a map point from the dropdown and press 'Update' " - "to create a spectrum plot for that point and the selected time.", + instructions, + note, control_bar, + error_message, plot, ], style={ "display": "flex", "flexDirection": "column", - "alignItems": "center", + "alignItems": "left", "width": "100%", "height": "100%", "gap": 6, @@ -94,15 +126,13 @@ def render_panel( ) -def get_wavelength( +def get_spectra( dataset: xr.Dataset, place_group: gpd.GeoDataFrame, places: list, ) -> pd.DataFrame: - grid_mapping = GridMapping.from_dataset(dataset) - # if place_geometry is not None and not grid_mapping.crs.is_geographic: project = pyproj.Transformer.from_crs( CRS_CRS84, grid_mapping.crs, always_xy=True ).transform @@ -122,7 +152,6 @@ def get_wavelength( result = pd.DataFrame() for place in places: - i = (dataset_place.name_ref == place).argmax().item() selected_values = ( dataset_place.drop_vars("geometry_ref") @@ -133,6 +162,10 @@ def get_wavelength( variables = list(selected_values.keys()) values = [selected_values[var]["data"] for var in variables] + cleaned_values = [ + 0 if isinstance(x, float) and math.isnan(x) else x for x in values + ] + wavelengths = [ dataset_place[var].attrs.get("wavelength", None) for var in variables ] @@ -140,7 +173,7 @@ def get_wavelength( res = { "places": place, "variable": variables, - "reflectance": values, + "reflectance": cleaned_values, "wavelength": wavelengths, } @@ -151,143 +184,245 @@ def get_wavelength( return result -# TODO - add selectedDatasetName to Available State Properties @panel.callback( - State("@app", "selectedDatasetId"), - State("@app", "selectedTimeLabel"), + State("@app", "selectedDatasetTitle"), Input("@app", "selectedTimeLabel"), Output("text", "children"), ) def update_text( ctx: Context, - dataset_id: str, - time_label: str, - _time_label: bool | None = None, + dataset_title: str | None = None, + time_label: str | None = None, ) -> list | None: - - text = f"{dataset_id} " f"/ {time_label[0:-1]}" - - return [text] + if time_label: + return [f"{dataset_title} / {time_label[0:-1]}"] + return [f"{dataset_title} "] @panel.callback( State("@app", "selectedDatasetId"), - State("@app", "selectedTimeLabel"), + Input("@app", "selectedTimeLabel"), + Input("@app", "selectedPlaceGeometry"), State("@app", "selectedPlaceGroup"), - State("select_places", "value"), - Input("button", "clicked"), + State("@container", "spectrum_list"), + State("@container", "previous_mode"), + Input("exploration_radio_group", "value"), + State("plot", "chart"), Output("plot", "chart"), + Output("error_message", "children"), + Output("@container", "spectrum_list"), + Output("@container", "previous_mode"), ) def update_plot( ctx: Context, - dataset_id: str, - time_label: str, - place_group: list[dict[str, Any]], - place: list, - _clicked: bool | None = None, -) -> alt.Chart | None: - - if not place_group: - return None - - if not place: - return None + dataset_id: str | None = None, + time_label: str | None = None, + place_geo: dict[str, Any] | None = None, + place_group: list[dict[str, Any]] | None = None, + spectrum_list: list[str] | None = None, + previous_mode: str | None = None, + exploration_radio_group: str | None = None, + current_chart: alt.Chart | None = None, +) -> tuple[alt.Chart | None, str, list, str]: + if exploration_radio_group is None: + return None, "Missing exploration mode choice", spectrum_list, previous_mode dataset = get_dataset(ctx, dataset_id) - - place_group = gpd.GeoDataFrame( - [ - { - "id": feature["id"], - "name": feature["properties"]["label"], - "color": feature["properties"]["color"], - "x": feature["geometry"]["coordinates"][0], - "y": feature["geometry"]["coordinates"][1], - "geometry": Point( - feature["geometry"]["coordinates"][0], - feature["geometry"]["coordinates"][1], - ), - } - for feature in place_group[0]["features"] - if feature.get("geometry", {}).get("type") == "Point" - ] + has_point = any( + feature.get("geometry", {}).get("type") == "Point" + for collection in place_group + for feature in collection.get("features", []) ) - place_group["time"] = pd.to_datetime(time_label).tz_localize(None) - place = [place] - source = get_wavelength(dataset, place_group, place) + if dataset is None: + return None, "Missing dataset selection", spectrum_list, exploration_radio_group + elif not place_group or not has_point: + return None, "Missing point selection", spectrum_list, exploration_radio_group - if source is None: - # TODO: set error message in panel UI - print("No reflectances found in Variables") - return None + label = find_selected_point_label(place_group, place_geo) - chart = ( - alt.Chart(source) - .mark_line(point=True) - .encode( - x="wavelength:Q", - y="reflectance:Q", - color="places:N", - tooltip=["variable", "wavelength", "reflectance"], + if label is None: + return ( + None, + "There is no label for the selected point or no point is selected", + spectrum_list, + previous_mode, ) - ).properties(width=300, height=200) - return chart + if place_geo.get("type") == "Point": + place_group_geodf = gpd.GeoDataFrame( + [ + { + "name": label, + "x": place_geo["coordinates"][0], + "y": place_geo["coordinates"][1], + "geometry": Point( + place_geo["coordinates"][0], + place_geo["coordinates"][1], + ), + } + ] + ) + else: + return None, "Selected geometry must be a point", spectrum_list, previous_mode + + place_group_geodf["time"] = pd.to_datetime(time_label).tz_localize(None) + places_select = [label] + new_spectrum_data = get_spectra(dataset, place_group_geodf, places_select) + + if new_spectrum_data is None or new_spectrum_data.empty: + return None, "No reflectances found in Variables", spectrum_list, previous_mode + + new_spectrum_data["Legend"] = new_spectrum_data["places"] + ": " + time_label + + existing_data = extract_data_from_chart(current_chart) + + # Filter points in case the user deletes them. + valid_labels = { + feature["properties"]["label"] + for item in place_group + for feature in item.get("features", []) + } + existing_data = filter_data_by_valid_labels(existing_data, valid_labels) + + if exploration_radio_group == "update": + if previous_mode == "add": + existing_data = pd.DataFrame() + else: + existing_data, spectrum_list = remove_last_added_place( + existing_data, spectrum_list + ) + + updated_data = add_place_data_to_existing(existing_data, new_spectrum_data) + if spectrum_list is None: + spectrum_list = [] + spectrum_list.append(label) + else: + updated_data = add_place_data_to_existing(existing_data, new_spectrum_data) + + # Vega Altair doesn’t support xOffset with x:Q, so we manually shift each bar + # slightly + unique_groups = sorted(updated_data["Legend"].unique()) + n_groups = len(unique_groups) + group_offset_map = { + group: i - (n_groups - 1) / 2 for i, group in enumerate(unique_groups) + } + + bar_spacing = 3 + updated_data["x_offset"] = updated_data.apply( + lambda row: row["wavelength"] + group_offset_map[row["Legend"]] * bar_spacing, + axis=1, + ) + new_chart = create_chart_from_data(updated_data) + return new_chart, "", spectrum_list, exploration_radio_group -@panel.callback( - Input("@app", "selectedPlaceGroup"), - Output("select_places", "options"), -) -def get_places( - ctx: Context, - place_group: list[dict[str, Any]], -) -> list[str]: - if not place_group: - return [] - else: - return [ - feature["properties"]["label"] - for feature in place_group[0]["features"] - if feature.get("geometry", {}).get("type") == "Point" - ] +def find_selected_point_label( + features_data: list[dict[str, Any]], target_point: dict[str, Any] +) -> str | None: + if target_point is None: + return None + for feature_collection in features_data: + for feature in feature_collection.get("features", []): + geometry = feature.get("geometry", {}) + coordinates = geometry.get("coordinates", []) + geo_type = geometry.get("type", "") + + if coordinates == target_point.get( + "coordinates", [] + ) and geo_type == target_point.get("type", ""): + return feature.get("properties", {}).get("label", None) + + return None + + +def extract_data_from_chart(chart: alt.Chart) -> pd.DataFrame: + if chart is None: + return pd.DataFrame(columns=["places", "variable", "reflectance", "wavelength"]) + + if chart.get("datasets", {}) != {}: + return pd.DataFrame(list(chart.get("datasets").values())[0]) + + return pd.DataFrame(columns=["places", "variable", "reflectance", "wavelength"]) + + +def create_chart_from_data(data: pd.DataFrame) -> alt.Chart: + if data.empty: + return ( + alt.Chart( + pd.DataFrame( + columns=["places", "variable", "reflectance", "wavelength"] + ) + ) + .mark_bar() + .encode( + x="wavelength:N", + y="reflectance:Q", + xOffset="places:N", + color="Legend:N", + tooltip=["places", "variable", "wavelength", "reflectance"], + ) + .configure_legend(orient="bottom", columns=2) + .properties(width="container", height="container") + ) + return ( + alt.Chart(data) + .mark_bar(size=2) + .encode( + x=alt.X("x_offset:Q", title="Wavelength"), + y=alt.Y("reflectance:Q", title="Reflectance"), + xOffset="Legend:N", + color="Legend:N", + tooltip=["places", "variable", "wavelength", "reflectance"], + ) + .configure_legend(orient="bottom", columns=2) + .properties(width="container", height="container") + ) -@panel.callback( - State("@app", "themeMode"), - Input("@app", "themeMode"), - Output("plot", "theme"), -) -def update_theme( - ctx: Context, - theme_mode: str, - _new_theme: bool | None = None, -) -> str: - if theme_mode == "light": - theme_mode = "default" +def add_place_data_to_existing( + existing_data: pd.DataFrame, new_data: pd.DataFrame +) -> pd.DataFrame: + if new_data.empty: + return existing_data + + if existing_data.empty: + return new_data + + # This is to check if the new_data already exists in the existing_data to avoid + # duplication + if not existing_data.empty: + merged = new_data.merge(existing_data, how="left", indicator=True) + if (merged["_merge"] == "both").all(): + return existing_data + + combined_data = pd.concat([existing_data, new_data], ignore_index=True) + + # Throttling to last 10 spectrum views + final_df = combined_data[ + combined_data["places"].isin( + combined_data.drop_duplicates("places", keep="last").tail( + _THROTTLE_TOTAL_SPECTRUM_PLOTS + )["places"] + ) + ] + return final_df - return theme_mode +def remove_last_added_place( + data: pd.DataFrame, spectrum_list: list +) -> tuple[pd.DataFrame, list]: + if not spectrum_list or data.empty: + return data, spectrum_list + + last_places = spectrum_list.pop() + filtered_data = data[~data["places"].isin([last_places])] + return filtered_data, spectrum_list -# TODO - add selectedDatasetName to Available State Properties -@panel.callback( - State("@app", "selectedDatasetId"), - State("@app", "selectedTimeLabel"), - State("@app", "selectedPlaceGroup"), - State("select_places", "value"), - Input("@app", "selectedTimeLabel"), - Output("plot", "chart"), -) -def update_timestep( - ctx: Context, - dataset_id: str, - time_label: str, - place_group: list[dict[str, Any]], - place: list, - _new_time_label: bool | None = None, -) -> alt.Chart | None: - return update_plot(ctx, dataset_id, time_label, place_group, place) +def filter_data_by_valid_labels(data: pd.DataFrame, valid_labels: set) -> pd.DataFrame: + if data.empty: + return data + return data[data["places"].isin(valid_labels)] diff --git a/xcube/webapi/viewer/contrib/helpers.py b/xcube/webapi/viewer/contrib/helpers.py index 43fdb3091..025a17695 100644 --- a/xcube/webapi/viewer/contrib/helpers.py +++ b/xcube/webapi/viewer/contrib/helpers.py @@ -1,6 +1,7 @@ # Copyright (c) 2018-2025 by xcube team and contributors # Permissions are hereby granted under the terms of the MIT License: # https://opensource.org/licenses/MIT. +from typing import Any import xarray as xr @@ -14,3 +15,13 @@ def get_datasets_ctx(ctx: Context) -> DatasetsContext: def get_dataset(ctx: Context, dataset_id: str | None = None) -> xr.Dataset | None: return get_datasets_ctx(ctx).get_dataset(dataset_id) if dataset_id else None + + +def get_place_label(place_id: str, place_group: list[dict[str, Any]]) -> str | None: + if not place_group or not place_id: + return None + for place in place_group: + for features in place["features"]: + if features["id"] == place_id: + return features["properties"]["label"] + return None