From 53972cf37c2fd20a2383abb851a2a4ee73a6671a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:07:43 +0000 Subject: [PATCH 1/2] Make chart axis flatness thresholds relative to data magnitude Found while testing crates/model/src/chart.rs:775-794 (axis_scale); same pattern in nice_scale :817 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/chart.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index f5aeb1ee..29d28fbb 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -772,7 +772,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 { @@ -784,12 +784,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; @@ -814,7 +817,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; @@ -831,7 +836,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) } @@ -839,6 +844,16 @@ 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 log_axes_run_over_whole_powers() { let mut c = Chart::sample(ChartType::Line); From 8789394de1ae19e437bf8310711d9c4007334d94 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:19:17 +0200 Subject: [PATCH 2/2] fix: keep chart axis steps positive and finite and drop absolute floors in rendering Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/chart.rs | 11 +++++++++++ crates/render/src/chart.rs | 32 +++++++++++++++++++++++--------- crates/render/src/radar.rs | 4 ++-- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 29d28fbb..11d27654 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -800,6 +800,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 { @@ -854,6 +858,13 @@ mod tests { 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); diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index ac185780..cbba217f 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -193,6 +193,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 { + 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. @@ -379,10 +390,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 = ticks((min, max, step)).map(|k| log.map_or(k, |(b, _)| b.powf(k))).collect(); let label_w = |sec: bool, sc: (f64, f64, f64)| { @@ -405,7 +413,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| { @@ -414,13 +422,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. @@ -514,10 +522,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, @@ -1054,6 +1062,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); diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs index 3416c7ce..6e61c144 100644 --- a/crates/render/src/radar.rs +++ b/crates/render/src/radar.rs @@ -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));