Skip to content

Commit e794e29

Browse files
authored
Merge pull request #837 from dcs4cop/forman-xxx-viewer_add_dataset_with_style
Add datasets to viewer with style
2 parents 7924c76 + fcb0346 commit e794e29

File tree

7 files changed

+2078
-703
lines changed

7 files changed

+2078
-703
lines changed

CHANGES.md

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
## Changes in 1.0.2 (in development)
22

3+
* The `Viewer.add_dataset()` method of the xcube JupyterLab integration
4+
has been enhanced by two optional keyword arguments `style` and
5+
`color_mappings` to allow for customized, initial color mapping
6+
of dataset variables. The example notebook
7+
[xcube-viewer-in-jl.ipynb](examples/notebooks/viewer/xcube-viewer-in-jl.ipynb)
8+
has been updated to reflect the enhancement.
39

410
## Changes in 1.0.1
511

@@ -275,12 +281,6 @@
275281
* Added convenience method `DataStore.list_data_ids()` that works
276282
like `get_data_ids()`, but returns a list instead of an iterator. (#776)
277283

278-
* Added Notebook
279-
[xcube-viewer-in-jl.ipynb](examples/notebooks/viewer/xcube-viewer-in-jl.ipynb)
280-
that explains how xcube Viewer can now be utilised in JupyterLab
281-
using the new (still experimental) xcube JupyterLab extension
282-
[xcube-jl-ext](https://github.com/dcs4cop/xcube-jl-ext).
283-
284284
* Replaced usages of deprecated numpy dtype `numpy.bool`
285285
by Python type `bool`.
286286

examples/notebooks/viewer/xcube-viewer-in-jl.ipynb

+1,804-681
Large diffs are not rendered by default.

test/webapi/viewer/test_viewer.py

+189
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# The MIT License (MIT)
2+
# Copyright (c) 2023 by the xcube team and contributors
3+
#
4+
# Permission is hereby granted, free of charge, to any person obtaining a
5+
# copy of this software and associated documentation files (the "Software"),
6+
# to deal in the Software without restriction, including without limitation
7+
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
8+
# and/or sell copies of the Software, and to permit persons to whom the
9+
# Software is furnished to do so, subject to the following conditions:
10+
#
11+
# The above copyright notice and this permission notice shall be included in
12+
# all copies or substantial portions of the Software.
13+
#
14+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20+
# DEALINGS IN THE SOFTWARE.
21+
22+
import unittest
23+
from typing import Optional, Mapping, Any
24+
25+
import pytest
26+
27+
from xcube.core.new import new_cube
28+
from xcube.server.api import ApiError
29+
from xcube.webapi.datasets.context import DatasetsContext
30+
from xcube.webapi.viewer import Viewer
31+
32+
STYLES_CONFIG = {
33+
"Styles": [
34+
{
35+
"Identifier": "SST",
36+
"ColorMappings": {
37+
"analysed_sst": {
38+
"ValueRange": [270, 290],
39+
"ColorBar": "inferno"
40+
}
41+
}
42+
}
43+
]
44+
}
45+
46+
47+
class ViewerTest(unittest.TestCase):
48+
49+
def setUp(self) -> None:
50+
self.viewer: Optional[Viewer] = None
51+
52+
def tearDown(self) -> None:
53+
if self.viewer is not None:
54+
self.viewer.stop_server()
55+
56+
def get_viewer(self, server_config: Optional[Mapping[str, Any]] = None) \
57+
-> Viewer:
58+
self.viewer = Viewer(server_config=server_config)
59+
return self.viewer
60+
61+
def test_start_and_stop_server(self):
62+
viewer = self.get_viewer()
63+
self.assertTrue(viewer.is_server_running)
64+
self.assertIsInstance(viewer.datasets_ctx, DatasetsContext)
65+
viewer.stop_server()
66+
self.assertFalse(viewer.is_server_running)
67+
68+
def test_info(self):
69+
viewer = self.get_viewer()
70+
# Just a smoke test:
71+
viewer.info() # will print something
72+
73+
def test_show(self):
74+
viewer = self.get_viewer()
75+
# Just a smoke test:
76+
result = viewer.show() # will show viewer
77+
if result is not None:
78+
from IPython.core.display import HTML
79+
self.assertIsInstance(result, HTML)
80+
81+
def test_no_config(self):
82+
viewer = self.get_viewer()
83+
self.assertIsInstance(viewer.server_config, dict)
84+
self.assertIn("port", viewer.server_config)
85+
self.assertIn("address", viewer.server_config)
86+
self.assertIn("reverse_url_prefix", viewer.server_config)
87+
88+
def test_with_config(self):
89+
viewer = self.get_viewer(STYLES_CONFIG)
90+
self.assertIsInstance(viewer.server_config, dict)
91+
self.assertIn("port", viewer.server_config)
92+
self.assertIn("address", viewer.server_config)
93+
self.assertIn("reverse_url_prefix", viewer.server_config)
94+
self.assertIn("Styles", viewer.server_config)
95+
self.assertEqual(STYLES_CONFIG["Styles"],
96+
viewer.server_config["Styles"])
97+
98+
def test_urls(self):
99+
viewer = self.get_viewer()
100+
101+
self.assertIn("port", viewer.server_config)
102+
port = viewer.server_config["port"]
103+
reverse_url_prefix = viewer.server_config.get("reverse_url_prefix")
104+
105+
if not reverse_url_prefix:
106+
expected_server_url = f"http://localhost:{port}"
107+
self.assertEqual(expected_server_url,
108+
viewer.server_url)
109+
110+
expected_viewer_url = f"{expected_server_url}/viewer/" \
111+
f"?serverUrl={expected_server_url}"
112+
self.assertEqual(expected_viewer_url,
113+
viewer.viewer_url)
114+
else:
115+
self.assertIsInstance(viewer.server_url, str)
116+
self.assertIsInstance(viewer.viewer_url, str)
117+
self.assertIn(reverse_url_prefix, viewer.server_url)
118+
self.assertIn(viewer.server_url, viewer.viewer_url)
119+
120+
def test_add_and_remove_dataset(self):
121+
viewer = self.get_viewer()
122+
123+
# Generate identifier and get title from dataset
124+
ds_id_1 = viewer.add_dataset(
125+
new_cube(variables={"analysed_sst": 280.},
126+
title="My SST 1"),
127+
)
128+
self.assertIsInstance(ds_id_1, str)
129+
130+
# Provide identifier and title
131+
ds_id_2 = viewer.add_dataset(
132+
new_cube(variables={"analysed_sst": 282.}),
133+
ds_id="my_sst_2",
134+
title="My SST 2"
135+
)
136+
self.assertEqual("my_sst_2", ds_id_2)
137+
138+
ds_config_1 = self.viewer.datasets_ctx.get_dataset_config(ds_id_1)
139+
self.assertEqual({"Identifier": ds_id_1,
140+
"Title": "My SST 1"},
141+
ds_config_1)
142+
143+
ds_config_2 = self.viewer.datasets_ctx.get_dataset_config(ds_id_2)
144+
self.assertEqual({"Identifier": ds_id_2,
145+
"Title": "My SST 2"},
146+
ds_config_2)
147+
148+
self.viewer.remove_dataset(ds_id_1)
149+
with pytest.raises(ApiError.NotFound):
150+
self.viewer.datasets_ctx.get_dataset_config(ds_id_1)
151+
152+
self.viewer.remove_dataset(ds_id_2)
153+
with pytest.raises(ApiError.NotFound):
154+
self.viewer.datasets_ctx.get_dataset_config(ds_id_2)
155+
156+
def test_add_dataset_with_style(self):
157+
viewer = self.get_viewer(STYLES_CONFIG)
158+
159+
ds_id = viewer.add_dataset(
160+
new_cube(variables={"analysed_sst": 280.}),
161+
title="My SST",
162+
style="SST"
163+
)
164+
165+
ds_config = self.viewer.datasets_ctx.get_dataset_config(ds_id)
166+
self.assertEqual({"Identifier": ds_id,
167+
"Title": "My SST",
168+
"Style": "SST"},
169+
ds_config)
170+
171+
def test_add_dataset_with_color_mapping(self):
172+
viewer = self.get_viewer()
173+
174+
ds_id = viewer.add_dataset(
175+
new_cube(variables={"analysed_sst": 280.}),
176+
title="My SST",
177+
color_mappings={
178+
"analysed_sst": {
179+
"ValueRange": [280., 290.],
180+
"ColorBar": "plasma"
181+
}
182+
},
183+
)
184+
185+
ds_config = self.viewer.datasets_ctx.get_dataset_config(ds_id)
186+
self.assertEqual({"Identifier": ds_id,
187+
"Title": "My SST",
188+
"Style": ds_id},
189+
ds_config)

xcube/webapi/datasets/context.py

+19-5
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,14 @@ def set_ml_dataset(self, ml_dataset: MultiLevelDataset):
188188
dict(Identifier=ml_dataset.ds_id,
189189
Hidden=True)))
190190

191-
def add_dataset(self,
192-
dataset: Union[xr.Dataset, MultiLevelDataset],
193-
ds_id: Optional[str] = None,
194-
title: Optional[str] = None):
191+
def add_dataset(
192+
self,
193+
dataset: Union[xr.Dataset, MultiLevelDataset],
194+
ds_id: Optional[str] = None,
195+
title: Optional[str] = None,
196+
style: Optional[str] = None,
197+
color_mappings: Dict[str, Dict[str, Any]] = None
198+
):
195199
assert_instance(dataset, (xr.Dataset, MultiLevelDataset), 'dataset')
196200
if isinstance(dataset, xr.Dataset):
197201
ml_dataset = BaseMultiLevelDataset(dataset, ds_id=ds_id)
@@ -209,6 +213,12 @@ def add_dataset(self,
209213
dataset_config = dict(Identifier=ds_id,
210214
Title=title or dataset.attrs.get("title",
211215
ds_id))
216+
if style is not None:
217+
dataset_config.update(dict(Style=style))
218+
if color_mappings is not None:
219+
style = style or ds_id
220+
dataset_config.update(dict(Style=style))
221+
self._cm_styles[style] = color_mappings
212222
self._dataset_cache[ds_id] = ml_dataset, dataset_config
213223
self._dataset_configs.append(dataset_config)
214224
return ds_id
@@ -218,7 +228,11 @@ def remove_dataset(self, ds_id: str):
218228
assert_given(ds_id, 'ds_id')
219229
if ds_id in self._dataset_cache:
220230
del self._dataset_cache[ds_id]
221-
# TODO: remove from self._dataset_configs
231+
self._dataset_configs = [
232+
dc
233+
for dc in self._dataset_configs
234+
if dc["Identifier"] != ds_id
235+
]
222236

223237
def add_ml_dataset(self,
224238
ml_dataset: MultiLevelDataset,

xcube/webapi/viewer/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@
2020
# DEALINGS IN THE SOFTWARE.
2121

2222
# noinspection PyUnresolvedReferences
23+
2324
from .routes import api
2425
from .viewer import Viewer

xcube/webapi/viewer/context.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ def config_items(self) -> Optional[Mapping[str, bytes]]:
3838

3939
@cached_property
4040
def config_path(self) -> Optional[str]:
41+
if "Viewer" not in self.config:
42+
return None
4143
return self.get_config_path(
42-
self.config.get("Viewer", {}).get("Configuration", {}),
44+
self.config["Viewer"].get("Configuration", {}),
4345
"'Configuration' item of 'Viewer'"
4446
)

0 commit comments

Comments
 (0)