diff --git a/CHANGELOG.md b/CHANGELOG.md index f283140..c31473b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/plot/manhattan.rs b/src/plot/manhattan.rs index 8d0e65f..d30dff6 100644 --- a/src/plot/manhattan.rs +++ b/src/plot/manhattan.rs @@ -113,6 +113,10 @@ pub struct ManhattanPlot { pub legend_label: Option, pub show_tooltips: bool, pub tooltip_labels: Option>, + /// 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. @@ -297,6 +301,7 @@ impl ManhattanPlot { legend_label: None, show_tooltips: false, tooltip_labels: None, + thin_overlapping_labels: false, } } @@ -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 + } } diff --git a/src/render/render.rs b/src/render/render.rs index 649efe4..a6b1f0e 100644 --- a/src/render/render.rs +++ b/src/render/render.rs @@ -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, + }); } } diff --git a/tests/manhattan_svg.rs b/tests/manhattan_svg.rs index eca897d..ddae993 100644 --- a/tests/manhattan_svg.rs +++ b/tests/manhattan_svg.rs @@ -508,3 +508,126 @@ fn test_manhattan_pvalue_floor() { assert!(svg.contains(" 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 = chrom_labels(&scene).into_iter().map(|(_, c)| c).collect(); + + assert_eq!( + drawn, + HG38_LABELLED + .iter() + .map(|s| s.to_string()) + .collect::>(), + "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 = chrom_labels(&scene).into_iter().map(|(_, c)| c).collect(); + + assert_eq!( + drawn, + HG38_LABELLED + .iter() + .map(|s| s.to_string()) + .collect::>(), + "all autosome + X/Y labels should be drawn on a wide plot" + ); +}