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
56import altair as alt
67import numpy as np
1011import shapely .geometry
1112import shapely .ops
1213from 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
1523from xcube .constants import CRS_CRS84
1624from xcube .core .geom import mask_dataset_by_geometry , normalize_geometry
1725from xcube .core .gridmapping import GridMapping
1826from xcube .server .api import Context
27+ from xcube .webapi .viewer .components import Markdown
1928from xcube .webapi .viewer .contrib import Panel , get_dataset
29+ from xcube .webapi .viewer .contrib .helpers import get_place_label
2030
2131panel = Panel (__name__ , title = "2D Histogram (Demo)" , icon = "equalizer" , position = 3 )
2232
2737NUM_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)
85153def 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