Skip to content

Commit af96d47

Browse files
committed
feat: Add sidebar toggle and orientation selection for map layout in MultiGeoAnimatorManager and GeoAnimatorManager
1 parent 398169c commit af96d47

2 files changed

Lines changed: 157 additions & 58 deletions

File tree

dvue/animator/multi_ui.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,13 @@ def __init__(
517517
name="Show diff (A \u2212 B)", value=show_diff,
518518
sizing_mode="stretch_width",
519519
)
520+
self._orientation_select = pn.widgets.RadioButtonGroup(
521+
name="Map layout",
522+
options=["Horizontal", "Vertical"],
523+
value="Horizontal",
524+
button_type="light",
525+
sizing_mode="stretch_width",
526+
)
520527
_transform_names = ["none"] + list(self._transform_options.keys())
521528
self._transform_select = pn.widgets.Select(
522529
name="Transform", options=_transform_names,
@@ -591,7 +598,9 @@ def __init__(
591598
)
592599
_diff_card = pn.Card(
593600
self._show_diff_check,
594-
title="Diff (A − B)", collapsed=False,
601+
pn.pane.Markdown("**Map layout**", margin=(4, 0, 2, 0)),
602+
self._orientation_select,
603+
title="Comparison", collapsed=False,
595604
sizing_mode="stretch_width",
596605
)
597606
_contour_card = pn.Card(
@@ -637,6 +646,10 @@ def __init__(
637646
sizing_mode="stretch_width",
638647
)
639648

649+
self._sidebar_toggle = pn.widgets.Toggle(
650+
name="\u25c4", value=True, button_type="light",
651+
width=32, height=32, margin=(4, 0, 4, 4),
652+
)
640653
self._controls = pn.Column(
641654
pn.pane.Markdown("### Controls", margin=(4, 0, 2, 0)),
642655
self._time_label_pane,
@@ -650,14 +663,15 @@ def __init__(
650663
_save_card,
651664
sizing_mode="stretch_width",
652665
max_width=280,
653-
margin=(4, 8, 4, 4),
666+
margin=(4, 8, 4, 0),
654667
)
655668

656669
# ----------------------------------------------------------------
657670
# 14. Maps layout placeholder
658671
# ----------------------------------------------------------------
659-
self._maps_pane = pn.Row(
672+
self._maps_pane = pn.FlexBox(
660673
self._pane_a, self._pane_b,
674+
flex_direction="row",
661675
sizing_mode="stretch_both",
662676
)
663677

@@ -681,6 +695,8 @@ def __init__(
681695
self._colormap_select.param.watch(self._on_colormap_change, "value")
682696
self._size_slider.param.watch(self._on_size_widget_change, "value")
683697
self._show_diff_check.param.watch(self._on_diff_toggle, "value")
698+
self._orientation_select.param.watch(self._on_orientation_change, "value")
699+
self._sidebar_toggle.param.watch(self._on_sidebar_toggle, "value")
684700
self._contours_check.param.watch(self._on_contours_toggle, "value")
685701
self._contour_color_check.param.watch(self._on_contour_color_toggle, "value")
686702
self._n_contours_slider.param.watch(self._on_n_contours_change, "value")
@@ -735,6 +751,8 @@ def collect_state(self) -> dict:
735751
"diff": {
736752
"show": self.show_diff,
737753
},
754+
"layout_orientation": self._orientation_select.value.lower(),
755+
"sidebar_collapsed": not self._sidebar_toggle.value,
738756
"x2": {"enabled": False, "threshold": 2700.0},
739757
}
740758
if meta.get("hydro_h5_paths") is not None:
@@ -1257,6 +1275,21 @@ def _on_diff_toggle(self, event: param.parameterized.Event) -> None:
12571275
self._update_diff_map(ts)
12581276
else:
12591277
self._maps_pane.objects = [self._pane_a, self._pane_b]
1278+
# Re-apply current orientation when returning to side-by-side
1279+
self._maps_pane.flex_direction = (
1280+
"column" if self._orientation_select.value == "Vertical" else "row"
1281+
)
1282+
1283+
def _on_orientation_change(self, event: param.parameterized.Event) -> None:
1284+
"""Switch maps between horizontal (row) and vertical (column) layout."""
1285+
self._maps_pane.flex_direction = (
1286+
"column" if event.new == "Vertical" else "row"
1287+
)
1288+
1289+
def _on_sidebar_toggle(self, event: param.parameterized.Event) -> None:
1290+
"""Collapse or expand the controls sidebar, letting the maps fill freed space."""
1291+
self._controls.visible = bool(event.new)
1292+
self._sidebar_toggle.name = "\u25c4" if event.new else "\u25ba"
12601293

12611294
# ------------------------------------------------------------------
12621295
# Callbacks — contours
@@ -1461,6 +1494,7 @@ def _apply() -> None:
14611494
def __panel__(self) -> pn.viewable.Viewable:
14621495
return pn.Column(
14631496
pn.Row(
1497+
self._sidebar_toggle,
14641498
self._controls,
14651499
self._maps_pane,
14661500
sizing_mode="stretch_both",

dvue/animator/ui.py

Lines changed: 120 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,10 @@ def __init__(
902902
sizing_mode="stretch_width",
903903
)
904904

905+
self._sidebar_toggle = pn.widgets.Toggle(
906+
name="\u25c4", value=True, button_type="light",
907+
width=32, height=32, margin=(4, 0, 4, 4),
908+
)
905909
self._controls = pn.Column(
906910
pn.pane.Markdown("### Controls", margin=(4, 0, 2, 0)),
907911
self._time_label_pane,
@@ -914,7 +918,7 @@ def __init__(
914918
_save_card,
915919
sizing_mode="stretch_width",
916920
max_width=280,
917-
margin=(4, 8, 4, 4),
921+
margin=(4, 8, 4, 0),
918922
)
919923

920924
# ----------------------------------------------------------------
@@ -950,13 +954,19 @@ def __init__(
950954
self._x2_check.param.watch(self._on_x2_toggle, "value")
951955
self._x2_threshold_input.param.watch(self._on_x2_threshold_change, "value")
952956
self._save_config_btn.on_click(self._on_save_config)
957+
self._sidebar_toggle.param.watch(self._on_sidebar_toggle, "value")
953958

954959
self.current_dt = ti[0].to_pydatetime()
955960

956961
# ------------------------------------------------------------------
957962
# Internal helpers
958963
# ------------------------------------------------------------------
959964

965+
def _on_sidebar_toggle(self, event: param.parameterized.Event) -> None:
966+
"""Collapse or expand the controls sidebar, letting the map fill freed space."""
967+
self._controls.visible = bool(event.new)
968+
self._sidebar_toggle.name = "\u25c4" if event.new else "\u25ba"
969+
960970
def _current_clim(self) -> tuple[float, float]:
961971
vmin = self.vmin if self.vmin is not None else self._reader.vmin
962972
vmax = self.vmax if self.vmax is not None else self._reader.vmax
@@ -1000,12 +1010,43 @@ def _compute_label_positions(
10001010
def _update_contour_labels(
10011011
self, xs_list: list, ys_list: list, lvls: list
10021012
) -> None:
1003-
"""Update the label source if the label renderer is visible."""
1013+
"""Update the label source if the label renderer is visible.
1014+
1015+
Must be called while holding the Bokeh document lock (i.e. inside
1016+
``doc.add_next_tick_callback`` or during normal render/update).
1017+
"""
10041018
if self._contour_label_renderer.visible:
10051019
self._contour_label_source.data = (
10061020
self._compute_label_positions(xs_list, ys_list, lvls)
10071021
)
10081022

1023+
def _rebuild_contours(self) -> None:
1024+
"""Recompute contour polygons for the current frame and apply them.
1025+
1026+
Safe to call from any Panel/param watcher callback. All Bokeh
1027+
``ColumnDataSource.data`` mutations are routed through
1028+
``doc.add_next_tick_callback`` when a live Bokeh document is
1029+
present, avoiding the ``RuntimeError: _pending_writes should be
1030+
non-None`` that occurs when Bokeh sources are mutated from inside
1031+
Panel's async event-processing coroutine (outside the doc lock).
1032+
"""
1033+
if not self._contour_renderer.visible:
1034+
return
1035+
current_values = list(self._bk_source.data["_value"])
1036+
xs, ys, lvls = self._compute_contours(current_values)
1037+
colors = self._contour_colors(lvls)
1038+
new_data = {"xs": xs, "ys": ys, "level": lvls, "color": colors}
1039+
1040+
def _apply():
1041+
self._contour_source.data = new_data
1042+
self._update_contour_labels(xs, ys, lvls)
1043+
1044+
doc = self._contour_source.document
1045+
if doc is not None:
1046+
doc.add_next_tick_callback(_apply)
1047+
else:
1048+
_apply()
1049+
10091050
def _compute_levels(
10101051
self, finite_vals: np.ndarray, vmin: float, vmax: float
10111052
) -> np.ndarray:
@@ -1369,6 +1410,7 @@ def collect_state(self) -> dict:
13691410
"clip_radius_km": self._contour_clip_slider.value,
13701411
},
13711412
"diff": {"show": False, "colormap": "coolwarm"},
1413+
"sidebar_collapsed": not self._sidebar_toggle.value,
13721414
}
13731415
if self._x2_callback is not None:
13741416
state["x2"] = {
@@ -1498,7 +1540,14 @@ def _on_contour_color_toggle(self, event: param.parameterized.Event) -> None:
14981540
lvls = self._contour_source.data.get("level", [])
14991541
if lvls:
15001542
colors = self._contour_colors(lvls)
1501-
self._contour_source.data = dict(self._contour_source.data, color=colors)
1543+
new_data = dict(self._contour_source.data, color=colors)
1544+
doc = self._contour_source.document
1545+
if doc is not None:
1546+
doc.add_next_tick_callback(
1547+
lambda: setattr(self._contour_source, "data", new_data)
1548+
)
1549+
else:
1550+
self._contour_source.data = new_data
15021551

15031552
def _on_contours_toggle(self, event: param.parameterized.Event) -> None:
15041553
"""Show or hide contours; recompute for current frame when enabling."""
@@ -1514,41 +1563,33 @@ def _on_contours_toggle(self, event: param.parameterized.Event) -> None:
15141563
self._contour_label_spacing_slider.visible = on
15151564
self._contour_clip_slider.visible = on
15161565
if on:
1517-
current_values = self._bk_source.data["_value"]
1518-
xs, ys, lvls = self._compute_contours(list(current_values))
1519-
colors = self._contour_colors(lvls)
1520-
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls, "color": colors}
1521-
self._update_contour_labels(xs, ys, lvls)
1566+
self._rebuild_contours()
15221567
else:
1523-
self._contour_source.data = {"xs": [], "ys": [], "level": [], "color": []}
1524-
self._contour_label_source.data = {"x": [], "y": [], "text": []}
1568+
empty_contours = {"xs": [], "ys": [], "level": [], "color": []}
1569+
empty_labels = {"x": [], "y": [], "text": []}
1570+
doc = self._contour_source.document
1571+
if doc is not None:
1572+
doc.add_next_tick_callback(
1573+
lambda: (
1574+
setattr(self._contour_source, "data", empty_contours)
1575+
or setattr(self._contour_label_source, "data", empty_labels)
1576+
)
1577+
)
1578+
else:
1579+
self._contour_source.data = empty_contours
1580+
self._contour_label_source.data = empty_labels
15251581

15261582
def _on_n_contours_change(self, event: param.parameterized.Event) -> None:
15271583
self.n_contours = int(event.new)
1528-
if self._contour_renderer.visible:
1529-
current_values = self._bk_source.data["_value"]
1530-
xs, ys, lvls = self._compute_contours(list(current_values))
1531-
colors = self._contour_colors(lvls)
1532-
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls, "color": colors}
1533-
self._update_contour_labels(xs, ys, lvls)
1584+
self._rebuild_contours()
15341585

15351586
def _on_contour_smooth_change(self, event: param.parameterized.Event) -> None:
15361587
self.contour_smooth = float(event.new)
1537-
if self._contour_renderer.visible:
1538-
current_values = self._bk_source.data["_value"]
1539-
xs, ys, lvls = self._compute_contours(list(current_values))
1540-
colors = self._contour_colors(lvls)
1541-
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls, "color": colors}
1542-
self._update_contour_labels(xs, ys, lvls)
1588+
self._rebuild_contours()
15431589

15441590
def _on_contour_levels_change(self, event: param.parameterized.Event) -> None:
15451591
self.contour_levels = event.new
1546-
if self._contour_renderer.visible:
1547-
current_values = self._bk_source.data["_value"]
1548-
xs, ys, lvls = self._compute_contours(list(current_values))
1549-
colors = self._contour_colors(lvls)
1550-
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls, "color": colors}
1551-
self._update_contour_labels(xs, ys, lvls)
1592+
self._rebuild_contours()
15521593

15531594
def _on_contour_custom_levels_change(self, event: param.parameterized.Event) -> None:
15541595
"""Recompute contours when the user edits the custom levels text box."""
@@ -1558,26 +1599,36 @@ def _on_contour_custom_levels_change(self, event: param.parameterized.Event) ->
15581599
auto_controls_active = not bool(event.new.strip())
15591600
self._n_contours_slider.disabled = not auto_controls_active
15601601
self._contour_levels_select.disabled = not auto_controls_active
1561-
if self._contour_renderer.visible:
1562-
current_values = self._bk_source.data["_value"]
1563-
xs, ys, lvls = self._compute_contours(list(current_values))
1564-
colors = self._contour_colors(lvls)
1565-
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls, "color": colors}
1566-
self._update_contour_labels(xs, ys, lvls)
1602+
self._rebuild_contours()
15671603

15681604
def _on_contour_labels_toggle(self, event: param.parameterized.Event) -> None:
15691605
"""Show or hide contour labels."""
1570-
self._contour_label_renderer.visible = bool(event.new)
1571-
if event.new:
1606+
show = bool(event.new)
1607+
self._contour_label_renderer.visible = show
1608+
if show:
15721609
xs = self._contour_source.data["xs"]
15731610
ys = self._contour_source.data["ys"]
15741611
lvls = self._contour_source.data["level"]
15751612
if xs:
1576-
self._contour_label_source.data = (
1577-
self._compute_label_positions(xs, ys, lvls)
1578-
)
1613+
new_label_data = self._compute_label_positions(xs, ys, lvls)
1614+
doc = self._contour_label_source.document
1615+
if doc is not None:
1616+
doc.add_next_tick_callback(
1617+
lambda: setattr(
1618+
self._contour_label_source, "data", new_label_data
1619+
)
1620+
)
1621+
else:
1622+
self._contour_label_source.data = new_label_data
15791623
else:
1580-
self._contour_label_source.data = {"x": [], "y": [], "text": []}
1624+
empty = {"x": [], "y": [], "text": []}
1625+
doc = self._contour_label_source.document
1626+
if doc is not None:
1627+
doc.add_next_tick_callback(
1628+
lambda: setattr(self._contour_label_source, "data", empty)
1629+
)
1630+
else:
1631+
self._contour_label_source.data = empty
15811632

15821633
def _on_label_spacing_change(self, event: param.parameterized.Event) -> None:
15831634
"""Re-place contour labels with the updated spacing."""
@@ -1587,9 +1638,16 @@ def _on_label_spacing_change(self, event: param.parameterized.Event) -> None:
15871638
ys = self._contour_source.data.get("ys", [])
15881639
lvls = self._contour_source.data.get("level", [])
15891640
if xs:
1590-
self._contour_label_source.data = (
1591-
self._compute_label_positions(xs, ys, lvls)
1592-
)
1641+
new_label_data = self._compute_label_positions(xs, ys, lvls)
1642+
doc = self._contour_label_source.document
1643+
if doc is not None:
1644+
doc.add_next_tick_callback(
1645+
lambda: setattr(
1646+
self._contour_label_source, "data", new_label_data
1647+
)
1648+
)
1649+
else:
1650+
self._contour_label_source.data = new_label_data
15931651

15941652
def _on_contour_clip_change(self, event: param.parameterized.Event) -> None:
15951653
"""Rebuild the contour clip zone with the new radius and recompute."""
@@ -1601,14 +1659,7 @@ def _on_contour_clip_change(self, event: param.parameterized.Event) -> None:
16011659
).buffer(buffer_m)
16021660
except Exception:
16031661
self._contour_clip_zone = None
1604-
if self._contour_renderer.visible:
1605-
current_values = self._bk_source.data["_value"]
1606-
xs, ys, lvls = self._compute_contours(list(current_values))
1607-
colors = self._contour_colors(lvls)
1608-
self._contour_source.data = {
1609-
"xs": xs, "ys": ys, "level": lvls, "color": colors
1610-
}
1611-
self._update_contour_labels(xs, ys, lvls)
1662+
self._rebuild_contours()
16121663

16131664
def _on_x2_toggle(self, event: param.parameterized.Event) -> None:
16141665
"""Show or hide the X2 isohaline line."""
@@ -1617,9 +1668,16 @@ def _on_x2_toggle(self, event: param.parameterized.Event) -> None:
16171668
if event.new and self._x2_callback is not None:
16181669
threshold = float(self._x2_threshold_input.value)
16191670
xs, ys = self._x2_callback(self._time_slider.value, threshold)
1620-
self._x2_source.data = {"xs": xs, "ys": ys}
1671+
new_data = {"xs": xs, "ys": ys}
1672+
else:
1673+
new_data = {"xs": [], "ys": []}
1674+
doc = self._x2_source.document
1675+
if doc is not None:
1676+
doc.add_next_tick_callback(
1677+
lambda: setattr(self._x2_source, "data", new_data)
1678+
)
16211679
else:
1622-
self._x2_source.data = {"xs": [], "ys": []}
1680+
self._x2_source.data = new_data
16231681

16241682
def _on_x2_threshold_change(self, event: param.parameterized.Event) -> None:
16251683
"""Recompute X2 line when the threshold value changes."""
@@ -1629,7 +1687,14 @@ def _on_x2_threshold_change(self, event: param.parameterized.Event) -> None:
16291687
except (TypeError, ValueError):
16301688
return
16311689
xs, ys = self._x2_callback(self._time_slider.value, threshold)
1632-
self._x2_source.data = {"xs": xs, "ys": ys}
1690+
new_data = {"xs": xs, "ys": ys}
1691+
doc = self._x2_source.document
1692+
if doc is not None:
1693+
doc.add_next_tick_callback(
1694+
lambda: setattr(self._x2_source, "data", new_data)
1695+
)
1696+
else:
1697+
self._x2_source.data = new_data
16331698

16341699
def _on_clim_text_change(self, event: param.parameterized.Event) -> None:
16351700
try:
@@ -1650,7 +1715,7 @@ def __panel__(self) -> pn.viewable.Viewable:
16501715
# to the chart pane, which would resize the Bokeh figure and reset the
16511716
# viewport / aspect ratio.
16521717
return pn.Column(
1653-
pn.Row(self._controls, self._chart_pane, sizing_mode="stretch_both"),
1718+
pn.Row(self._sidebar_toggle, self._controls, self._chart_pane, sizing_mode="stretch_both"),
16541719
sizing_mode="stretch_both",
16551720
min_height=self._map_height,
16561721
)

0 commit comments

Comments
 (0)