Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Fix heatmap axis labels not updating when toggling clustering#3487

Merged
ewels merged 4 commits into
MultiQC:mainfrom
nictru:heatmap-clustering
Mar 12, 2026
Merged

Fix heatmap axis labels not updating when toggling clustering#3487
ewels merged 4 commits into
MultiQC:mainfrom
nictru:heatmap-clustering

Conversation

@nictru

@nictru nictru commented Feb 17, 2026

Copy link
Copy Markdown
Contributor

Attention: The changes, as well as the description below have been heavily LLM-generated. To me the changes seem valid, but I am not familiar with the MultiQC codebase.

Summary

  • Fix a bug where heatmap axis labels become mismatched with data when toggling between "Sorted by sample" and "Clustered" views
  • Add Python-side tests validating that clustered data maintains label-data consistency

Problem

When a heatmap supports clustering, toggling to the "Clustered" view reorders the data matrix (rows, xcats, ycats) correctly, but the axis tick labels remain in the original unclustered order. This causes the wrong label to appear next to the wrong row/column.

The root cause is in heatmap.py (HeatmapPlot.create), where the Plotly layout's tickmode is set to "array" with ticktext fixed to the original category order. When the JavaScript-side clustering toggle swaps the trace data to the clustered versions, the layout's ticktext is never updated to match, so Plotly renders stale labels at each position.

Fix

In buildTraces() (in both default and original template heatmap JS), sync the layout's ticktext and tickvals to the current category order before rendering. This runs before recalculateTicks, so downstream tick subsampling and highlighting still operate on the correct labels.

if (this.layout.yaxis.tickmode === "array") {
  this.layout.yaxis.ticktext = ycats;
  this.layout.yaxis.tickvals = [...Array(ycats.length).keys()];
}
if (this.layout.xaxis.tickmode === "array") {
  this.layout.xaxis.ticktext = xcats;
  this.layout.xaxis.tickvals = [...Array(xcats.length).keys()];
}

Alternative considered

A more "root-cause" fix would be to remove the tickmode: "array" / ticktext override from the Python side entirely, and rely on Plotly's type: "category" axis to derive labels automatically from the trace's x/y values. This would eliminate the coupling between layout and data ordering altogether.

We chose the JS-side fix instead because:

  1. Lower risk. The Python-side tickmode: "array" path is also used by recalculateTicks() to control tick density on large heatmaps, and by the toolbox for sample highlighting. Removing it could introduce regressions in those features.
  2. Minimal scope. The fix is 8 lines in a single method, easy to review, and doesn't change the data flow between Python and JavaScript.
  3. Correct by construction. prepData() already resolves the current category order (clustered or unclustered, with toolbox filtering applied). The fix simply ensures the layout stays in sync with that resolved state on every render.

@nictru

nictru commented Feb 24, 2026

Copy link
Copy Markdown
Contributor Author

/review

@nictru

nictru commented Mar 11, 2026

Copy link
Copy Markdown
Contributor Author

@ewels could you have a look at this? Or maybe tell Claude to check? It's annoying me more and more :(

@ewels

ewels commented Mar 11, 2026

Copy link
Copy Markdown
Member

Ah well spotted! Not sure why your request didn't work:

/review

PR looks simple enough though - will make sure it gets into the next release 👍🏻

Thanks!

@ewels ewels added this to the v1.34 milestone Mar 11, 2026
@ewels ewels added bug: core Bug in the main MultiQC code core: front end priority: high labels Mar 11, 2026
@claude

claude Bot commented Mar 11, 2026

Copy link
Copy Markdown

This PR fixes a real bug where clustering a heatmap leaves axis tick labels stale (still in unclustered order) after toggling views. The JS fix is minimal and well-targeted, and the PR description clearly explains the tradeoff between a JS-side and Python-side fix.

Details

Code Review

JS Fix (heatmap.js — both default and original templates)

The fix is correct. prepData() already resolves the current category order (clustering + toolbox filtering), so syncing ticktext/tickvals from those resolved values before recalculateTicks() runs is the right approach. The ordering matters here — the tick sync runs first, then recalculateTicks() may further downsample for dense heatmaps, which is consistent with how ticks worked before.

if (this.layout.yaxis.tickmode === "array") {
  this.layout.yaxis.ticktext = ycats;
  this.layout.yaxis.tickvals = [...Array(ycats.length).keys()];
}

Minor: tickmode guard is necessary but creates a subtle gap. When the heatmap is large (Python skips setting tickmode: "array" because y_px_per_elem <= 12 or x_px_per_elem <= 18), the fix doesn't apply. In that case, Plotly uses auto ticks derived from the y values passed to the trace, which prepData() already sets correctly — so this case likely works fine already. Worth a comment to document this constraint.

Duplication across two templates: The identical fix is applied to both default/src/js/plots/heatmap.js and original/assets/js/plots/heatmap.js. This is correct and necessary, but it means future changes need to be kept in sync across both files.


Python Tests (tests/test_plots.py)

The tests are well-structured and cover the four meaningful clustering configurations (both, neither, rows-only, cols-only). The key invariant test is particularly good:

assert ds["rows_clustered"][ci][cj] == data[orig_row_idx][orig_col_idx]

However, the tests don't cover the actual bug being fixed. The Python-side clustering data (rows_clustered, xcats_clustered, ycats_clustered) is computed at report generation time and validated correctly here. But the bug is in buildTraces() — a JS function that syncs layout ticks at render time. That code path is not exercised by these Python tests. The tests validate that the data flowing into the JS is correct, not that the JS uses it correctly for tick labels.

This is a common limitation when the bug is interactive/browser-side, but it's worth acknowledging: if the JS tick-sync code were accidentally reverted, these tests would still pass.

Test isolation concern (pre-existing issue, not introduced here): _verify_rendered asserts len(report.plot_data) == 1, which would fail if a prior test left data in report.plot_data. The new tests use unique id values ("heatmap_cluster", "heatmap_no_cluster", etc.) which helps with the second assertion but not the len == 1 check. Since the existing test_heatmap presumably passes in CI, there must be state reset happening somewhere — but it's fragile and worth investigating.


Summary

Assessment
Bug validity Confirmed — ticktext was never updated on clustering toggle
JS fix correctness Correct — runs before recalculateTicks, covers the right code path
Python test coverage Validates data structure, but not the JS tick-sync logic
Risk of regression Low — the fix only mutates layout when tickmode === "array", leaving other code paths unchanged

Suggested improvement: A comment in buildTraces() explaining why the tick update only applies when tickmode === "array" (large heatmaps use auto-ticks derived from trace data, which are already correct) would help future readers.

"

@ewels ewels left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks!

@ewels ewels merged commit 3b3f361 into MultiQC:main Mar 12, 2026
9 checks passed
@nictru nictru deleted the heatmap-clustering branch March 12, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants