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