22
33from __future__ import annotations
44
5+ import asyncio
56from typing import TYPE_CHECKING , TypedDict
67
78import anndata as ad
@@ -61,8 +62,12 @@ def create_clustermap_plot(
6162 if hasattr (x , "toarray" ):
6263 x = x .toarray ()
6364
65+ # Take var_names from the same space as x: adata.raw has a different
66+ # (usually larger) gene set than adata.var, so mixing them makes the
67+ # variance-ranked indices overrun adata.var_names.
68+ var_names = adata .raw .var_names if use_raw else adata .var_names
69+
6470 # Filter genes if max_genes is specified
65- var_names = adata .var_names
6671 if max_genes is not None and len (var_names ) > max_genes :
6772 gene_vars = np .var (x , axis = 0 )
6873 top_gene_indices = np .argsort (gene_vars )[- max_genes :]
@@ -97,10 +102,12 @@ def create_clustermap_plot(
97102 "show_grid" : False ,
98103 "tools" : ["hover" ],
99104 "colorbar" : True ,
105+ "responsive" : True ,
100106 }
101107
102108 return clustered_plot .opts (
103- hv .opts .HeatMap (** main_plot_opts ), hv .opts .Dendrogram (xaxis = None , yaxis = None )
109+ hv .opts .HeatMap (** main_plot_opts ),
110+ hv .opts .Dendrogram (xaxis = None , yaxis = None , responsive = True ),
104111 )
105112
106113
@@ -146,12 +153,19 @@ def __init__(self, adata: AnnData | None = None, **params: object) -> None:
146153 """Initialize the ClusterMap with the given parameters."""
147154 if adata is not None :
148155 params ["adata" ] = adata
149- super ().__init__ (** params )
150156
151- # Check if raw data exists
152- has_raw = self .adata .raw is not None
153- if self .use_raw is None :
154- self .use_raw = has_raw
157+ # Resolve use_raw's default (if not given, or given as None) before
158+ # calling super().__init__ so it's set as part of construction rather
159+ # than as a later attribute assignment. A later assignment would fire
160+ # the async _update_plot watcher below, which relies on a running
161+ # event loop to schedule -- not guaranteed outside of a live Panel
162+ # session (e.g. plain scripts and tests), where it has been observed
163+ # to leak an event loop/socket.
164+ has_raw = adata is not None and adata .raw is not None
165+ if params .get ("use_raw" ) is None :
166+ params ["use_raw" ] = has_raw
167+
168+ super ().__init__ (** params )
155169
156170 use_raw_widget = pmui .widgets .Checkbox .from_param (
157171 self .param .use_raw ,
@@ -160,6 +174,12 @@ def __init__(self, adata: AnnData | None = None, **params: object) -> None:
160174 disabled = not has_raw , # Disable if no raw data
161175 )
162176
177+ # Shown immediately; later recomputes (triggered by widget changes,
178+ # which only ever happen inside a running Panel session) go through
179+ # the async _update_plot watcher, which swaps in the finished plot or
180+ # an error message behind a loading indicator.
181+ self ._plot_placeholder = pn .pane .Placeholder (width = 300 , height = 300 )
182+
163183 self ._widgets = pmui .Column (
164184 pmui .widgets .Select .from_param (
165185 self .param .cmap ,
@@ -175,29 +195,49 @@ def __init__(self, adata: AnnData | None = None, **params: object) -> None:
175195 visible = self .param .show_widgets ,
176196 sx = {"border" : 1 , "borderColor" : "#e3e3e3" , "borderRadius" : 1 },
177197 sizing_mode = "stretch_width" ,
178- max_width = 400 ,
198+ max_width = 300 ,
179199 )
180200
181- @param .depends ("use_raw" , "cmap" , "max_genes" , "plot_opts" )
182- def _plot_view (self ) -> pn .viewable .Viewable :
183- """Create the plot view with parameter dependencies."""
201+ # Build the first plot directly and synchronously; see the use_raw
202+ # comment above for why this doesn't go through _update_plot.
203+ self ._plot_placeholder .object = self ._build_plot_or_error ()
204+
205+ def _build_plot_or_error (self ) -> pn .viewable .Viewable :
206+ """Build the clustermap pane, or an Alert describing why it failed."""
184207 config = ClusterMapConfig (
185208 cmap = self .cmap ,
186209 )
187-
188- plot = create_clustermap_plot (
189- self .adata ,
190- use_raw = self .use_raw ,
191- max_genes = self .max_genes ,
192- ** config ,
193- )
194-
195- # Apply user-provided plot options to the HeatMap only
196- if self .plot_opts :
197- plot = plot .opts (hv .opts .HeatMap (** self .plot_opts ))
198-
199- return plot
210+ try :
211+ plot = create_clustermap_plot (
212+ self .adata ,
213+ use_raw = self .use_raw ,
214+ max_genes = self .max_genes ,
215+ ** config ,
216+ )
217+ if self .plot_opts :
218+ plot = plot .opts (hv .opts .HeatMap (** self .plot_opts ))
219+ return pn .pane .HoloViews (
220+ plot , sizing_mode = "stretch_both" , min_height = 550 , min_width = 450
221+ )
222+ except Exception as e : # noqa: BLE001
223+ msg = f"Could not render clustermap: { e } "
224+ if pn .state .notifications is not None :
225+ pn .state .notifications .error (msg )
226+ return pn .pane .Alert (msg , alert_type = "danger" , sizing_mode = "stretch_width" )
227+
228+ @param .depends ("use_raw" , "cmap" , "max_genes" , "plot_opts" , watch = True )
229+ async def _update_plot (self ) -> None :
230+ """Recompute the clustermap and swap it into the placeholder."""
231+ # Hold the loading overlay across the whole update, including the object
232+ # assignment, so it doesn't vanish while the plot is still being built
233+ # and serialized. Yield first so it paints, and run the slow clustering
234+ # off the event loop.
235+ with self ._plot_placeholder .param .update (loading = True ):
236+ await asyncio .sleep (0.01 )
237+ self ._plot_placeholder .object = await asyncio .to_thread (
238+ self ._build_plot_or_error
239+ )
200240
201241 def __panel__ (self ) -> pn .viewable .Viewable :
202242 """Create the Panel application layout."""
203- return pmui .Row (self ._widgets , self ._plot_view )
243+ return pmui .Row (self ._widgets , self ._plot_placeholder )
0 commit comments