-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1092 from xcube-dev/forman-x-ease_impl_of_server_…
…side_viewer_panels Ease development of server-side viewer panels
- Loading branch information
Showing
13 changed files
with
657 additions
and
418 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,7 @@ | ||
from chartlets import Extension | ||
from .my_panel_1 import panel as my_panel_1 | ||
from .my_panel_2 import panel as my_panel_2 | ||
|
||
ext = Extension(__name__) | ||
ext.add(my_panel_1) | ||
ext.add(my_panel_2) |
247 changes: 195 additions & 52 deletions
247
examples/serve/panels-demo/my_viewer_ext/my_panel_1.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,83 +1,226 @@ | ||
import altair as alt | ||
import numpy as np | ||
import pandas as pd | ||
import pyproj | ||
import shapely | ||
import shapely.ops | ||
from chartlets import Component, Input, State, Output | ||
from chartlets.components import Box, Dropdown, Checkbox, Typography | ||
from chartlets.components import Box, Button, CircularProgress, Plot, Select | ||
|
||
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.webapi.viewer.contrib import Panel | ||
from xcube.webapi.viewer.contrib import get_datasets_ctx | ||
from xcube.webapi.viewer.contrib import get_dataset | ||
from xcube.server.api import Context | ||
|
||
|
||
panel = Panel(__name__, title="Panel B") | ||
panel = Panel(__name__, title="2D Histogram") | ||
|
||
|
||
COLORS = [("red", 0), ("green", 1), ("blue", 2), ("yellow", 3)] | ||
# Number of bins in x and y directions. | ||
# This results in columns of 4096 items. | ||
# Vega Altair's maximum is 5000. | ||
NUM_BINS_MAX = 64 | ||
|
||
|
||
@panel.layout( | ||
Input(source="app", property="controlState.selectedDatasetId"), | ||
) | ||
def render_panel( | ||
ctx: Context, | ||
dataset_id: str = "", | ||
) -> Component: | ||
@panel.layout(State("@app", "selectedDatasetId")) | ||
def render_panel(ctx: Context, dataset_id: str | None = None) -> Component: | ||
dataset = get_dataset(ctx, dataset_id) | ||
|
||
opaque = False | ||
color = 0 | ||
plot = Plot(id="plot", chart=None, style={"paddingTop": 6}) | ||
|
||
opaque_checkbox = Checkbox( | ||
id="opaque", | ||
value=opaque, | ||
label="Opaque", | ||
) | ||
var_names, var_name_1, var_name_2 = get_var_select_options(dataset) | ||
|
||
color_dropdown = Dropdown( | ||
id="color", | ||
value=color, | ||
label="Color", | ||
options=COLORS, | ||
style={"flexGrow": 0, "minWidth": 80}, | ||
select_var_1 = Select( | ||
id="select_var_1", label="Variable 1", value=var_name_1, options=var_names | ||
) | ||
select_var_2 = Select( | ||
id="select_var_2", label="Variable 2", value=var_name_2, options=var_names | ||
) | ||
|
||
info_text = Typography( | ||
id="info_text", text=update_info_text(ctx, dataset_id, opaque, color) | ||
button = Button(id="button", text="Update", style={"maxWidth": 100}) | ||
|
||
controls = Box( | ||
children=[select_var_1, select_var_2, button], | ||
style={ | ||
"display": "flex", | ||
"flexDirection": "row", | ||
"alignItems": "center", | ||
"gap": 6, | ||
"padding": 6, | ||
}, | ||
) | ||
|
||
return Box( | ||
children=[plot, controls], | ||
style={ | ||
"display": "flex", | ||
"flexDirection": "column", | ||
"alignItems": "center", | ||
"width": "100%", | ||
"height": "100%", | ||
"gap": "6px", | ||
"gap": 6, | ||
"padding": 6, | ||
}, | ||
children=[opaque_checkbox, color_dropdown, info_text], | ||
) | ||
|
||
|
||
# noinspection PyUnusedLocal | ||
@panel.callback( | ||
Input(source="app", property="controlState.selectedDatasetId"), | ||
Input("opaque"), | ||
Input("color"), | ||
State("info_text", "text"), | ||
Output("info_text", "text"), | ||
State("@app", "selectedDatasetId"), | ||
State("@app", "selectedTimeLabel"), | ||
State("@app", "selectedPlaceGeometry"), | ||
State("select_var_1"), | ||
State("select_var_2"), | ||
Input("button", "clicked"), | ||
Output("plot", "chart"), | ||
) | ||
def update_info_text( | ||
def update_plot( | ||
ctx: Context, | ||
dataset_id: str = "", | ||
opaque: bool = False, | ||
color: int = 0, | ||
info_text: str = "", | ||
) -> str: | ||
ds_ctx = get_datasets_ctx(ctx) | ||
ds_configs = ds_ctx.get_dataset_configs() | ||
|
||
opaque = opaque or False | ||
color = color if color is not None else 0 | ||
return ( | ||
f"The dataset is {dataset_id}," | ||
f" the color is {COLORS[color][0]} and" | ||
f" it {'is' if opaque else 'is not'} opaque." | ||
f" The length of the last info text" | ||
f" was {len(info_text or "")}." | ||
f" The number of datasets is {len(ds_configs)}." | ||
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, | ||
_clicked: bool | None = None, # trigger, will always be True | ||
) -> alt.Chart | None: | ||
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: | ||
dataset = dataset.sel(time=pd.Timestamp(time_label[0:-1]), method="nearest") | ||
else: | ||
dataset = dataset.isel(time=-1) | ||
|
||
grid_mapping = GridMapping.from_dataset(dataset) | ||
place_geometry = normalize_geometry(place_geometry) | ||
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 | ||
place_geometry = shapely.ops.transform(project, place_geometry) | ||
|
||
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?") | ||
return None | ||
|
||
var_1_data: np.ndarray = dataset[var_1_name].values.ravel() | ||
var_2_data: np.ndarray = dataset[var_2_name].values.ravel() | ||
var_1_range = [np.nanmin(var_1_data), np.nanmax(var_1_data)] | ||
var_2_range = [np.nanmin(var_2_data), np.nanmax(var_2_data)] | ||
num_bins = min(NUM_BINS_MAX, var_1_data.size) | ||
hist2d, x_edges, y_edges = np.histogram2d( | ||
var_1_data, | ||
var_2_data, | ||
bins=num_bins, | ||
range=np.array([var_1_range, var_2_range]), | ||
density=True, | ||
) | ||
# x and y 2D arrays with bin centers | ||
x, y = np.meshgrid(np.arange(num_bins), np.arange(num_bins)) | ||
# z = hist2d | ||
z = np.where(hist2d > 0.0, hist2d, np.nan).T | ||
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 (<div>) size. | ||
chart = ( | ||
alt.Chart(source) | ||
.mark_rect() | ||
.encode( | ||
x=alt.X( | ||
f"{var_1_name}:O", | ||
# scale=alt.Scale(bins=x_centers), | ||
), | ||
y=alt.Y( | ||
f"{var_2_name}:O", | ||
sort="descending", | ||
# scale=alt.Scale(bins=y_centers), | ||
), | ||
color=alt.Color("z:Q", scale=alt.Scale(scheme="viridis"), title="Density"), | ||
) | ||
).properties(width=360, height=360) | ||
|
||
return chart | ||
|
||
|
||
@panel.callback( | ||
Input("@app", "selectedDatasetId"), | ||
Input("@app", "selectedPlaceGeometry"), | ||
Output("button", "disabled"), | ||
) | ||
def set_button_disablement( | ||
_ctx: Context, | ||
dataset_id: str | None = None, | ||
place_geometry: str | None = None, | ||
) -> bool: | ||
return not dataset_id or not place_geometry | ||
|
||
|
||
@panel.callback( | ||
Input("@app", "selectedDatasetId"), | ||
State("select_var_1", "value"), | ||
State("select_var_2", "value"), | ||
Output("select_var_1", "options"), | ||
Output("select_var_1", "value"), | ||
Output("select_var_2", "options"), | ||
Output("select_var_2", "value"), | ||
) | ||
def populate_selects( | ||
ctx: Context, | ||
dataset_id: str | None = None, | ||
var_name_1: str | None = None, | ||
var_name_2: str | None = None, | ||
) -> tuple[list, str | None, list, str | None]: | ||
dataset = get_dataset(ctx, dataset_id) | ||
var_names, var_name_1, var_name_2 = get_var_select_options( | ||
dataset, var_name_1, var_name_2 | ||
) | ||
return var_names, var_name_1, var_names, var_name_2 | ||
|
||
|
||
def get_var_select_options( | ||
dataset, | ||
var_name_1: str | None = None, | ||
var_name_2: str | None = None, | ||
) -> tuple[list, str | None, str | None]: | ||
|
||
if dataset is not None: | ||
var_names = [ | ||
var_name | ||
for var_name, var in dataset.data_vars.items() | ||
if len(var.dims) >= 1 | ||
] | ||
else: | ||
var_names = [] | ||
|
||
if var_names: | ||
if not var_name_1 or var_name_1 not in var_names: | ||
var_name_1 = var_names[0] | ||
if not var_name_2 or var_name_2 not in var_names: | ||
var_name_2 = var_names[0] | ||
|
||
return var_names, var_name_1, var_name_2 | ||
|
||
|
||
# TODO: Doesn't work. We need to ensure that show_progress() returns | ||
# before update_plot() | ||
# @panel.callback( | ||
# Input("button", "clicked"), | ||
# Output("button", ""), | ||
# ) | ||
def show_progress( | ||
_ctx: Context, | ||
_clicked: bool | None = None, # trigger, will always be True | ||
) -> alt.Chart | None: | ||
return CircularProgress(id="button", size=28) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
from chartlets import Component, Input, State, Output | ||
from chartlets.components import Box, Select, Checkbox, Typography | ||
|
||
from xcube.webapi.viewer.contrib import Panel | ||
from xcube.webapi.viewer.contrib import get_datasets_ctx | ||
from xcube.server.api import Context | ||
|
||
|
||
panel = Panel(__name__, title="Panel B") | ||
|
||
|
||
COLORS = [(0, "red"), (1, "green"), (2, "blue"), (3, "yellow")] | ||
|
||
|
||
@panel.layout( | ||
State("@app", "selectedDatasetId"), | ||
) | ||
def render_panel( | ||
ctx: Context, | ||
dataset_id: str = "", | ||
) -> Component: | ||
|
||
opaque = False | ||
color = 0 | ||
|
||
opaque_checkbox = Checkbox( | ||
id="opaque", | ||
value=opaque, | ||
label="Opaque", | ||
) | ||
|
||
color_select = Select( | ||
id="color", | ||
value=color, | ||
label="Color", | ||
options=COLORS, | ||
style={"flexGrow": 0, "minWidth": 80}, | ||
) | ||
|
||
info_text = Typography( | ||
id="info_text", children=update_info_text(ctx, dataset_id, opaque, color) | ||
) | ||
|
||
return Box( | ||
style={ | ||
"display": "flex", | ||
"flexDirection": "column", | ||
"width": "100%", | ||
"height": "100%", | ||
"gap": "6px", | ||
}, | ||
children=[opaque_checkbox, color_select, info_text], | ||
) | ||
|
||
|
||
# noinspection PyUnusedLocal | ||
@panel.callback( | ||
Input("@app", "selectedDatasetId"), | ||
Input("opaque"), | ||
Input("color"), | ||
State("info_text", "children"), | ||
Output("info_text", "children"), | ||
) | ||
def update_info_text( | ||
ctx: Context, | ||
dataset_id: str = "", | ||
opaque: bool = False, | ||
color: int = 0, | ||
info_children: list[str] = "", | ||
) -> list[str]: | ||
ds_ctx = get_datasets_ctx(ctx) | ||
ds_configs = ds_ctx.get_dataset_configs() | ||
|
||
info_text = info_children[0] if info_children else "" | ||
|
||
opaque = opaque or False | ||
color = color if color is not None else 0 | ||
return [ | ||
f"The dataset is {dataset_id}," | ||
f" the color is {COLORS[color][1]} and" | ||
f" it {'is' if opaque else 'is not'} opaque." | ||
f" The length of the last info text" | ||
f" was {len(info_text or "")}." | ||
f" The number of datasets is {len(ds_configs)}." | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.