Charts: logarithmic value axes

This commit is contained in:
Nikita Rakitin
2026-10-14 04:45:26 +03:00
parent 457406a7a5
commit 4a7ad03eb0
4 changed files with 64 additions and 6 deletions
+30
View File
@@ -174,6 +174,8 @@ pub struct Chart {
pub y_max: Option<f64>,
/// The value axis' major unit, when the chart fixes it.
pub y_major: Option<f64>,
/// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000).
pub y_log_base: Option<f64>,
/// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`).
pub y_format: Option<String>,
/// 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);
+7
View File
@@ -271,6 +271,7 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
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<f64> {
mod tests {
use super::*;
#[test]
fn log_base_is_read() {
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:axId val="1"/><c:axId val="2"/></c:lineChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/><c:scaling><c:logBase val="10"/><c:orientation val="minMax"/></c:scaling></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0));
}
#[test]
fn legend_position_and_overlay_are_read() {
let legend = r#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
+3
View File
@@ -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));
+24 -6
View File
@@ -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<f64> = 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<f64> = 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();