Skip to content

Commit b2855e1

Browse files
authored
explore: allow overlaying map layers or showing maps side-by-side (#183)
* add geopandas to the dev deps * implement composable maps * use the composable maps by default * avoid modifying the existing map * try to control styling * get the grid layout to actually work * also wrap bare maps in `MapsWithSliders` (needs a new name) * make sure we have sliders before creating the hbox * get the typing right * support a grid of maps * add `pyinstrument` to the deps * allow synchronizing the maps * refactor to make changing the list of maps shorter * simplify merge using duck typing * make `merge` private * add a "add_layer" method as a imperative API version * move the import of `BaseLayer` out of the typing imports * bump lock file * revert returning the sliders wrapper for a bare map * tests for `MapWithSliders` * typing * link the maps in java script * avoid the infinite loop of events * synchronize widgets when using the `|` notation * fix the slider merging * changelog * demonstrate the new capabilities in the tutorials
1 parent 28bc5f2 commit b2855e1

File tree

7 files changed

+1401
-50
lines changed

7 files changed

+1401
-50
lines changed

docs/changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## 0.3.1 (_unreleased_)
44

5+
- support interactive facet plots and combining maps ({pull}`183`)
6+
57
## 0.3.0 (2025-09-26)
68

79
### New features

docs/tutorials/h3.ipynb

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@
183183
"\n",
184184
"We can quickly visualize the data using {py:meth}`xarray.DataArray.dggs.explore`, which is powered by [lonboard](https://github.com/developmentseed/lonboard).\n",
185185
"\n",
186-
"```{warning}\n",
187-
"This is currently restricted to 1D `DataArray` objects, so we need to select a single timestep.\n",
186+
"```{note}\n",
187+
"The slider requires a running kernel, so this won't work in static documentation.\n",
188188
"```"
189189
]
190190
},
@@ -195,7 +195,27 @@
195195
"metadata": {},
196196
"outputs": [],
197197
"source": [
198-
"ds[\"air\"].isel(time=15).dggs.explore()"
198+
"ds[\"air\"].dggs.explore()"
199+
]
200+
},
201+
{
202+
"cell_type": "markdown",
203+
"id": "16",
204+
"metadata": {},
205+
"source": [
206+
"We can also combine multiple maps:"
207+
]
208+
},
209+
{
210+
"cell_type": "code",
211+
"execution_count": null,
212+
"id": "17",
213+
"metadata": {},
214+
"outputs": [],
215+
"source": [
216+
"ds[\"air\"].dggs.explore(alpha=0.8, cmap=\"viridis\") | ds[\"air\"].dggs.explore(\n",
217+
" alpha=0.8, cmap=\"coolwarm\", center=273.15\n",
218+
")"
199219
]
200220
}
201221
],
@@ -210,7 +230,7 @@
210230
"name": "python",
211231
"nbconvert_exporter": "python",
212232
"pygments_lexer": "ipython3",
213-
"version": "3.12.7"
233+
"version": "3.13.7"
214234
}
215235
},
216236
"nbformat": 4,

docs/tutorials/healpix.ipynb

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,8 @@
202202
"\n",
203203
"We can quickly visualize the data using {py:meth}`xarray.DataArray.dggs.explore`, which is powered by [lonboard](https://github.com/developmentseed/lonboard).\n",
204204
"\n",
205-
"```{warning}\n",
206-
"This is currently restricted to 1D `DataArray` objects, so we need to select a single timestep.\n",
205+
"```{note}\n",
206+
"The slider requires a running kernel, so this won't work in static documentation.\n",
207207
"```"
208208
]
209209
},
@@ -214,7 +214,27 @@
214214
"metadata": {},
215215
"outputs": [],
216216
"source": [
217-
"ds[\"air\"].isel(time=15).dggs.explore()"
217+
"ds[\"air\"].dggs.explore()"
218+
]
219+
},
220+
{
221+
"cell_type": "markdown",
222+
"id": "17",
223+
"metadata": {},
224+
"source": [
225+
"We can also combine multiple maps:"
226+
]
227+
},
228+
{
229+
"cell_type": "code",
230+
"execution_count": null,
231+
"id": "18",
232+
"metadata": {},
233+
"outputs": [],
234+
"source": [
235+
"ds[\"air\"].dggs.explore(alpha=0.8, cmap=\"viridis\") | ds[\"air\"].dggs.explore(\n",
236+
" alpha=0.8, cmap=\"coolwarm\", center=273.15\n",
237+
")"
218238
]
219239
}
220240
],
@@ -229,7 +249,7 @@
229249
"name": "python",
230250
"nbconvert_exporter": "python",
231251
"pygments_lexer": "ipython3",
232-
"version": "3.11.6"
252+
"version": "3.13.7"
233253
}
234254
},
235255
"nbformat": 4,

pixi.lock

Lines changed: 1211 additions & 40 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,8 @@ jupyterlab = "*"
215215
jupyter-resource-usage = "*"
216216
jupyterlab_code_formatter = "*"
217217
python-build = ">=1.3.0,<2"
218+
geopandas = ">=1.1.1,<2"
219+
pyinstrument = ">=5.1.1,<6"
218220

219221
[tool.pixi.environments]
220222
nightly = { features = ["tests", "nightly"], no-default-feature = true }

xdggs/plotting.py

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
from __future__ import annotations
2+
13
from dataclasses import dataclass
24
from functools import partial
35
from typing import Any
46

57
import ipywidgets
68
import numpy as np
79
import xarray as xr
8-
from lonboard import Map
10+
from lonboard import BaseLayer, Map
911

1012

1113
def on_slider_change(change, container):
@@ -39,7 +41,115 @@ def render(self):
3941
# add any additional control widgets here
4042
control_box = ipywidgets.HBox([self.dimension_sliders])
4143

42-
return ipywidgets.VBox([self.map, control_box])
44+
return MapWithSliders(
45+
[self.map, control_box], layout=ipywidgets.Layout(width="100%")
46+
)
47+
48+
49+
def extract_maps(obj: MapGrid | MapWithSliders | Map):
50+
if isinstance(obj, Map):
51+
return obj
52+
53+
return getattr(obj, "maps", (obj.map,))
54+
55+
56+
class MapGrid(ipywidgets.GridBox):
57+
def __init__(
58+
self,
59+
maps: MapWithSliders | Map = None,
60+
n_columns: int = 2,
61+
synchronize: bool = False,
62+
):
63+
self.n_columns = n_columns
64+
self.synchronize = synchronize
65+
66+
column_width = 100 // n_columns
67+
layout = ipywidgets.Layout(
68+
width="100%", grid_template_columns=f"repeat({n_columns}, {column_width}%)"
69+
)
70+
71+
if maps is None:
72+
maps = []
73+
74+
if synchronize and maps:
75+
all_maps = [getattr(m, "map", m) for m in maps]
76+
77+
first = all_maps[0]
78+
for second in all_maps[1:]:
79+
ipywidgets.jslink((first, "view_state"), (second, "view_state"))
80+
81+
super().__init__(maps, layout=layout)
82+
83+
def _replace_maps(self, maps):
84+
return type(self)(maps, n_columns=self.n_columns, synchronize=self.synchronize)
85+
86+
def add_map(self, map_: MapWithSliders | Map):
87+
return self._replace_maps(self.maps + (map_,))
88+
89+
@property
90+
def maps(self):
91+
return self.children
92+
93+
def __or__(self, other: MapGrid | MapWithSliders | Map):
94+
other_maps = extract_maps(other)
95+
96+
return self._replace_maps(self.maps + other_maps)
97+
98+
def __ror__(self, other: MapWithSliders | Map):
99+
other_maps = extract_maps(other)
100+
101+
return self._replace_maps(self.maps + other_maps)
102+
103+
104+
class MapWithSliders(ipywidgets.VBox):
105+
def change_layout(self, layout):
106+
return type(self)(self.children, layout=layout)
107+
108+
@property
109+
def sliders(self) -> list:
110+
return list(self.children[1:]) if len(self.children) > 1 else []
111+
112+
@property
113+
def map(self) -> Map:
114+
return self.children[0]
115+
116+
@property
117+
def layers(self) -> list[BaseLayer]:
118+
return self.map.layers
119+
120+
def __or__(self, other: MapWithSliders | Map):
121+
[other_map] = extract_maps(other)
122+
123+
return MapGrid([self, other], synchronize=True)
124+
125+
def _merge(self, layers, sliders):
126+
all_layers = list(self.map.layers) + list(layers)
127+
new_map = Map(all_layers)
128+
129+
slider_widgets = []
130+
if self.sliders:
131+
slider_widgets.extend(self.sliders)
132+
if sliders:
133+
slider_widgets.extend(sliders)
134+
135+
widgets = [new_map]
136+
if slider_widgets:
137+
widgets.append(ipywidgets.HBox(slider_widgets))
138+
139+
return type(self)(widgets, layout=self.layout)
140+
141+
def add_layer(self, layer: BaseLayer):
142+
self.map.add_layer(layer)
143+
144+
def __and__(self, other: MapWithSliders | Map | BaseLayer):
145+
if isinstance(other, BaseLayer):
146+
layers = [other]
147+
sliders = []
148+
else:
149+
layers = other.layers
150+
sliders = getattr(other, "sliders", [])
151+
152+
return self._merge(layers, sliders)
43153

44154

45155
def create_arrow_table(polygons, arr, coords=None):

xdggs/tests/test_plotting.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,29 @@ def test_explore(arr, expected_type):
239239
actual = arr.dggs.explore()
240240

241241
assert isinstance(actual, expected_type)
242+
243+
244+
class TestMapWithSliders:
245+
@pytest.mark.parametrize(
246+
["sliders", "expected"],
247+
(
248+
pytest.param([ipywidgets.VBox()], [ipywidgets.VBox()], id="sliders"),
249+
pytest.param([], [], id="empty"),
250+
),
251+
)
252+
def test_sliders(self, sliders, expected) -> None:
253+
map_ = plotting.MapWithSliders([lonboard.Map(layers=[]), *sliders])
254+
255+
assert map_.sliders == expected or isinstance(map_.sliders[0], ipywidgets.VBox)
256+
257+
def test_map(self):
258+
base_map = lonboard.Map(layers=[])
259+
wrapped_map = plotting.MapWithSliders([base_map, ipywidgets.HBox()])
260+
261+
assert wrapped_map.map is base_map
262+
263+
def test_layers(self):
264+
base_map = lonboard.Map(layers=[])
265+
wrapped_map = plotting.MapWithSliders([base_map, ipywidgets.HBox()])
266+
267+
assert wrapped_map.layers == base_map.layers

0 commit comments

Comments
 (0)