From 6a91164a5b01e5d92669df4d37e36f61adcf53c1 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 17:05:17 +0200 Subject: [PATCH] Issue #144: automatic value axes scale like Office The value axis used a generic "nice" scale: about five intervals and no headroom, so data with a maximum of 42.1 got 0-50 in steps of 10, while PowerPoint and LibreOffice draw 0-45 in steps of 5. `auto_scale` now follows the rule Office documents for automatic limits: the maximum is the first major unit at or above max + (max - min)/20, the minimum is zero when all values are positive and the smallest is below 5/6 of the largest (otherwise the first major unit at or below min - the same headroom), and all-negative data mirrors that. The major unit is the smallest 1, 2 or 5 x 10^n that gives at most 10 intervals. Fixed ``/`` limits are kept as they are and no longer get rounded or padded; values beyond them stop at the plot edge. 100% charts get 0-1 in steps of 0.1. Fixes #144 Co-Authored-By: Claude Opus 5.5 --- crates/model/src/chart.rs | 184 ++++++++++++++++++++++++++++--------- crates/render/src/chart.rs | 11 ++- 2 files changed, 147 insertions(+), 48 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 5ba72fc4..0ce14b41 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -210,60 +210,99 @@ impl Chart { c.vary_colors = n_series == 1; c } - /// Value range across series (stacked sums for stacked types). + /// The value axis scale `(min, max, major unit)` the chart is drawn with: the chart's fixed + /// limits, otherwise Office's automatic scale ([`auto_scale`]). + pub fn axis_scale(&self) -> (f64, f64, f64) { + if self.kind.percent() { + return auto_scale(0.0, 1.0, Some(0.0), Some(1.0)); + } + let (lo, hi) = self.value_range(); + auto_scale(lo, hi, self.y_min, self.y_max) + } + + /// Range of the plotted values (stacked sums for stacked types, which start at zero); + /// `(0, 0)` without values. pub fn value_range(&self) -> (f64, f64) { - let mut lo: f64 = 0.0; - let mut hi: f64 = 0.0; let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); if self.kind.stacked() { + let (mut lo, mut hi) = (0.0f64, 0.0f64); for i in 0..n { let (mut p, mut m) = (0.0, 0.0); for s in &self.series { - let v = s.values.get(i).copied().flatten().unwrap_or(0.0); + let v = s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0); if v >= 0.0 { p += v } else { m += v } } hi = hi.max(p); lo = lo.min(m); } - } else { - for s in &self.series { - for v in s.values.iter().flatten() { - if v.is_finite() { - hi = hi.max(*v); - lo = lo.min(*v); - } - } - } + return (lo, hi); } - if self.kind.percent() { - return (0.0, 1.0); - } - (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) + let mut values = self.series.iter().flat_map(|s| s.values.iter().flatten()).filter(|v| v.is_finite()); + let Some(first) = values.next() else { return (0.0, 0.0) }; + values.fold((*first, *first), |(lo, hi), v| (lo.min(*v), hi.max(*v))) } } -/// "Nice" axis scale: (min, max, major step) covering [lo, hi]. -pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { - let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) }; - let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo }; - let raw = span / 5.0; - let mag = 10f64.powf(raw.log10().floor()); - let norm = raw / mag; - let step = if norm <= 1.0 { - 1.0 - } else if norm <= 2.0 { - 2.0 - } else if norm <= 2.5 { - 2.5 - } else if norm <= 5.0 { - 5.0 - } else { - 10.0 - } * mag; - let min = (lo / step).floor() * step; - let max = (hi / step).ceil() * step; - let max = if (max - min).abs() < 1e-12 { min + step } else { max }; - (min, max, step) +/// Office's automatic value-axis scale `(min, max, major unit)` for values in `[lo, hi]`, keeping +/// the limits a chart fixes (``, ``). The rule Office documents for automatic limits: +/// +/// - **Minimum:** zero when every value is ≥ 0 and the smallest is below 5/6 of the largest; +/// otherwise the first major unit at or below `lo − (hi − lo)/20`. +/// - **Maximum:** the first major unit at or above `hi + (hi − lo)/20`. +/// - All-negative values mirror this: the axis ends at zero when the largest is above 5/6 of the +/// smallest. +/// +/// The major unit is the smallest 1, 2 or 5 × 10ⁿ that gives at most 10 major intervals (#144). +pub fn auto_scale(lo: f64, hi: f64, fixed_min: Option, fixed_max: Option) -> (f64, f64, f64) { + let fixed_min = fixed_min.filter(|v| v.is_finite()); + let fixed_max = fixed_max.filter(|v| v.is_finite()); + let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 0.0) }; + let (lo, hi) = (fixed_min.unwrap_or(lo), fixed_max.unwrap_or(hi)); + let pad = (hi - lo).abs() / 20.0; + let flat = (hi - lo).abs() < 1e-12; + // Where the axis has to reach, before rounding out to the major unit. + let low = match fixed_min { + Some(m) => m, + None if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) => 0.0, + None => lo - pad, + }; + let mut high = match fixed_max { + Some(m) => m, + None if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) => 0.0, + None => hi + pad, + }; + if high - low < 1e-12 { + high = low + low.abs().max(1.0); + } + // Smallest 1, 2 or 5 × 10ⁿ at or above a tenth of the span. + let raw = (high - low) / 10.0; + let mut exp = raw.log10().floor().clamp(-400.0, 400.0) as i32; + let mut mantissa = [1.0, 2.0, 5.0, 10.0].into_iter().find(|m| m * 10f64.powi(exp) >= raw * (1.0 - 1e-9)).unwrap_or(10.0); + let mut out = (low, high, raw); + // A rounded-out axis can need one more interval than the span; then the next unit fits. + for _ in 0..4 { + if mantissa >= 10.0 { + mantissa = 1.0; + exp = exp.saturating_add(1); + } + let step = if exp >= 0 { mantissa * 10f64.powi(exp) } else { mantissa / 10f64.powi(-exp) }; + let min = fixed_min.unwrap_or_else(|| (low / step + 1e-9).floor() * step); + let max = fixed_max.unwrap_or_else(|| (high / step - 1e-9).ceil() * step); + let max = if max - min < 1e-12 { min + step } else { max }; + out = (min, max, step); + if (max - min) / step <= 10.0 + 1e-9 { + break; + } + mantissa = if mantissa < 2.0 { + 2.0 + } else if mantissa < 5.0 { + 5.0 + } else { + 10.0 + }; + } + // Spans too large or too small for f64 steps. + if [out.0, out.1, out.2].iter().all(|v| v.is_finite()) && out.2 > 0.0 && out.1 > out.0 { out } else { (0.0, 1.0, 0.1) } } #[cfg(test)] @@ -271,13 +310,70 @@ mod tests { use super::*; #[test] - fn nice_scale_covers() { - let (a, b, s) = nice_scale(0.0, 5.1); + fn auto_scale_covers() { + let (a, b, s) = auto_scale(0.0, 5.1, None, None); assert!(a <= 0.0 && b >= 5.1 && s > 0.0); - let (a, b, _) = nice_scale(f64::NAN, 3.0); - assert!(a.is_finite() && b.is_finite()); - let (a, b, _) = nice_scale(7.0, 7.0); + let (a, b, _) = auto_scale(f64::NAN, 3.0, None, None); + assert!(a.is_finite() && b.is_finite() && b > a); + let (a, b, _) = auto_scale(7.0, 7.0, None, None); assert!(b > a); + for (lo, hi, min, max) in + [(0.0, 1.0, Some(5.0), Some(5.0)), (-f64::MAX, f64::MAX, None, None), (0.0, 1e-320, None, None), (0.0, 1.0, Some(5.0), Some(1.0))] + { + let (a, b, s) = auto_scale(lo, hi, min, max); + assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{lo} {hi}: {a} {b} {s}"); + } + } + + fn column(values: &[f64]) -> Chart { + let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() }; + Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() } + } + + /// #144: Office scales 42.1 / 18.7 / 6.3 as 0–45 in steps of 5, not 0–50 in steps of 10. + #[test] + fn automatic_axis_matches_office() { + assert_eq!(column(&[42.1, 18.7, 6.3]).axis_scale(), (0.0, 45.0, 5.0)); + // PowerPoint's default chart data (max 5): 0–6 in steps of 1. + let mut c = column(&[4.3, 2.5, 3.5, 4.5]); + c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() }); + c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() }); + assert_eq!(c.axis_scale(), (0.0, 6.0, 1.0)); + // A maximum of 100 (or 10) gets 5% headroom, which rounds up to the next major unit. + assert_eq!(column(&[100.0, 40.0]).axis_scale(), (0.0, 120.0, 20.0)); + assert_eq!(column(&[10.0, 3.0]).axis_scale(), (0.0, 12.0, 2.0)); + // Headroom 2.95 + 2.95/20 = 3.1 rounds up to 3.5. + assert_eq!(column(&[2.95, 0.0]).axis_scale(), (0.0, 3.5, 0.5)); + } + + #[test] + fn automatic_axis_minimum() { + // Below 5/6 of the maximum, the axis starts at zero; at or above it, near the data. + assert_eq!(column(&[59.0, 49.0]).axis_scale().0, 0.0); + let (min, max, step) = column(&[59.0, 50.0]).axis_scale(); + assert!(min > 0.0 && min <= 50.0 - 0.45 && max >= 59.45 && (max - min) / step <= 10.0, "{min} {max} {step}"); + // Negative data mirrors it: the axis ends at zero, and has headroom below. + assert_eq!(column(&[-42.1, -18.7, -6.3]).axis_scale(), (-45.0, 0.0, 5.0)); + // Mixed signs get headroom at both ends. + assert_eq!(column(&[42.1, -18.7]).axis_scale(), (-30.0, 50.0, 10.0)); + } + + #[test] + fn fixed_axis_limits_are_kept() { + let mut c = column(&[42.1, 18.7, 6.3]); + c.y_max = Some(50.0); + assert_eq!(c.axis_scale(), (0.0, 50.0, 5.0)); + c.y_min = Some(10.0); + c.y_max = None; + assert_eq!(c.axis_scale(), (10.0, 45.0, 5.0)); + let mut p = column(&[42.1, 18.7, 6.3]); + p.kind = ChartType::PercentColumn; + assert_eq!(p.axis_scale(), (0.0, 1.0, 0.1)); + // Degenerate data still gives an axis. + for (a, b) in [(0.0, 0.0), (7.0, 7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0)] { + let (min, max, step) = column(&[a, b]).axis_scale(); + assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}"); + } } #[test] diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index a29b2023..0921771b 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -2,7 +2,7 @@ use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::Shape; -use deckcraft_model::chart::{Chart, ChartType, nice_scale}; +use deckcraft_model::chart::{Chart, ChartType}; use deckcraft_model::resolve::Ctx; use deckcraft_model::style::{ColorRef, Fill}; use deckcraft_model::text::{Align, Anchor, Bullet, TextBody}; @@ -162,8 +162,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } return; } - let (lo, hi) = c.value_range(); - let (min, max, step) = nice_scale(lo.min(0.0), hi); + let (min, max, step) = c.axis_scale(); let horiz = c.kind.horizontal(); // Axis label gutter. let gutter = base * 3.0; @@ -176,7 +175,11 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); } let span = (max - min).max(1e-9); - let to_v = |v: f64| if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() }; + // Values outside fixed axis limits stop at the plot's edge. + let to_v = |v: f64| { + let v = v.clamp(min, max); + if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() } + }; // Gridlines and value labels. let n_steps = ((span / step).round() as i64).clamp(1, 50); for k in 0..=n_steps {