From 494a7dbb5c0fe57d819b83f1981d62e15d4b14d4 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 30 Oct 2026 00:54:56 +0100 Subject: [PATCH] Keep clustered hairline bars of each series in their own strip of the pixel column Co-Authored-By: Claude Sonnet 5.5 --- crates/chart/src/render/cartesian.rs | 32 ++++++++++++++++++++------ crates/chart/src/tests/budget.rs | 34 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs index 78774b078..1d643c21f 100644 --- a/crates/chart/src/render/cartesian.rs +++ b/crates/chart/src/render/cartesian.rs @@ -59,24 +59,32 @@ struct BarRun { a1: f32, lo: f32, hi: f32, + /// More than one bar was merged. + merged: bool, } impl BarRun { fn new(pos: f32, lo: f32, hi: f32) -> Self { - BarRun { bucket: pos.floor(), a0: pos, a1: pos, lo, hi } + BarRun { bucket: pos.floor(), a0: pos, a1: pos, lo, hi, merged: false } } fn add(&mut self, pos: f32, lo: f32, hi: f32) { + self.merged = true; self.a0 = self.a0.min(pos); self.a1 = self.a1.max(pos); self.lo = self.lo.min(lo); self.hi = self.hi.max(hi); } - fn draw(&self, ctx: &mut Ctx, bw: f32, horizontal: bool, color: Rgba) { - let along = self.a1 - self.a0 + bw; + /// `band` is the part of the pixel column reserved for this series (`start`, `len`): in a + /// clustered chart a merged run stays inside it, so the other series keep a visible strip. + fn draw(&self, ctx: &mut Ctx, bw: f32, horizontal: bool, color: Rgba, band: Option<(f32, f32)>) { + let (a0, along) = match band { + Some((start, len)) if self.merged => (self.bucket + start, len), + _ => (self.a0, self.a1 - self.a0 + bw), + }; if horizontal { - ctx.rect(self.lo, self.a0, self.hi - self.lo, along, color, None); + ctx.rect(self.lo, a0, self.hi - self.lo, along, color, None); } else { - ctx.rect(self.a0, self.lo, along, self.hi - self.lo, color, None); + ctx.rect(a0, self.lo, along, self.hi - self.lo, color, None); } } } @@ -105,10 +113,19 @@ impl BarSink { fn pos(&self, c: f32) -> f32 { if self.horizontal { c + self.nslots * self.bw / 2.0 - (self.k + 1.0) * self.bw } else { c - self.nslots * self.bw / 2.0 + self.k * self.bw } } + /// This series' strip of a pixel column (offset, length) when the columns hold several + /// clustered series, in the direction the slots run. + fn band(&self) -> Option<(f32, f32)> { + (self.nslots > 1.0).then(|| { + let idx = if self.horizontal { self.nslots - 1.0 - self.k } else { self.k }; + (idx / self.nslots, 1.0 / self.nslots) + }) + } /// Draws the bar at category center `c` spanning `lo..lo + len` on the value axis, or, when /// `merge` is set, adds it to the current run. fn bar(&mut self, ctx: &mut Ctx, merge: bool, c: f32, lo: f32, len: f32) { let pos = self.pos(c); + let band = self.band(); if !merge { if self.horizontal { ctx.rect(lo, pos, len, self.bw, self.color, None); @@ -121,14 +138,15 @@ impl BarSink { Some(m) if m.bucket == pos.floor() => m.add(pos, lo, lo + len), run => { if let Some(m) = run.replace(BarRun::new(pos, lo, lo + len)) { - m.draw(ctx, self.bw, self.horizontal, self.color); + m.draw(ctx, self.bw, self.horizontal, self.color, band); } } } } fn finish(self, ctx: &mut Ctx) { + let band = self.band(); if let Some(m) = self.run { - m.draw(ctx, self.bw, self.horizontal, self.color); + m.draw(ctx, self.bw, self.horizontal, self.color, band); } } } diff --git a/crates/chart/src/tests/budget.rs b/crates/chart/src/tests/budget.rs index 1f2e35ab3..54d33aa7d 100644 --- a/crates/chart/src/tests/budget.rs +++ b/crates/chart/src/tests/budget.rs @@ -124,6 +124,40 @@ fn hairline_columns_are_merged_and_wide_ones_are_not() { assert!((1..=320).contains(&n), "{n} bars"); } +#[test] +fn clustered_hairline_series_keep_a_visible_strip() { + // Four series of similar height, 20,000 categories in 400 px: every pixel column holds a + // merged bar of each series, and they must not cover each other. + for kind in [ChartKind::ColumnClustered, ChartKind::BarClustered] { + let horizontal = kind == ChartKind::BarClustered; + let mut c = chart(kind); + c.data_labels = false; + c.legend = LegendPos::None; + let mut d = data_of(4, 20_000); + for (s, series) in d.series.iter_mut().enumerate() { + for (i, v) in series.values.iter_mut().enumerate() { + *v = Some(50.0 + ((i + s) % 3) as f64); + } + } + let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure); + let along = |r: &(f32, f32, f32, f32)| if horizontal { (r.1, r.1 + r.3) } else { (r.0, r.0 + r.2) }; + let mut strips: Vec<(f32, f32, usize)> = Vec::new(); + for (s, series) in d.series.iter().enumerate() { + strips.extend(series_rects(&prims, series.color).iter().map(|r| (along(r).0, along(r).1, s))); + } + strips.sort_by(|a, b| a.0.total_cmp(&b.0)); + assert!(strips.len() > 400); + // Rectangles of different series never overlap (more than rounding). + for w in strips.windows(2) { + assert!(w[0].2 == w[1].2 || w[1].0 >= w[0].1 - 1e-3, "series {} and {} overlap", w[0].2, w[1].2); + } + // And all four series show up in the same pixel column. + let col = strips[strips.len() / 2].0.floor(); + let seen: std::collections::BTreeSet = strips.iter().filter(|s| s.0.floor() == col).map(|s| s.2).collect(); + assert_eq!(seen.len(), 4, "series visible in pixel column {col}"); + } +} + #[test] fn clean_data_is_rendered_without_a_copy() { let d = data_of(3, 50);