Files
vectorcraft/crates/engine/src/cmd/graph/axis.rs
T
dotCooCoo 23d371ca28 Graphs: graph.setType, setData and create refuse params of the wrong type
graph.setType read `axisMin: "10"` as a cleared bound, so the axis went
back to the calculated range, and it ignored a string or number given for
a true/false option, a string for a number and a number for a text option.
graph.setData and graph.create ignored a `csv` that is not a string and
`series`, `categories` or `rows` that are not lists, and read a string in
`rows` as a blank cell.

The three commands now refuse such a value with an error naming the key,
and leave the graph and the undo history as they were. A null is still
read as a key left out, and a null axis bound still goes back to the
calculated value. A negative tick count is refused too.
2026-11-11 16:41:13 -08:00

80 lines
4.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{GraphSpec, Result, Value, bad};
pub(super) const LABEL_SIZE: f64 = 9.0;
/// Most tick marks per division (Graph Type › Tick Marks), and most category tick marks a graph draws.
pub(super) const MAX_TICK_MARKS: usize = 20;
pub(super) const MAX_CATEGORY_TICKS: usize = 10_000;
/// Longest axis label prefix or suffix (Graph Type › Add Labels).
const MAX_AFFIX: usize = 64;
/// An axis label prefix or suffix: one line (no control characters or line separators), at most [`MAX_AFFIX`] long.
pub(super) fn one_line(s: &str) -> String {
s.chars().filter(|c| !c.is_control() && !matches!(c, '\u{2028}' | '\u{2029}')).take(MAX_AFFIX).collect()
}
/// A count parameter: a whole number, also as the float a dialog sends back (3.0), at most `max`.
pub(super) fn count_param(p: &Value, key: &str, max: u64) -> Option<usize> {
let v = p.get(key)?;
let n = v.as_u64().or_else(|| v.as_f64().filter(|x| x.is_finite() && *x >= 0.0).map(|x| x.round().min(max as f64) as u64))?;
usize::try_from(n.min(max)).ok()
}
/// [`count_param`], or `None` when `p` leaves `key` out or gives null. A negative number is an error, and so is any
/// other value, such as `"5"`.
pub(super) fn count_given(p: &Value, key: &str, max: u64, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) if !v.is_number() => Err(bad(cmd, format!("`{key}` must be a number, not {v}"))),
Some(_) => count_param(p, key, max).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be 0 or more"))),
}
}
/// A "nice" axis: (min, max, step).
pub(super) fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) {
let (lo, hi) = if (hi - lo).abs() < 1e-12 { (lo.min(0.0), lo.max(0.0) + 1.0) } else { (lo, hi) };
let n = if ticks == 0 { 5 } else { ticks.clamp(1, 100) } as f64;
let raw = (hi - lo) / n;
let mag = 10f64.powf(raw.log10().floor());
let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * mag).find(|s| *s >= raw - 1e-12).unwrap_or(10.0 * mag);
((lo / step).floor() * step, (hi / step).ceil() * step, step)
}
/// The value axis of a radar graph: from 0 to the largest value of any series.
pub(super) fn radar_axis(g: &GraphSpec) -> (f64, f64, f64) {
value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max))
}
/// A value axis: with both `min` and `max` set (Graph Type › Tick Values › Override Calculated Values), exactly that
/// range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using whichever bound
/// was given.
pub(super) fn value_axis(ticks: usize, axis_min: Option<f64>, axis_max: Option<f64>, lo: f64, hi: f64) -> (f64, f64, f64) {
match (axis_min, axis_max) {
(Some(min), Some(max)) if max > min && (max - min).is_finite() => {
let n = if ticks == 0 { 5 } else { ticks.clamp(1, 100) } as f64;
(min, max, (max - min) / n)
}
_ => {
// An override too wide for f64 (or NaN) gives a non-finite axis, which would reach `clamp` with NaN bounds
// and panic: fall back to the data, then to 0..1.
let finite = |(a, b, s): (f64, f64, f64)| a.is_finite() && b.is_finite() && s.is_finite() && b > a && s > 0.0;
[nice_axis(axis_min.unwrap_or(lo), axis_max.unwrap_or(hi), ticks), nice_axis(lo, hi, ticks)]
.into_iter()
.find(|&a| finite(a))
.unwrap_or((0.0, 1.0, 0.2))
}
}
}
/// The tick values from `lo` to `hi` every `step`, both ends included: counted, not accumulated, so the last tick
/// lands on `hi` and a step too small for the range (or not finite) can't loop forever. At most 1000 ticks.
pub(super) fn tick_values(lo: f64, hi: f64, step: f64) -> Vec<f64> {
let n = ((hi - lo) / step).round();
if !(lo.is_finite() && step.is_finite() && step > 0.0 && n.is_finite() && n >= 0.0) {
return vec![];
}
(0..=(n as usize).min(1000)).map(|i| lo + i as f64 * step).collect()
}