From 6247f2b75003f323aa94f713c1058210ebd9659e Mon Sep 17 00:00:00 2001 From: = Date: Thu, 19 Mar 2026 12:47:55 -0400 Subject: [PATCH 1/3] fix (closes #780): ensure zooming behavior of column_colors and row_colors matches heatmap zoom coordinates --- dash_bio/component_factory/_clustergram.py | 91 +++++++++++++++++----- 1 file changed, 73 insertions(+), 18 deletions(-) diff --git a/dash_bio/component_factory/_clustergram.py b/dash_bio/component_factory/_clustergram.py index 8ec5f1068..fb70738c2 100644 --- a/dash_bio/component_factory/_clustergram.py +++ b/dash_bio/component_factory/_clustergram.py @@ -576,9 +576,10 @@ def figure(self, computed_traces=None): showticklabels=True, side="bottom", showline=False, - range=[min(tickvals_col) - 5, max(tickvals_col) + 5] + range=[min(tickvals_col) - 5, max(tickvals_col) + 5], # workaround for autoscale issues above; otherwise # the graph cuts off and must be scaled manually + fixedrange=False ) if len(tickvals_row) == 0: @@ -592,17 +593,20 @@ def figure(self, computed_traces=None): showticklabels=True, side="right", showline=False, + ticks="", + showgrid=False, + fixedrange=False ) # hide labels, if necessary for label in self._hidden_labels: fig["layout"][label].update(ticks="", showticklabels=False) - row_colors_heatmap = self._get_row_colors_heatmap() + row_colors_heatmap = self._get_row_colors_heatmap(tickvals_row) if row_colors_heatmap is not None: - fig.append_trace(self._get_row_colors_heatmap(), 3, 2) + fig.append_trace(row_colors_heatmap, 3, 2) - col_colors_heatmap = self._get_column_colors_heatmap() + col_colors_heatmap = self._get_column_colors_heatmap(tickvals_col) if col_colors_heatmap is not None: fig.append_trace(col_colors_heatmap, 2, 3) @@ -712,6 +716,43 @@ def figure(self, computed_traces=None): domain=[0, 1 - col_ratio - col_colors_ratio] ) + # Link color heatmap axes to main heatmap for zoom synchronization + # Using 'matches' to ensure the same coordinate system + # Row colors (yaxis10) matches main heatmap y-axis (yaxis11) + if len(tickvals_row) > 0: + fig["layout"]["yaxis10"].update( + matches="y11", + range=[min(tickvals_row), max(tickvals_row)], + showticklabels=False, + ticks="", + showgrid=False, + tickmode="array", + tickvals=[], + ticktext=[] + ) + # Similar setup for column colors: (xaxis7 and xaxis6) match main heatmap x-axis (xaxis11) + if len(tickvals_col) > 0: + fig["layout"]["xaxis7"].update( + matches="x11", + range=[min(tickvals_col), max(tickvals_col)], + showticklabels=False, + ticks="", + showgrid=False, + tickmode="array", + tickvals=[], + ticktext=[] + ) + fig["layout"]["xaxis6"].update( + matches="x11", + range=[min(tickvals_col), max(tickvals_col)], + showticklabels=False, + ticks="", + showgrid=False, + tickmode="array", + tickvals=[], + ticktext=[] + ) + fig["layout"][ "legend" ] = dict( # pylint: disable=unsupported-assignment-operation @@ -833,7 +874,7 @@ def _get_clusters(self): return (Zcol, Zrow) - def _get_row_colors_heatmap(self): + def _get_row_colors_heatmap(self, tickvals_row=None): colors = self._row_colors if colors is None: @@ -854,14 +895,21 @@ def _get_row_colors_heatmap(self): z = [[i] for i in range(len(colors))] - return go.Heatmap( - z=z, - colorscale=colorscale, - colorbar={"xpad": 100}, - showscale=False - ) + heatmap_kwargs = { + "z": z, + "colorscale": colorscale, + "colorbar": {"xpad": 100}, + "showscale": False + } + + # Use the same y-coordinates as the main heatmap for proper + # zoom synchronization + if tickvals_row is not None: + heatmap_kwargs["y"] = tickvals_row - def _get_column_colors_heatmap(self): + return go.Heatmap(**heatmap_kwargs) + + def _get_column_colors_heatmap(self, tickvals_col=None): colors = self._column_colors if colors is None: @@ -882,12 +930,19 @@ def _get_column_colors_heatmap(self): z = [[i * 5 for i in range(len(colors))]] - return go.Heatmap( - z=z, - colorscale=colorscale, - colorbar={"xpad": 100}, - showscale=False - ) + heatmap_kwargs = { + "z": z, + "colorscale": colorscale, + "colorbar": {"xpad": 100}, + "showscale": False + } + + # Use the same x-coordinates as the main heatmap for proper + # zoom synchronization + if tickvals_col is not None: + heatmap_kwargs["x"] = tickvals_col + + return go.Heatmap(**heatmap_kwargs) def _compute_clustered_data(self): """Get the traces that need to be plotted for the row and column From 8e98682251a7605b687f98ab25ce82e65c1b0dc5 Mon Sep 17 00:00:00 2001 From: = Date: Thu, 19 Mar 2026 15:54:46 -0400 Subject: [PATCH 2/3] fix (closes #778): ensure that heatmap and dendrograms zoom in sync by matching yaxis9 and xaxis3 --- dash_bio/component_factory/_clustergram.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dash_bio/component_factory/_clustergram.py b/dash_bio/component_factory/_clustergram.py index fb70738c2..6e35238d5 100644 --- a/dash_bio/component_factory/_clustergram.py +++ b/dash_bio/component_factory/_clustergram.py @@ -558,11 +558,11 @@ def figure(self, computed_traces=None): col_dendro_traces_max_y = np.concatenate(col_dendro_traces_y).max() # ensure that everything is aligned properly - # with the heatmap + # with the heatmap and dendrograms zoom synchronously yaxis9 = fig["layout"]["yaxis9"] # pylint: disable=invalid-sequence-index - yaxis9.update(scaleanchor="y11") + yaxis9.update(scaleanchor="y11", matches="y11") xaxis3 = fig["layout"]["xaxis3"] # pylint: disable=invalid-sequence-index - xaxis3.update(scaleanchor="x11") + xaxis3.update(scaleanchor="x11", matches="x11") if len(tickvals_col) == 0: tickvals_col = [10 * i + 5 for i in range(len(self._column_ids))] From 860f107936b5d68c517d100f0efa5eb30668d6bc Mon Sep 17 00:00:00 2001 From: = Date: Mon, 6 Apr 2026 11:22:26 -0400 Subject: [PATCH 3/3] refactor: address code review feedback for clustergram zoom fixes --- dash_bio/component_factory/_clustergram.py | 27 ++++------------------ 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/dash_bio/component_factory/_clustergram.py b/dash_bio/component_factory/_clustergram.py index 6e35238d5..072444311 100644 --- a/dash_bio/component_factory/_clustergram.py +++ b/dash_bio/component_factory/_clustergram.py @@ -560,9 +560,9 @@ def figure(self, computed_traces=None): # ensure that everything is aligned properly # with the heatmap and dendrograms zoom synchronously yaxis9 = fig["layout"]["yaxis9"] # pylint: disable=invalid-sequence-index - yaxis9.update(scaleanchor="y11", matches="y11") + yaxis9.update(matches="y11") xaxis3 = fig["layout"]["xaxis3"] # pylint: disable=invalid-sequence-index - xaxis3.update(scaleanchor="x11", matches="x11") + xaxis3.update(matches="x11") if len(tickvals_col) == 0: tickvals_col = [10 * i + 5 for i in range(len(self._column_ids))] @@ -592,10 +592,7 @@ def figure(self, computed_traces=None): tickfont=self._tick_font, showticklabels=True, side="right", - showline=False, - ticks="", - showgrid=False, - fixedrange=False + showline=False ) # hide labels, if necessary @@ -723,31 +720,15 @@ def figure(self, computed_traces=None): fig["layout"]["yaxis10"].update( matches="y11", range=[min(tickvals_row), max(tickvals_row)], - showticklabels=False, - ticks="", - showgrid=False, tickmode="array", tickvals=[], ticktext=[] ) - # Similar setup for column colors: (xaxis7 and xaxis6) match main heatmap x-axis (xaxis11) + # Similar setup for column colors: xaxis7 matches main heatmap x-axis (xaxis11) if len(tickvals_col) > 0: fig["layout"]["xaxis7"].update( matches="x11", range=[min(tickvals_col), max(tickvals_col)], - showticklabels=False, - ticks="", - showgrid=False, - tickmode="array", - tickvals=[], - ticktext=[] - ) - fig["layout"]["xaxis6"].update( - matches="x11", - range=[min(tickvals_col), max(tickvals_col)], - showticklabels=False, - ticks="", - showgrid=False, tickmode="array", tickvals=[], ticktext=[]