Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ease development of server-side viewer panels #1092

Merged
merged 14 commits into from
Nov 28, 2024
Merged
2 changes: 1 addition & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
Panel instances provide two decorators `layout()` and `callback()`
which are used to implement the UI and the interaction behaviour,
respectively. The functionality is provided by the
`https://github.com/bcdev/chartlets` Python library.
[Chartlets](https://bcdev.github.io/chartlets/) Python library.
A working example can be found in `examples/serve/panels-demo`.

* The xcube test helper module `test.s3test` has been enhanced to support
Expand Down
2 changes: 1 addition & 1 deletion environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ dependencies:
- altair
- pip
- pip:
- chartlets
- chartlets >=0.0.28
# Testing
- flake8 >=3.7
- kerchunk
Expand Down
2 changes: 2 additions & 0 deletions examples/serve/panels-demo/my_viewer_ext/__init__.py
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 examples/serve/panels-demo/my_viewer_ext/my_panel_1.py
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)
85 changes: 85 additions & 0 deletions examples/serve/panels-demo/my_viewer_ext/my_panel_2.py
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)}."
]
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies = [
"cftime>=1.6.3",
"click>=8.0",
"cmocean>=2.0",
"chartlets>=0.0.28",
"dask>=2021.6",
"dask-image>=0.6",
"deprecated>=1.2",
Expand Down
Loading