Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`ManhattanPlot::with_thin_overlapping_labels()`** — opts the Manhattan x-axis into collision-aware chromosome labelling. By default every chromosome whose band is at least 6px wide is labelled, which can overprint the labels of adjacent small chromosomes (e.g. 17/19/21) on a genome-wide plot. When enabled, labels are placed in a single left-to-right pass and any label whose estimated footprint would overlap the previously drawn one is skipped, automatically thinning crowded regions while keeping the rest readable. Works with both horizontal and rotated (`Layout::with_x_tick_rotate`) labels. Off by default; existing behaviour is unchanged.

---

## [0.2.0] — 2026-05-07
Expand Down
26 changes: 26 additions & 0 deletions src/plot/manhattan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ pub struct ManhattanPlot {
pub legend_label: Option<String>,
pub show_tooltips: bool,
pub tooltip_labels: Option<Vec<String>>,
/// When `true`, chromosome labels that would overlap a previously drawn
/// label are skipped, automatically thinning the labels of crowded small
/// chromosomes. Default: `false` (every label within a ≥6px band is drawn).
pub thin_overlapping_labels: bool,
}

/// Reference genome chromosome sizes used for cumulative x-coordinate layout.
Expand Down Expand Up @@ -297,6 +301,7 @@ impl ManhattanPlot {
legend_label: None,
show_tooltips: false,
tooltip_labels: None,
thin_overlapping_labels: false,
}
}

Expand Down Expand Up @@ -749,4 +754,25 @@ impl ManhattanPlot {
self.tooltip_labels = Some(labels.into_iter().map(|s| s.into()).collect());
self
}

/// Skip chromosome labels that would overlap the previously drawn one.
///
/// By default every chromosome whose band is at least 6px wide is labelled,
/// which can overprint the labels of adjacent small chromosomes (e.g. 17,
/// 19, 21) on a genome-wide plot. With this enabled, labels are placed in a
/// single left-to-right pass and any label whose estimated footprint would
/// collide with the last one is dropped, automatically thinning crowded
/// regions while keeping the rest readable. Works with horizontal and
/// rotated ([`Layout::with_x_tick_rotate`]) labels.
///
/// ```rust,no_run
/// # use kuva::plot::{ManhattanPlot, GenomeBuild};
/// let mp = ManhattanPlot::new()
/// .with_data_bp(vec![("1", 1_f64, 0.01_f64)], GenomeBuild::Hg38)
/// .with_thin_overlapping_labels();
/// ```
pub fn with_thin_overlapping_labels(mut self) -> Self {
self.thin_overlapping_labels = true;
self
}
}
57 changes: 43 additions & 14 deletions src/render/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7705,25 +7705,54 @@ fn add_manhattan_chr_labels(mp: &ManhattanPlot, scene: &mut Scene, computed: &Co
let plot_right = computed.width - computed.margin_right;
let label_y = computed.height - computed.margin_bottom + 5.0 + computed.tick_size as f64;
let min_label_px = 6.0_f64;
// Minimum horizontal gap (px) to keep between adjacent labels when thinning.
let label_gap = 2.0_f64;
// Right edge of the last label that was actually drawn. Spans are in genomic
// (left-to-right) order, so when `thin_overlapping_labels` is set we can
// greedily skip any label whose footprint would collide with the previous
// one — automatically thinning narrow chromosomes (e.g. 17-22). When it is
// not set the original behaviour (label every ≥6px band) is preserved.
let mut last_label_right = f64::NEG_INFINITY;
for span in &mp.spans {
let band_px = (computed.map_x(span.x_end) - computed.map_x(span.x_start)).abs();
let mid_x = computed.map_x((span.x_start + span.x_end) / 2.0);
if mid_x >= plot_left && mid_x <= plot_right && band_px >= min_label_px {
let (anchor, rotate) = match computed.x_tick_rotate {
Some(angle) => (TextAnchor::End, Some(angle)),
None => (TextAnchor::Middle, None),
if mid_x < plot_left || mid_x > plot_right || band_px < min_label_px {
continue;
}
let (anchor, rotate) = match computed.x_tick_rotate {
Some(angle) => (TextAnchor::End, Some(angle)),
None => (TextAnchor::Middle, None),
};
if mp.thin_overlapping_labels {
// Estimate the label's horizontal footprint to detect overlap. This
// stage has no font metrics, so width is approximated from the glyph
// count; rotation reduces the horizontal span by cos(angle).
let text_w = span.name.chars().count() as f64 * computed.tick_size as f64 * 0.6;
let h_extent = match rotate {
Some(angle) => text_w * angle.to_radians().cos().abs(),
None => text_w,
};
scene.add(Primitive::Text {
x: mid_x,
y: label_y,
content: span.name.clone(),
size: computed.tick_size,
anchor,
rotate,
bold: false,
color: None,
});
let (left_edge, right_edge) = match anchor {
// End-anchored (rotated) labels grow leftward from mid_x.
TextAnchor::End => (mid_x - h_extent, mid_x),
// Middle-anchored labels are centred on mid_x.
_ => (mid_x - h_extent / 2.0, mid_x + h_extent / 2.0),
};
if left_edge < last_label_right + label_gap {
continue;
}
last_label_right = right_edge;
}
scene.add(Primitive::Text {
x: mid_x,
y: label_y,
content: span.name.clone(),
size: computed.tick_size,
anchor,
rotate,
bold: false,
color: None,
});
}
}

Expand Down
123 changes: 123 additions & 0 deletions tests/manhattan_svg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,3 +508,126 @@ fn test_manhattan_pvalue_floor() {

assert!(svg.contains("<svg"));
}

// ── Chromosome label collision avoidance ─────────────────────────────────────

use kuva::render::render::Primitive;

/// Collect the chromosome-name labels (bottom row) drawn in a Manhattan scene.
/// Returns (x, content) pairs for Text primitives sitting in the bottom axis row.
fn chrom_labels(scene: &kuva::render::render::Scene) -> Vec<(f64, String)> {
let is_chrom_name = |s: &str| {
!s.is_empty()
&& (s == "X" || s == "Y" || s == "MT" || s.chars().all(|c| c.is_ascii_digit()))
};
// Chromosome labels share one y value: the bottom-most text row, drawn
// below the y=0 axis tick. Collect chrom-like Text primitives, then keep
// only those in the lowest (largest-y) row to exclude y-axis tick labels.
let candidates: Vec<(f64, f64, String)> = scene
.elements
.iter()
.filter_map(|el| match el {
Primitive::Text { x, y, content, .. } if is_chrom_name(content) => {
Some((*x, *y, content.clone()))
}
_ => None,
})
.collect();
let max_y = candidates
.iter()
.map(|(_, y, _)| *y)
.fold(f64::NEG_INFINITY, f64::max);
let mut out: Vec<(f64, String)> = candidates
.into_iter()
.filter(|(_, y, _)| (max_y - y).abs() < 1.0)
.map(|(x, _, c)| (x, c))
.collect();
out.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
out
}

// Visible chromosome bands for an hg38 plot: all autosomes plus X and Y.
// MT is in the build but its band is far too narrow (~16 kb) to ever be
// labelled, so it is never expected among the drawn labels.
const HG38_LABELLED: &[&str] = &[
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17",
"18", "19", "20", "21", "22", "X", "Y",
];

#[test]
fn test_manhattan_labels_all_drawn_by_default() {
// The thinning feature is OFF by default: at the default width every
// chromosome band wider than 6px is labelled, reproducing the original
// behaviour where adjacent small-chromosome labels can visually overlap.
let mp = ManhattanPlot::new().with_data_bp(make_gwas_bp_data(), GenomeBuild::Hg38);
let plots = vec![Plot::Manhattan(mp)];
let layout = Layout::auto_from_plots(&plots);
let scene = render_multiple(plots, layout);
let drawn: Vec<String> = chrom_labels(&scene).into_iter().map(|(_, c)| c).collect();

assert_eq!(
drawn,
HG38_LABELLED
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
"by default all autosome + X/Y labels are drawn (no thinning)"
);
}

#[test]
fn test_manhattan_labels_thinned_when_enabled() {
// With the opt-in enabled, the same default-width plot drops labels rather
// than overprinting them (regression: chr 17/19/21 used to overlap 16/22).
let mp = ManhattanPlot::new()
.with_data_bp(make_gwas_bp_data(), GenomeBuild::Hg38)
.with_thin_overlapping_labels();
let plots = vec![Plot::Manhattan(mp)];
let layout = Layout::auto_from_plots(&plots);
let scene = render_multiple(plots, layout);
let labels = chrom_labels(&scene);

assert!(
labels.len() < HG38_LABELLED.len(),
"expected some labels to be thinned on a narrow plot, drew {} of {}",
labels.len(),
HG38_LABELLED.len()
);
assert!(
!labels.is_empty(),
"at least some labels should still be drawn"
);

// Whatever labels survive must not overlap horizontally.
for pair in labels.windows(2) {
let (x0, _) = &pair[0];
let (x1, _) = &pair[1];
assert!(
x1 - x0 >= 6.0,
"adjacent chromosome labels overlap: x={} and x={}",
x0,
x1
);
}
}

#[test]
fn test_manhattan_labels_all_drawn_when_wide() {
// Even with thinning enabled, a wide plot has room for every label (MT excepted).
let mp = ManhattanPlot::new()
.with_data_bp(make_gwas_bp_data(), GenomeBuild::Hg38)
.with_thin_overlapping_labels();
let plots = vec![Plot::Manhattan(mp)];
let layout = Layout::auto_from_plots(&plots).with_width(4000.0);
let scene = render_multiple(plots, layout);
let drawn: Vec<String> = chrom_labels(&scene).into_iter().map(|(_, c)| c).collect();

assert_eq!(
drawn,
HG38_LABELLED
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
"all autosome + X/Y labels should be drawn on a wide plot"
);
}