Merge PR #1198: Make chart axis flatness thresholds relative to data magnitude

This commit is contained in:
aiongg
2026-10-24 06:14:53 +00:00
3 changed files with 57 additions and 17 deletions
+32 -6
View File
@@ -802,7 +802,7 @@ pub fn axis_scale(
// Office's automatic limits ("How chart axis limits are determined", as Jon Peltier
// summarises it): 5 % of the data range past the data, and zero when the values don't
// sit within the top sixth of their largest magnitude. A single value is measured from zero.
let flat = hi - lo < 1e-12;
let flat = hi - lo <= 1e-12 * lo.abs().max(hi.abs());
let pad = if !headroom {
0.0
} else if flat {
@@ -814,12 +814,15 @@ pub fn axis_scale(
let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad };
let mut lo = fmin.unwrap_or(auto_lo);
let mut hi = fmax.unwrap_or(auto_hi);
if hi - lo < 1e-12 {
let mag = lo.abs().max(hi.abs());
// Relative to the data's magnitude, so tiny values don't count as flat; all-zero falls back to 1.
let unit = if mag == 0.0 { 1.0 } else { mag };
if hi - lo <= 1e-12 * mag {
// A fixed limit on the far side of the data: the automatic end gives way.
match (fmin, fmax) {
(None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) },
(None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(unit) },
(Some(_), None) if lo < 0.0 => hi = 0.0,
_ => hi = lo + lo.abs().max(1.0),
_ => hi = lo + unit,
}
}
let n = max_intervals.clamp(1, 50) as f64;
@@ -827,6 +830,10 @@ pub fn axis_scale(
Some(m) => m,
None => {
let mut step = 10f64.powf(((hi - lo) / n).log10().floor());
// Subnormal spans underflow to a zero (or non-finite) step: fall back to the unit.
if !(step > 0.0 && step.is_finite()) {
step = unit;
}
let mut k = 0;
// 1, 2, 5, 10, 20… × the magnitude until the intervals fit.
while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 {
@@ -844,7 +851,9 @@ pub fn axis_scale(
/// "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 data_mag = lo.abs().max(hi.abs());
let span = if hi - lo <= 1e-12 * data_mag { data_mag } else { hi - lo };
let span = if span == 0.0 { 1.0 } else { span };
let raw = span / 5.0;
let mag = 10f64.powf(raw.log10().floor());
let norm = raw / mag;
@@ -861,7 +870,7 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
} * 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 };
let max = if (max - min).abs() <= 1e-12 * data_mag { min + step } else { max };
(min, max, step)
}
@@ -869,6 +878,23 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
mod tests {
use super::*;
#[test]
fn tiny_data_keeps_its_own_scale() {
let (min, max, step) = axis_scale(1e-13, 5e-13, None, None, None, 8, true);
assert!(min == 0.0 && (5e-13..1e-11).contains(&max), "{min} {max} {step}");
assert!(step > 0.0 && step < 1e-11);
let (min, max, step) = nice_scale(1e-13, 5e-13);
assert!(min <= 1e-13 && (5e-13..1e-11).contains(&max) && step < 1e-11, "{min} {max} {step}");
assert_eq!(axis_scale(0.0, 0.0, None, None, None, 8, true).1, 1.0);
}
#[test]
fn subnormal_data_gives_a_finite_scale() {
let (min, max, step) = axis_scale(0.0, 5e-324, None, None, None, 8, true);
assert!(min.is_finite() && max.is_finite() && step.is_finite(), "{min} {max} {step}");
assert!(step > 0.0 && max >= min, "{min} {max} {step}");
}
#[test]
fn log_axes_run_over_whole_powers() {
let mut c = Chart::sample(ChartType::Line);
+23 -9
View File
@@ -232,6 +232,17 @@ fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bo
(origin, if overlay { area } else { plot })
}
/// An axis span to divide by: the span itself when positive, else 1 (no absolute floor, so tiny data keeps its scale).
pub(crate) fn positive_span(s: f64) -> f64 {
if s > 0.0 && s.is_finite() { s } else { 1.0 }
}
/// The tick values of an axis (min, max, major step), one per step (at most 51).
fn axis_ticks((lo, hi, st): (f64, f64, f64)) -> impl Iterator<Item = f64> {
let n = if st > 0.0 { (((hi - lo) / st).round() as i64).clamp(1, 50) } else { 1 };
(0..=n).map(move |k| lo + k as f64 * st)
}
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) {
match c.kind {
// Histograms and Pareto charts are drawn as the column (and line) chart of their bins.
@@ -435,10 +446,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
};
// 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)| {
let n = (((hi - lo) / st.max(1e-12)).round() as i64).clamp(1, 50);
(0..=n).map(move |k| lo + k as f64 * st)
};
let ticks = axis_ticks;
// Label gutters as wide as the longest value label.
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)| {
@@ -461,7 +469,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
if let Some(f) = &c.plot_fill {
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 span = positive_span(max - min);
// 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| {
@@ -470,13 +478,13 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
};
// 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.max(min2).min(max2) - min2) / (max2 - min2).max(1e-9) * plot.height(),
(true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / positive_span(max2 - min2) * plot.height(),
_ => to_v(v),
};
// Minor gridlines, under the major ones: five to a major unit.
if c.minor_gridlines {
let minor = step / 5.0;
let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500);
let n = if minor > 0.0 { (((max - min) / minor).round() as i64).clamp(1, 500) } else { 1 };
let mut g = BezPath::new();
for k in (0..=n).filter(|k| k % 5 != 0) {
// On a logarithmic axis `min`, `max` and `step` are exponents of the base.
@@ -570,10 +578,10 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
// Scatter and bubble charts have a value x axis: one scale over every series' x values, with its
// own tick labels in place of category names.
let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) };
let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / (hi - lo).max(1e-9) * plot.width());
let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width());
if let Some(sc) = xscale {
for v in ticks(sc) {
let pos = plot.x0 + (v - sc.0) / (sc.1 - sc.0).max(1e-9) * plot.width();
let pos = plot.x0 + (v - sc.0) / positive_span(sc.1 - sc.0) * plot.width();
label(
ctx,
rctx,
@@ -1114,6 +1122,12 @@ mod tests {
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
#[test]
fn tiny_data_gets_several_ticks() {
let sc = deckcraft_model::chart::axis_scale(1e-13, 5e-13, None, None, None, 8, true);
assert!(axis_ticks(sc).count() >= 3, "{sc:?}");
}
#[test]
fn scatter_x_is_a_value_scale_over_all_series() {
let mut c = Chart::sample(ChartType::Scatter);
+2 -2
View File
@@ -28,8 +28,8 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec
let r = (area.width().min(area.height()) / 2.0 - base * 1.9).max(base);
let cen = area.center();
let (min, max, step) = c.axis_scale(false, 5).unwrap_or((0.0, 1.0, 0.2));
let span = (max - min).max(1e-9);
let ticks = (((max - min) / step.max(1e-12)).round() as i64).clamp(1, 20);
let span = crate::chart::positive_span(max - min);
let ticks = if step > 0.0 { (((max - min) / step).round() as i64).clamp(1, 20) } else { 1 };
ctx.set_transform(m);
ctx.set_stroke(kurbo::Stroke::new(0.75));
ctx.set_paint(color(grid_col, 1.0));