mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 21:16:57 -06:00
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 `<c:min>`/`<c:max>` 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 <noreply@anthropic.com>
This commit is contained in:
+140
-44
@@ -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 (`<c:min>`, `<c:max>`). 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<f64>, fixed_max: Option<f64>) -> (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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user