mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #1198: Make chart axis flatness thresholds relative to data magnitude
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user