@@ -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