From 4a7ad03eb0cbdd287ec44651d8041dd35fac2760 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:45:26 +0300 Subject: [PATCH] Charts: logarithmic value axes --- crates/model/src/chart.rs | 30 ++++++++++++++++++++++++++++++ crates/pptx/src/read/chart.rs | 7 +++++++ crates/pptx/src/write/chart.rs | 3 +++ crates/render/src/chart.rs | 30 ++++++++++++++++++++++++------ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..04b591ce 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -174,6 +174,8 @@ pub struct Chart { pub y_max: Option, /// The value axis' major unit, when the chart fixes it. pub y_major: Option, + /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). + pub y_log_base: Option, /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). pub y_format: Option, /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. @@ -217,6 +219,7 @@ impl Default for Chart { y_min: None, y_max: None, y_major: None, + y_log_base: None, y_format: None, y_hidden: false, y2_min: None, @@ -311,6 +314,22 @@ impl Chart { Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent)) } + /// A logarithmic value axis' scale in exponents — (min, max, unit), so its labels are + /// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint + /// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive. + pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> { + let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?; + let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied()); + let (lo, hi) = vals + .filter(|v| v.is_finite() && *v > 0.0) + .fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?; + let e = |v: f64| v.log(b); + let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e); + let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0); + let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0); + Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit)) + } + /// A value axis label in the axis' number format and the chart's language. pub fn axis_label(&self, secondary: bool, v: f64) -> String { let code = if secondary { &self.y2_format } else { &self.y_format }; @@ -431,6 +450,17 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { mod tests { use super::*; + #[test] + fn log_axes_run_over_whole_powers() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)]; + assert_eq!(c.log_axis_scale(8), None); + c.y_log_base = Some(10.0); + assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0))); + assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); + } + #[test] fn nice_scale_covers() { let (a, b, s) = nice_scale(0.0, 5.1); diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..2d2981bb 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -271,6 +271,7 @@ pub(crate) fn chart_from(root: &El) -> Option { c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); c.axis_titles.1 = v.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); c.y_major = num(v, "majorUnit"); + c.y_log_base = sc.and_then(|s| s.child_val("logBase")).and_then(|v| v.parse().ok()).filter(|b: &f64| b.is_finite() && *b >= 2.0); c.y_format = format_code(v); c.y_hidden = deleted(v); } @@ -310,6 +311,12 @@ fn text_size(e: Option<&El>) -> Option { mod tests { use super::*; + #[test] + fn log_base_is_read() { + let xml = br#""#; + assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0)); + } + #[test] fn legend_position_and_overlay_are_read() { let legend = r#""#; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 76567c9e..a3cf7042 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -273,6 +273,9 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.open0("c:valAx"); w.val("c:axId", AX_VAL); w.open0("c:scaling"); + if let Some(b) = c.y_log_base { + w.val("c:logBase", fmt_num(b)); + } w.val("c:orientation", "minMax"); if let Some(m) = c.y_max.filter(|v| v.is_finite()) { w.val("c:max", fmt_num(m)); diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..6fc482cf 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -249,7 +249,16 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h // As many major units as the value axis has room for labels (PowerPoint's automatic unit). let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 }; let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize; - let (min, max, step) = c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)); + // A logarithmic axis is scaled in exponents of its base. + let log = c.log_axis_scale(max_intervals).map(|sc| (c.y_log_base.unwrap_or(10.0), sc)); + let (min, max, step) = match log { + Some((_, sc)) => sc, + None => c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)), + }; + let to_e = |v: f64| match log { + Some((b, _)) => v.max(f64::MIN_POSITIVE).log(b).max(min - 1.0), + None => v, + }; // A combo chart's secondary value axis, on the right (vertical value axes only). let second = if horiz { None } else { c.axis_scale(true, max_intervals) }; let ticks = |(lo, hi, st): (f64, f64, f64)| { @@ -257,8 +266,11 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h (0..=n).map(move |k| lo + k as f64 * st) }; // Label gutters as wide as the longest value label. - let label_w = - |sec: bool, sc: (f64, f64, f64)| ticks(sc).map(|v| c.axis_label(sec, v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6; + let primary_ticks: Vec = ticks((min, max, step)).map(|k| log.map_or(k, |(b, _)| b.powf(k))).collect(); + let label_w = |sec: bool, sc: (f64, f64, f64)| { + let vals: Vec = if sec { ticks(sc).collect() } else { primary_ticks.clone() }; + vals.iter().map(|v| c.axis_label(sec, *v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6 + }; let gutter = if horiz { base * 4.5 } else if c.y_hidden { @@ -276,14 +288,17 @@ 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() }; + let to_v = |v: f64| { + let v = to_e(v); + if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() } + }; // A series' value as a position on its own axis. let to_axis = |sec: bool, v: f64| match (sec, second) { (true, Some((min2, max2, _))) => plot.y1 - (v - min2) / (max2 - min2).max(1e-9) * plot.height(), _ => to_v(v), }; // Gridlines and value labels. - for v in ticks((min, max, step)) { + for &v in &primary_ticks { let pos = to_v(v); let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) }; let mut g = BezPath::new(); @@ -378,7 +393,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ); } } - let zero = to_v(0.0f64.clamp(min, max)); + let zero = match log { + Some((b, _)) => to_v(b.powf(min)), + None => to_v(0.0f64.clamp(min, max)), + }; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect();