mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #669 from NikitkaRa: Charts: logarithmic value axes
# Conflicts: # crates/pptx/src/read/chart.rs # crates/render/src/chart.rs
This commit is contained in:
@@ -280,6 +280,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.
|
||||
@@ -349,6 +351,7 @@ impl Default for Chart {
|
||||
y_min: None,
|
||||
y_max: None,
|
||||
y_major: None,
|
||||
y_log_base: None,
|
||||
y_format: None,
|
||||
y_hidden: false,
|
||||
cat_reversed: false,
|
||||
@@ -544,6 +547,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 };
|
||||
@@ -702,6 +721,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);
|
||||
|
||||
@@ -324,6 +324,7 @@ fn chart_within(root: &El, mut budget: usize) -> 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);
|
||||
}
|
||||
@@ -450,6 +451,12 @@ mod tests {
|
||||
assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c)).unwrap().minor_gridlines);
|
||||
}
|
||||
|
||||
#[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>"#;
|
||||
|
||||
@@ -354,6 +354,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));
|
||||
|
||||
@@ -355,7 +355,16 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
// 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)| {
|
||||
@@ -363,8 +372,11 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
(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 {
|
||||
@@ -385,7 +397,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
// Values past the axis limits (fixed ones, or zero on an axis that starts above it) stop at
|
||||
// the plot's edge.
|
||||
let to_v = |v: f64| {
|
||||
let v = v.max(min).min(max);
|
||||
let v = to_e(v).max(min).min(max);
|
||||
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.
|
||||
@@ -414,7 +426,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
ctx.stroke_path(&g);
|
||||
}
|
||||
// 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();
|
||||
@@ -543,7 +555,10 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v);
|
||||
return;
|
||||
}
|
||||
let zero = to_v(clamp_soft(0.0, min, max));
|
||||
let zero = match log {
|
||||
Some((b, _)) => to_v(b.powf(min)),
|
||||
None => to_v(clamp_soft(0.0, 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();
|
||||
|
||||
Reference in New Issue
Block a user