Skip to main content

ggplot_rs/scale/
continuous.rs

1use std::sync::Arc;
2
3use crate::aes::Aesthetic;
4use crate::data::Value;
5
6use super::format::LabelFormatter;
7use super::sec_axis::SecAxis;
8use super::transform::ScaleTransform;
9use super::util::extended_breaks;
10use super::Scale;
11
12/// Locale-dependent break labels ([`ScaleContinuous::with_localized_labels`]).
13pub type LocalizedLabels = Arc<dyn Fn(Option<&crate::locale::Locale>) -> Vec<String> + Send + Sync>;
14
15/// Continuous linear scale.
16#[derive(Clone)]
17pub struct ScaleContinuous {
18    aesthetic: Aesthetic,
19    name: String,
20    min: f64,
21    max: f64,
22    trained: bool,
23    filter_oob: bool,
24    // Per-side expansion: (mult_lower, add_lower, mult_upper, add_upper).
25    expand: (f64, f64, f64, f64),
26    // Axis position: false = default (bottom for x, left for y), true = opposite
27    // side (top for x, right for y).
28    position_opposite: bool,
29    pub(crate) scale_transform: ScaleTransform,
30    custom_breaks: Option<Vec<f64>>,
31    custom_labels: Option<Vec<String>>,
32    /// Labels for `custom_breaks` resolved against the scale's locale.
33    localized_labels: Option<LocalizedLabels>,
34    pub(crate) sec_axis: Option<SecAxis>,
35    label_formatter: Option<LabelFormatter>,
36    /// Locale for the default labels (`None` = default marks).
37    locale: Option<crate::locale::Locale>,
38    /// Formatter for `format_value` (B2-4: `data-fvalue`/tooltip templates),
39    /// distinct from `label_formatter` — e.g. a full currency string where
40    /// the axis itself shows an abbreviated break.
41    value_formatter: Option<LabelFormatter>,
42    /// Target number of breaks (ggplot2's `n.breaks`, #408; default 5).
43    n_breaks: usize,
44    /// A time-of-day axis (`scale_x_time`, #408): values are seconds, breaks
45    /// fall on round time steps.
46    time: bool,
47    /// `scale_x_binned(n.breaks)` (#408): bins between about this many breaks.
48    binned: Option<usize>,
49    /// The bin edges, fixed by the first layer binned (later layers share
50    /// them); also the axis breaks.
51    bin_edges: Vec<f64>,
52    /// Draw ggplot2's `guide_axis_logticks()` ticks (#409).
53    log_ticks: bool,
54}
55
56impl ScaleContinuous {
57    pub fn new() -> Self {
58        ScaleContinuous {
59            aesthetic: Aesthetic::X,
60            name: String::new(),
61            min: f64::INFINITY,
62            max: f64::NEG_INFINITY,
63            trained: false,
64            filter_oob: false,
65            n_breaks: 5,
66            time: false,
67            binned: None,
68            bin_edges: Vec::new(),
69            log_ticks: false,
70            expand: (0.05, 0.0, 0.05, 0.0),
71            position_opposite: false,
72            scale_transform: ScaleTransform::Identity,
73            custom_breaks: None,
74            custom_labels: None,
75            localized_labels: None,
76            sec_axis: None,
77            label_formatter: None,
78            locale: None,
79            value_formatter: None,
80        }
81    }
82
83    pub fn for_aesthetic(mut self, aes: Aesthetic) -> Self {
84        self.aesthetic = aes;
85        self
86    }
87
88    pub fn with_name(mut self, name: &str) -> Self {
89        self.name = name.to_string();
90        self
91    }
92
93    pub fn with_limits(mut self, min: f64, max: f64) -> Self {
94        self.min = min;
95        self.max = max;
96        self.trained = true;
97        self.filter_oob = true;
98        self
99    }
100
101    pub fn with_transform(mut self, transform: ScaleTransform) -> Self {
102        self.scale_transform = transform;
103        self
104    }
105
106    /// Set custom break positions (data values where ticks appear).
107    /// Aim for about `n` breaks (ggplot2's `n.breaks`, #408) instead of 5;
108    /// the extended-Wilkinson search still picks round values.
109    pub fn with_n_breaks(mut self, n: usize) -> Self {
110        self.n_breaks = n.max(2);
111        self
112    }
113
114    /// ggplot2's `scale_x_time()` / `scale_y_time()` (#408): the values are
115    /// seconds (e.g. since midnight, `hms`), the breaks fall on round time
116    /// steps (1 s … 1 h … 1 day) and the labels read `HH:MM:SS`.
117    pub fn time() -> Self {
118        let mut s = ScaleContinuous::new().with_label_formatter(super::util::format_hms);
119        s.time = true;
120        s
121    }
122
123    /// ggplot2's `scale_x_binned(n.breaks = n)` (#408): positions are cut
124    /// into round bins between about `n` breaks (right-closed, like ggplot2) and every value
125    /// sits at its bin's midpoint before the stat runs — so `geom_bar()`
126    /// draws a histogram. The axis breaks are the bin edges. Explicit
127    /// `with_limits` set the binned range.
128    pub fn binned(n: usize) -> Self {
129        let mut s = ScaleContinuous::new();
130        s.binned = Some(n.max(1));
131        s
132    }
133
134    /// ggplot2's `guide = guide_axis_logticks()` (#409): on a log10 axis,
135    /// extra ticks at every 1–9 × 10ᵏ — long at the decades, mid at the 5s,
136    /// short otherwise. No effect on other transforms.
137    pub fn with_log_ticks(mut self) -> Self {
138        self.log_ticks = true;
139        self
140    }
141
142    pub fn with_breaks(mut self, breaks: Vec<f64>) -> Self {
143        self.custom_breaks = Some(breaks);
144        self
145    }
146
147    /// Set custom labels for breaks. Must match the number of breaks.
148    pub fn with_labels(mut self, labels: Vec<String>) -> Self {
149        self.custom_labels = Some(labels);
150        self
151    }
152
153    /// Labels for the custom breaks that depend on the locale (month or
154    /// weekday names): `f` gets the scale's locale — the plot's
155    /// [`GGPlot::locale`](crate::plot::GGPlot::locale) unless set on the
156    /// scale — or `None`. Takes precedence over [`with_labels`](Self::with_labels).
157    pub fn with_localized_labels<F>(mut self, f: F) -> Self
158    where
159        F: Fn(Option<&crate::locale::Locale>) -> Vec<String> + Send + Sync + 'static,
160    {
161        self.localized_labels = Some(Arc::new(f));
162        self
163    }
164
165    /// Set the expansion multiplier and additive constant.
166    /// Like R's `expand = c(mult, add)`, applied symmetrically. Default `(0.05, 0.0)`.
167    pub fn with_expand(mut self, mult: f64, add: f64) -> Self {
168        self.expand = (mult, add, mult, add);
169        self
170    }
171
172    /// Per-side expansion (R's `expansion(mult = c(l, u), add = c(l, u))`):
173    /// separate multiplicative/additive expansion for the lower and upper ends.
174    pub fn with_expand_sides(
175        mut self,
176        mult_lower: f64,
177        add_lower: f64,
178        mult_upper: f64,
179        add_upper: f64,
180    ) -> Self {
181        self.expand = (mult_lower, add_lower, mult_upper, add_upper);
182        self
183    }
184
185    /// Place this axis on the opposite side (x → top, y → right).
186    pub fn with_position_opposite(mut self) -> Self {
187        self.position_opposite = true;
188        self
189    }
190
191    /// Set a label formatter. Accepts a plain `fn` (e.g. `label_comma`) or a
192    /// configurable formatter such as `label_si()` / `label_number(...)`.
193    pub fn with_label_formatter<F>(mut self, f: F) -> Self
194    where
195        F: Fn(f64) -> String + Send + Sync + 'static,
196    {
197        self.label_formatter = Some(std::sync::Arc::new(f));
198        self
199    }
200
201    /// Set a formatter for [`format_value`](Scale::format_value) — a
202    /// per-mark `data-fvalue` or tooltip-template value — distinct from
203    /// [`with_label_formatter`](Self::with_label_formatter), which only
204    /// affects axis break labels. Falls back to the label formatter, then
205    /// the locale default, when unset.
206    pub fn with_value_formatter<F>(mut self, f: F) -> Self
207    where
208        F: Fn(f64) -> String + Send + Sync + 'static,
209    {
210        self.value_formatter = Some(std::sync::Arc::new(f));
211        self
212    }
213
214    /// Render the default break labels with `locale`'s decimal and thousands
215    /// marks (`1.000`, `2,5` in de-DE). Overrides the plot locale
216    /// ([`GGPlot::locale`](crate::GGPlot::locale)); a custom
217    /// [`with_label_formatter`](Self::with_label_formatter) still wins.
218    pub fn with_locale(mut self, locale: crate::locale::Locale) -> Self {
219        self.locale = Some(locale);
220        self
221    }
222
223    /// Add a secondary axis with a transformation function.
224    pub fn with_sec_axis(mut self, sec: SecAxis) -> Self {
225        self.sec_axis = Some(sec);
226        self
227    }
228
229    /// Get the secondary axis, if any.
230    pub fn sec_axis(&self) -> Option<&SecAxis> {
231        self.sec_axis.as_ref()
232    }
233
234    fn format_label(&self, v: f64) -> String {
235        if let Some(f) = &self.label_formatter {
236            f(v)
237        } else {
238            super::util::format_number_opt(v, self.locale.as_ref())
239        }
240    }
241
242    fn expanded_range(&self) -> (f64, f64) {
243        let range = self.max - self.min;
244        let (ml, al, mu, au) = self.expand;
245        (self.min - range * ml - al, self.max + range * mu + au)
246    }
247
248    /// Whether this axis should be drawn on the opposite side (x → top, y → right).
249    pub fn is_opposite(&self) -> bool {
250        self.position_opposite
251    }
252}
253
254impl Default for ScaleContinuous {
255    fn default() -> Self {
256        Self::new()
257    }
258}
259
260impl Scale for ScaleContinuous {
261    fn set_default_locale(&mut self, locale: &crate::locale::Locale) {
262        if self.locale.is_none() {
263            self.locale = Some(locale.clone());
264        }
265    }
266
267    fn locale(&self) -> Option<&crate::locale::Locale> {
268        self.locale.as_ref()
269    }
270
271    fn format_value(&self, value: &Value) -> Option<String> {
272        let f = value.as_f64()?;
273        if !f.is_finite() {
274            return None;
275        }
276        // The built data holds the value in the scale's transformed space
277        // (e.g. `scale_y_log10` stores `log10(y)`), but `data-fvalue`/a
278        // tooltip template is meant to be the human-readable number —
279        // `y = 100` under log10, not `2` — the same inverse the break
280        // labels already apply (`breaks()` below calls `format_label`
281        // with `scale_transform.inverse(v)`, never raw `v`).
282        let f = self.scale_transform.inverse(f);
283        if !f.is_finite() {
284            return None;
285        }
286        Some(match &self.value_formatter {
287            Some(fmt) => fmt(f),
288            None => self.format_label(f),
289        })
290    }
291
292    fn aesthetic(&self) -> Aesthetic {
293        self.aesthetic.clone()
294    }
295
296    fn train(&mut self, values: &[Value]) {
297        for v in values {
298            if let Some(f) = v.as_f64() {
299                if f.is_finite() {
300                    if f < self.min {
301                        self.min = f;
302                    }
303                    if f > self.max {
304                        self.max = f;
305                    }
306                }
307            }
308        }
309        self.trained = true;
310    }
311
312    fn map(&self, value: &Value) -> f64 {
313        let f = match value.as_f64() {
314            Some(f) => f,
315            None => return 0.0,
316        };
317        // ±Inf means "panel edge" (ggplot2): -Inf → lower edge, +Inf → upper.
318        if f == f64::INFINITY {
319            return 1.0;
320        }
321        if f == f64::NEG_INFINITY {
322            return 0.0;
323        }
324        // An untrained scale (empty data) has no domain — centre everything
325        // rather than producing NaN.
326        if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
327            return 0.5;
328        }
329        let (emin, emax) = self.expanded_range();
330        let range = emax - emin;
331        if range.abs() < f64::EPSILON {
332            0.5
333        } else {
334            (f - emin) / range
335        }
336    }
337
338    fn expanded_domain(&self) -> Option<(f64, f64)> {
339        if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
340            return None;
341        }
342        let (a, b) = self.expanded_range();
343        if !(a.is_finite() && b.is_finite()) {
344            return None;
345        }
346        if (b - a).abs() < f64::EPSILON {
347            // A degenerate domain maps everything to the panel centre.
348            return Some((a - 0.5, b + 0.5));
349        }
350        Some((a, b))
351    }
352
353    fn breaks(&self) -> Vec<(f64, String)> {
354        if !self.trained || self.min > self.max {
355            return vec![];
356        }
357
358        // Use custom breaks if provided
359        if let Some(ref custom) = self.custom_breaks {
360            let localized = self
361                .localized_labels
362                .as_ref()
363                .map(|f| f(self.locale.as_ref()));
364            let custom_labels = localized.as_ref().or(self.custom_labels.as_ref());
365            return custom
366                .iter()
367                .enumerate()
368                .map(|(i, &v)| {
369                    let pos = self.map(&Value::Float(v));
370                    let label = if let Some(labels) = custom_labels {
371                        labels
372                            .get(i)
373                            .cloned()
374                            .unwrap_or_else(|| self.format_label(v))
375                    } else {
376                        self.format_label(self.scale_transform.inverse(v))
377                    };
378                    (pos, label)
379                })
380                .collect();
381        }
382
383        if super::util::is_degenerate_range(self.min, self.max) {
384            let label = self.format_label(self.scale_transform.inverse(self.min));
385            return vec![(0.5, label)];
386        }
387
388        // Extended-Wilkinson breaks over the data range (matching ggplot2's
389        // scales::extended_breaks), keeping those within the expanded (visible)
390        // range. Labels show the original (inverse-transformed) value.
391        let (emin, emax) = self.expanded_range();
392        if !self.bin_edges.is_empty() {
393            let shown: Vec<f64> = self
394                .bin_edges
395                .iter()
396                .map(|&v| self.scale_transform.inverse(v))
397                .collect();
398            let labels: Vec<String> = match &self.label_formatter {
399                Some(f) => shown.iter().map(|&v| f(v)).collect(),
400                None => super::util::format_numbers_opt(&shown, self.locale.as_ref()),
401            };
402            return self
403                .bin_edges
404                .iter()
405                .zip(labels)
406                .map(|(&v, l)| (self.map(&Value::Float(v)), l))
407                .collect();
408        }
409        let tol = (emax - emin).abs() * 1e-9;
410        let breaks: Vec<f64> = if self.scale_transform.uses_data_space_breaks() {
411            // Log/sqrt axes: pick round ticks in *data* space, then forward-
412            // transform them back onto the scale — not `extended_breaks`
413            // directly on the transformed range, which gives "nice" numbers
414            // in log/sqrt space that read as noise once inverse-transformed
415            // (half-decades like 316.23 under log10, squares like 625 under
416            // sqrt).
417            let data_min = self.scale_transform.inverse(self.min);
418            let data_max = self.scale_transform.inverse(self.max);
419            if data_min.is_finite() && data_max.is_finite() && data_max > data_min {
420                self.scale_transform
421                    .breaks_in_data_space(data_min, data_max, self.n_breaks)
422                    .into_iter()
423                    .map(|d| self.scale_transform.apply(d))
424                    .filter(|&v| v.is_finite() && v >= emin - tol && v <= emax + tol)
425                    .collect()
426            } else {
427                vec![]
428            }
429        } else {
430            if self.time {
431                super::util::time_breaks(self.min, self.max, self.n_breaks)
432            } else {
433                extended_breaks(self.min, self.max, self.n_breaks)
434            }
435            .into_iter()
436            .filter(|&v| v >= emin - tol && v <= emax + tol)
437            .collect()
438        };
439        let shown: Vec<f64> = breaks
440            .iter()
441            .map(|&v| self.scale_transform.inverse(v))
442            .collect();
443        // Without a custom formatter, label the breaks jointly so neighbours
444        // stay distinguishable on narrow ranges.
445        let labels: Vec<String> = match &self.label_formatter {
446            Some(f) => shown.iter().map(|&v| f(v)).collect(),
447            None => super::util::format_numbers_opt(&shown, self.locale.as_ref()),
448        };
449        breaks
450            .iter()
451            .zip(labels)
452            .map(|(&v, label)| (self.map(&Value::Float(v)), label))
453            .collect()
454    }
455
456    fn name(&self) -> &str {
457        &self.name
458    }
459
460    fn set_name(&mut self, name: &str) {
461        self.name = name.to_string();
462    }
463
464    fn transform(&self, value: &Value) -> Value {
465        self.scale_transform.transform_value(value)
466    }
467
468    fn set_log_ticks(&mut self, on: bool) {
469        self.log_ticks = on;
470    }
471
472    fn log_ticks(&self) -> Vec<(f64, u8)> {
473        if !self.log_ticks
474            || !self.trained
475            || !matches!(self.scale_transform, ScaleTransform::Log10)
476        {
477            return Vec::new();
478        }
479        let (lo, hi) = self.expanded_range();
480        if !(lo.is_finite() && hi.is_finite()) || hi <= lo || hi - lo > 30.0 {
481            return Vec::new();
482        }
483        let mut out = Vec::new();
484        for k in (lo.floor() as i64)..=(hi.ceil() as i64) {
485            for m in 1..=9u32 {
486                let t = k as f64 + (m as f64).log10();
487                if t >= lo - 1e-9 && t <= hi + 1e-9 {
488                    let kind = match m {
489                        1 => 0,
490                        5 => 1,
491                        _ => 2,
492                    };
493                    out.push((self.map(&Value::Float(t)), kind));
494                }
495            }
496        }
497        out
498    }
499
500    fn bin_positions(&mut self, values: &[Value]) -> Option<Vec<Value>> {
501        let n = self.binned?;
502        if self.bin_edges.is_empty() {
503            let (lo, hi) = match self.filter_limits() {
504                Some(l) => l,
505                None => {
506                    let fin = values
507                        .iter()
508                        .filter_map(Value::as_f64)
509                        .filter(|f| f.is_finite());
510                    let (lo, hi) = fin.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), f| {
511                        (a.min(f), b.max(f))
512                    });
513                    if lo > hi {
514                        return None;
515                    }
516                    (lo, hi)
517                }
518            };
519            let (lo, hi) = if hi > lo {
520                (lo, hi)
521            } else {
522                (lo - 0.5, hi + 0.5)
523            };
524            let mut e = super::util::extended_breaks(lo, hi, n);
525            if e.len() < 2 {
526                e = vec![lo, hi];
527            }
528            let step = e[1] - e[0];
529            while e[0] > lo + step * 1e-9 {
530                e.insert(0, e[0] - step);
531            }
532            while *e.last().unwrap() < hi - step * 1e-9 {
533                e.push(e.last().unwrap() + step);
534            }
535            // The axis spans whole bins.
536            self.min = self.min.min(e[0]);
537            self.max = self.max.max(*e.last().unwrap());
538            self.trained = true;
539            self.bin_edges = e;
540        }
541        let e = &self.bin_edges;
542        Some(
543            values
544                .iter()
545                .map(|v| match v.as_f64().filter(|f| f.is_finite()) {
546                    Some(f) => {
547                        // Right-closed bins (lo, hi]; the first includes lo.
548                        let k = e.windows(2).position(|w| f <= w[1]).unwrap_or(e.len() - 2);
549                        Value::Float((e[k] + e[k + 1]) / 2.0)
550                    }
551                    None => v.clone(),
552                })
553                .collect(),
554        )
555    }
556
557    fn sec_axis(&self) -> Option<&SecAxis> {
558        self.sec_axis.as_ref()
559    }
560
561    fn set_limits(&mut self, min: f64, max: f64) {
562        self.min = min;
563        self.max = max;
564        self.trained = true;
565    }
566
567    fn set_expand(&mut self, mult: f64, add: f64) {
568        self.expand = (mult, add, mult, add);
569    }
570
571    fn clear_expand(&mut self, lower: bool, upper: bool) {
572        if lower {
573            (self.expand.0, self.expand.1) = (0.0, 0.0);
574        }
575        if upper {
576            (self.expand.2, self.expand.3) = (0.0, 0.0);
577        }
578    }
579
580    fn filter_limits(&self) -> Option<(f64, f64)> {
581        if self.filter_oob && self.trained {
582            Some((self.min, self.max))
583        } else {
584            None
585        }
586    }
587
588    fn domain(&self) -> Option<(f64, f64)> {
589        if self.trained {
590            Some((self.min, self.max))
591        } else {
592            None
593        }
594    }
595
596    fn axis_position_opposite(&self) -> bool {
597        self.position_opposite
598    }
599
600    fn clone_box(&self) -> Box<dyn Scale> {
601        Box::new(self.clone())
602    }
603
604    fn reset_training(&mut self) {
605        self.min = f64::INFINITY;
606        self.max = f64::NEG_INFINITY;
607        self.trained = false;
608    }
609}