Skip to content

Commit 4e6305f

Browse files
authored
Merge pull request #1144 from xcube-dev/yogesh-xxx-demo-todos
Finishing up remaining TODOs in panel's demo
2 parents 7aa698a + 7595e94 commit 4e6305f

File tree

5 files changed

+464
-177
lines changed

5 files changed

+464
-177
lines changed

CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ for dataset reprojection:
8080
- Updated schema for the `num_levels` parameter which now explains the parameter
8181
in more detail.
8282

83+
* Improved the demos for the xcube Viewer server-side extensions in various ways (#1134):
84+
- enhanced user input validation
85+
- added error message label
86+
- fixed bugs in code
87+
- improved UI styles and general UX
88+
8389
## Changes in 1.9.0
8490

8591
### Enhancements

examples/serve/panels-demo/demo_panels/panel_demo.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ def render_panel(
3030
label="Opaque",
3131
)
3232

33+
# Will render the firstrow from viewer
34+
# first_row = FirstRow(id="firstrow", required=[dataset_title, time_label],
35+
# hostComponent= True)
36+
37+
# Will render the firstrow as a box with typography from chartlets
38+
# first_row = FirstRow(id="firstrow", required=[dataset_title, time_label],
39+
# hostComponent=False)
40+
3341
color_select = Select(
3442
id="color",
3543
value=color,
@@ -42,6 +50,15 @@ def render_panel(
4250
id="info_text", children=update_info_text(ctx, dataset_id, opaque, color)
4351
)
4452

53+
instructions = Typography(
54+
id="instructions",
55+
children=[
56+
"This panel just demonstrates how server-side extensions work. "
57+
"It has no useful functionality.",
58+
],
59+
variant="body2",
60+
)
61+
4562
return Box(
4663
style={
4764
"display": "flex",
@@ -51,11 +68,10 @@ def render_panel(
5168
"gap": "6px",
5269
},
5370
children=[
54-
"This panel just demonstrates how server-side extensions work. "
55-
"It has no useful functionality.",
71+
instructions,
5672
opaque_checkbox,
5773
color_select,
58-
info_text
74+
info_text,
5975
],
6076
)
6177

examples/serve/panels-demo/demo_panels/panel_histo2d.py

Lines changed: 157 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) 2018-2025 by xcube team and contributors
22
# Permissions are hereby granted under the terms of the MIT License:
33
# https://opensource.org/licenses/MIT.
4+
from typing import Any
45

56
import altair as alt
67
import numpy as np
@@ -10,13 +11,22 @@
1011
import shapely.geometry
1112
import shapely.ops
1213
from chartlets import Component, Input, Output, State
13-
from chartlets.components import Box, Button, CircularProgress, Select, VegaChart
14+
from chartlets.components import (
15+
Box,
16+
Button,
17+
CircularProgress,
18+
Select,
19+
VegaChart,
20+
Typography,
21+
)
1422

1523
from xcube.constants import CRS_CRS84
1624
from xcube.core.geom import mask_dataset_by_geometry, normalize_geometry
1725
from xcube.core.gridmapping import GridMapping
1826
from xcube.server.api import Context
27+
from xcube.webapi.viewer.components import Markdown
1928
from xcube.webapi.viewer.contrib import Panel, get_dataset
29+
from xcube.webapi.viewer.contrib.helpers import get_place_label
2030

2131
panel = Panel(__name__, title="2D Histogram (Demo)", icon="equalizer", position=3)
2232

@@ -27,11 +37,37 @@
2737
NUM_BINS_MAX = 64
2838

2939

30-
@panel.layout(State("@app", "selectedDatasetId"))
31-
def render_panel(ctx: Context, dataset_id: str | None = None) -> Component:
40+
@panel.layout(
41+
State("@app", "selectedDatasetId"),
42+
State("@app", "selectedDatasetTitle"),
43+
State("@app", "selectedTimeLabel"),
44+
)
45+
def render_panel(
46+
ctx: Context,
47+
dataset_id: str | None = None,
48+
dataset_title: str | None = None,
49+
time_label: str | None = None,
50+
) -> Component:
3251
dataset = get_dataset(ctx, dataset_id)
3352

34-
plot = VegaChart(id="plot", chart=None, style={"paddingTop": 6})
53+
plot = VegaChart(
54+
id="plot",
55+
chart=None,
56+
style={
57+
"paddingTop": 6,
58+
# Since for dynamic resizing we use `container` as width and height for
59+
# this chart during updates, it is necessary that we provide the width
60+
# and the height here. This is for the `container` div of VegaChart.
61+
"width": "100%",
62+
"height": 400,
63+
},
64+
)
65+
66+
if time_label:
67+
text = f"{dataset_title} / {time_label[0:-1]}"
68+
else:
69+
text = f"{dataset_title}"
70+
place_text = Typography(id="text", children=[text], align="left")
3571

3672
var_names, var_name_1, var_name_2 = get_var_select_options(dataset)
3773

@@ -42,7 +78,7 @@ def render_panel(ctx: Context, dataset_id: str | None = None) -> Component:
4278
id="select_var_2", label="Variable 2", value=var_name_2, options=var_names
4379
)
4480

45-
button = Button(id="button", text="Update", style={"maxWidth": 100})
81+
button = Button(id="button", text="Update", style={"maxWidth": 100}, disabled=True)
4682

4783
controls = Box(
4884
children=[select_var_1, select_var_2, button],
@@ -54,48 +90,77 @@ def render_panel(ctx: Context, dataset_id: str | None = None) -> Component:
5490
},
5591
)
5692

93+
control_bar = Box(
94+
children=[place_text, controls],
95+
style={
96+
"display": "flex",
97+
"flexDirection": "row",
98+
"alignItems": "center",
99+
"justifyContent": "space-between",
100+
"width": "100%",
101+
"gap": 6,
102+
},
103+
)
104+
105+
error_message = Typography(
106+
id="error_message",
107+
style={"color": "red"},
108+
children=[""],
109+
)
110+
111+
instructions_text = Markdown(
112+
text="Create or select a region shape in the map, then select two "
113+
"variables from the dropdowns, and press **Update** to create "
114+
"a 2D histogram plot.",
115+
)
116+
117+
instructions = Typography(
118+
id="instructions",
119+
children=[instructions_text],
120+
variant="body2",
121+
)
122+
57123
return Box(
58124
children=[
59-
"Create or select a region shape in the map, then select two "
60-
"variables from the dropdowns, and press 'Update' to create "
61-
"a 2D histogram plot.",
62-
controls,
63-
plot
125+
instructions,
126+
control_bar,
127+
plot,
128+
error_message,
64129
],
65130
style={
66131
"display": "flex",
67132
"flexDirection": "column",
68-
"alignItems": "center",
133+
"alignItems": "left",
69134
"width": "100%",
70135
"height": "100%",
71136
"gap": 6,
72137
},
73138
)
74139

75140

141+
error_message = ""
142+
143+
76144
@panel.callback(
77145
State("@app", "selectedDatasetId"),
78-
State("@app", "selectedTimeLabel"),
79146
State("@app", "selectedPlaceGeometry"),
80147
State("select_var_1"),
81148
State("select_var_2"),
149+
State("@app", "selectedTimeLabel"),
82150
Input("button", "clicked"),
83151
Output("plot", "chart"),
84152
)
85153
def update_plot(
86154
ctx: Context,
87155
dataset_id: str | None = None,
88-
time_label: float | None = None,
89156
place_geometry: str | None = None,
90157
var_1_name: str | None = None,
91158
var_2_name: str | None = None,
159+
time_label: float | None = None,
92160
_clicked: bool | None = None, # trigger, will always be True
93161
) -> alt.Chart | None:
162+
global error_message
94163
dataset = get_dataset(ctx, dataset_id)
95-
if dataset is None or not place_geometry or not var_1_name or not var_2_name:
96-
# TODO: set error message in panel UI
97-
print("panel disabled")
98-
return None
99164

100165
if "time" in dataset.coords:
101166
if time_label:
@@ -111,19 +176,13 @@ def update_plot(
111176
).transform
112177
place_geometry = shapely.ops.transform(project, place_geometry)
113178

114-
if (
115-
place_geometry is None
116-
or place_geometry.is_empty
117-
or isinstance(place_geometry, shapely.geometry.Point)
118-
):
119-
# TODO: set error message in panel UI
120-
print("2-D histogram only works for geometries with a non-zero extent.")
121-
return
179+
if place_geometry is None or isinstance(place_geometry, shapely.geometry.Point):
180+
error_message = "Selected geometry must cover an area."
181+
return None
122182

123183
dataset = mask_dataset_by_geometry(dataset, place_geometry)
124184
if dataset is None:
125-
# TODO: set error message in panel UI
126-
print("dataset is None after masking, invalid geometry?")
185+
error_message = "Selected geometry produces empty subset"
127186
return None
128187

129188
var_1_data: np.ndarray = dataset[var_1_name].values.ravel()
@@ -147,23 +206,22 @@ def update_plot(
147206
source = pd.DataFrame(
148207
{var_1_name: x.ravel(), var_2_name: y.ravel(), "z": z.ravel()}
149208
)
150-
# TODO: use edges or center coordinates as tick labels.
151209
x_centers = x_edges[0:-1] + np.diff(x_edges) / 2
152210
y_centers = y_edges[0:-1] + np.diff(y_edges) / 2
153-
# TODO: limit number of ticks on axes to, e.g., 10.
154-
# TODO: allow chart to be adjusted to available container (<div>) size.
155211

156-
# Get the tick values
212+
# Limit number of ticks on axes
157213
x_num_ticks = 8
214+
y_num_ticks = 8
215+
216+
# Get the tick values using the center values
158217
x_tick_values = np.linspace(min(x_centers), max(x_centers), x_num_ticks)
159218
x_tick_values = np.array(
160-
[min(x_centers, key=lambda x: abs(x - t)) for t in x_tick_values]
219+
[min(x_centers, key=lambda xc: abs(xc - t)) for t in x_tick_values]
161220
)
162221

163-
num_ticks = 8
164-
y_tick_values = np.linspace(min(y_centers), max(y_centers), num_ticks)
222+
y_tick_values = np.linspace(min(y_centers), max(y_centers), y_num_ticks)
165223
y_tick_values = np.array(
166-
[min(y_centers, key=lambda y: abs(y - t)) for t in y_tick_values]
224+
[min(y_centers, key=lambda yc: abs(yc - t)) for t in y_tick_values]
167225
)
168226

169227
chart = (
@@ -199,8 +257,14 @@ def update_plot(
199257
color=alt.Color("z:Q", scale=alt.Scale(scheme="viridis"), title="Density"),
200258
tooltip=[var_1_name, var_2_name, "z:Q"],
201259
)
202-
).properties(width=300, height=300)
203-
260+
).properties(
261+
# allow chart to be adjusted to available container (<div>) size. Make sure
262+
# that you add width and height to the style props while defining the Vega
263+
# chart plot in render panel method
264+
width="container",
265+
height="container",
266+
)
267+
error_message = ""
204268
return chart
205269

206270

@@ -262,8 +326,30 @@ def get_var_select_options(
262326
return var_names, var_name_1, var_name_2
263327

264328

329+
@panel.callback(
330+
State("@app", "selectedDatasetTitle"),
331+
State("@app", "selectedPlaceId"),
332+
State("@app", "selectedPlaceGroup"),
333+
State("@app", "selectedTimeLabel"),
334+
Input("button", "clicked"),
335+
Output("text", "children"),
336+
)
337+
def update_text(
338+
ctx: Context,
339+
dataset_title: str,
340+
place_id: str | None = None,
341+
place_group: list[dict[str, Any]] | None = None,
342+
time_label: str | None = None,
343+
_clicked: bool | None = None,
344+
) -> list | None:
345+
place_name = get_place_label(place_id, place_group)
346+
if time_label:
347+
return [f"{dataset_title} / {time_label[0:-1]} / {place_name}"]
348+
return [f"{dataset_title} "]
349+
350+
265351
# TODO: Doesn't work. We need to ensure that show_progress() returns
266-
# before update_plot()
352+
# before update_plot(). EDIT: This cannot work in its current form!
267353
# @panel.callback(
268354
# Input("button", "clicked"),
269355
# Output("button", ""),
@@ -273,3 +359,36 @@ def show_progress(
273359
_clicked: bool | None = None, # trigger, will always be True
274360
) -> alt.Chart | None:
275361
return CircularProgress(id="button", size=28)
362+
363+
364+
@panel.callback(
365+
Input("@app", "selectedDatasetId"),
366+
Input("@app", "selectedPlaceGeometry"),
367+
Input("@app", "selectedTimeLabel"),
368+
State("select_var_1"),
369+
State("select_var_2"),
370+
Input("button", "clicked"),
371+
Output("error_message", "children"),
372+
)
373+
def update_error_message(
374+
ctx: Context,
375+
dataset_id: str | None = None,
376+
place_geometry: str | None = None,
377+
_time_label: str | None = None,
378+
var_1_name: str | None = None,
379+
var_2_name: str | None = None,
380+
_clicked: bool | None = None,
381+
) -> str:
382+
global error_message
383+
384+
if error_message == "":
385+
if dataset_id is None:
386+
error_message = "Missing dataset selection"
387+
388+
if not place_geometry:
389+
error_message = "Missing place geometry selection"
390+
391+
elif not var_1_name or not var_2_name:
392+
error_message = "Missing variable selection"
393+
394+
return error_message

0 commit comments

Comments
 (0)