Skip to main content

ggplot_rs/scale/
sec_axis.rs

1/// A label formatter for a secondary axis' tick values.
2pub type SecLabeller = std::sync::Arc<dyn Fn(f64) -> String + Send + Sync>;
3
4/// A secondary axis specification — defines a transformation from the primary axis domain.
5///
6/// # Example
7/// ```
8/// use ggplot_rs::prelude::*;
9///
10/// // Temperature in Celsius on primary, Fahrenheit on secondary
11/// let sec = SecAxis::new(|c| c * 9.0 / 5.0 + 32.0).with_name("Fahrenheit");
12/// ```
13#[derive(Clone)]
14pub struct SecAxis {
15    /// Transformation from primary axis values to secondary axis values.
16    pub transform: fn(f64) -> f64,
17    /// Name / title for the secondary axis.
18    pub name: String,
19    /// Custom break positions in primary axis coordinates.
20    pub breaks: Option<Vec<f64>>,
21    /// Label the axis as a percentage of the primary scale's trained data
22    /// maximum ([`SecAxis::percent_of_max`]) instead of through `transform`.
23    pub percent_of_max: bool,
24    /// `secondary = primary * scale + offset` ([`SecAxis::linear`]): the
25    /// axis gets its own nice ticks in the secondary range.
26    pub linear: Option<(f64, f64)>,
27    /// Formats the secondary axis' tick values ([`SecAxis::with_labeller`]);
28    /// `None` uses the plot's number format.
29    pub labeller: Option<SecLabeller>,
30}
31
32impl SecAxis {
33    /// Create a secondary axis with the given transformation function.
34    pub fn new(transform: fn(f64) -> f64) -> Self {
35        SecAxis {
36            transform,
37            name: String::new(),
38            breaks: None,
39            percent_of_max: false,
40            linear: None,
41            labeller: None,
42        }
43    }
44
45    /// ggplot2's `dup_axis()`: the primary axis repeated on the other side
46    /// (same breaks and labels). Name it with [`with_name`](Self::with_name).
47    pub fn dup() -> Self {
48        SecAxis::new(|v| v)
49    }
50
51    /// A 0–100 % axis relative to the primary scale's trained data maximum
52    /// (the value drawn at the right axis' 100 % tick) — e.g. a Pareto
53    /// chart's cumulative share, where the maximum is the grand total. The
54    /// ticks sit at their own nice percentages (0, 25, …, 100 %) rather than
55    /// at the primary breaks.
56    pub fn percent_of_max() -> Self {
57        SecAxis {
58            percent_of_max: true,
59            ..SecAxis::new(|v| v)
60        }
61    }
62
63    /// A linear secondary axis: `secondary = primary * scale + offset`,
64    /// e.g. a second measure drawn rescaled into the primary range (a
65    /// combo chart's right axis). Its ticks sit at nice values of the
66    /// secondary range, not at the primary breaks. A zero or non-finite
67    /// `scale` falls back to the identity.
68    pub fn linear(scale: f64, offset: f64) -> Self {
69        let ok = scale.is_finite() && scale != 0.0 && offset.is_finite();
70        SecAxis {
71            linear: Some(if ok { (scale, offset) } else { (1.0, 0.0) }),
72            ..SecAxis::new(|v| v)
73        }
74    }
75
76    /// Format the secondary axis' tick labels with `f` (e.g. a currency or
77    /// percent format of the second measure).
78    pub fn with_labeller(mut self, f: impl Fn(f64) -> String + Send + Sync + 'static) -> Self {
79        self.labeller = Some(std::sync::Arc::new(f));
80        self
81    }
82
83    pub fn with_name(mut self, name: &str) -> Self {
84        self.name = name.to_string();
85        self
86    }
87
88    pub fn with_breaks(mut self, breaks: Vec<f64>) -> Self {
89        self.breaks = Some(breaks);
90        self
91    }
92
93    /// Transform a primary axis value to the secondary axis value.
94    pub fn transform_value(&self, v: f64) -> f64 {
95        match self.linear {
96            Some((a, b)) => v * a + b,
97            None => (self.transform)(v),
98        }
99    }
100
101    /// A tick label for a secondary value: the labeller, else the number
102    /// format.
103    pub(crate) fn label(&self, v: f64, locale: Option<&crate::locale::Locale>) -> String {
104        match &self.labeller {
105            Some(f) => f(v),
106            None => crate::scale::util::format_number_opt(v, locale),
107        }
108    }
109
110    /// The axis' own ticks as `(normalized position, label)` when it does not
111    /// simply relabel the primary breaks (the [`percent_of_max`](Self::percent_of_max)
112    /// mode); `None` otherwise.
113    pub(crate) fn own_ticks(&self, primary: &dyn super::Scale) -> Option<Vec<(f64, String)>> {
114        if let Some((a, b)) = self.linear {
115            let (lo, hi) = primary.expanded_domain()?;
116            let (s1, s2) = (lo * a + b, hi * a + b);
117            let (smin, smax) = (s1.min(s2), s1.max(s2));
118            let eps = 1e-9 * (smax - smin).abs().max(1e-12);
119            return Some(
120                super::util::extended_breaks(smin, smax, 5)
121                    .into_iter()
122                    .filter(|t| *t >= smin - eps && *t <= smax + eps)
123                    .map(|t| {
124                        let pos = primary.map(&crate::data::Value::Float((t - b) / a));
125                        (pos, self.label(t, primary.locale()))
126                    })
127                    .collect(),
128            );
129        }
130        if !self.percent_of_max {
131            return None;
132        }
133        let (_, max) = primary.domain()?;
134        let (lo, hi) = primary.expanded_domain()?;
135        if !(max > 0.0 && max.is_finite()) {
136            return Some(vec![]);
137        }
138        let (lo_pct, hi_pct) = (lo / max * 100.0, hi / max * 100.0);
139        let eps = 1e-9 * (hi_pct - lo_pct).abs().max(1.0);
140        Some(
141            super::util::extended_breaks(0.0, 100.0, 5)
142                .into_iter()
143                .filter(|b| *b >= lo_pct - eps && *b <= hi_pct + eps)
144                .map(|b| {
145                    let pos = primary.map(&crate::data::Value::Float(b / 100.0 * max));
146                    let label = crate::scale::util::format_number_opt(b, primary.locale());
147                    (pos, format!("{label}%"))
148                })
149                .collect(),
150        )
151    }
152}
153
154impl std::fmt::Debug for SecAxis {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("SecAxis")
157            .field("name", &self.name)
158            .field("breaks", &self.breaks)
159            .field("percent_of_max", &self.percent_of_max)
160            .field("linear", &self.linear)
161            .field("labeller", &self.labeller.is_some())
162            .finish()
163    }
164}