Keep clustered hairline bars of each series in their own strip of the pixel column

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-30 00:54:56 +01:00
co-authored by Claude Sonnet 5.5
parent 95c3bb9b89
commit 494a7dbb5c
2 changed files with 59 additions and 7 deletions
+25 -7
View File
@@ -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);
}
}
}
+34
View File
@@ -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<usize> = 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);