Skip to main content

ggplot_rs/scale/
size.rs

1use crate::aes::Aesthetic;
2use crate::data::Value;
3
4use super::Scale;
5
6/// Continuous size scale — maps data values to a point radius range.
7#[derive(Clone, Debug)]
8pub struct ScaleSizeContinuous {
9    name: String,
10    min: f64,
11    max: f64,
12    trained: bool,
13    /// Output range in pixels: (min_radius, max_radius).
14    range: (f64, f64),
15    /// Locale for the default break labels (`None` = default marks).
16    locale: Option<crate::locale::Locale>,
17    /// `scale_size_area(max_size)` (B3-6): private, set only via
18    /// [`area`](Self::area). `None` (the default) keeps the linear radius
19    /// mapping (`range`); `Some(max_size)` maps so *area* — not radius —
20    /// is proportional to the value, with 0 mapping to 0.
21    area_max: Option<f64>,
22}
23
24impl ScaleSizeContinuous {
25    pub fn new() -> Self {
26        ScaleSizeContinuous {
27            name: String::new(),
28            min: f64::INFINITY,
29            max: f64::NEG_INFINITY,
30            trained: false,
31            range: (1.0, 6.0),
32            locale: None,
33            area_max: None,
34        }
35    }
36
37    pub fn with_range(mut self, min: f64, max: f64) -> Self {
38        self.range = (min, max);
39        self
40    }
41
42    pub fn with_name(mut self, name: &str) -> Self {
43        self.name = name.to_string();
44        self
45    }
46
47    /// `scale_size_area(max_size)` (ggplot2): maps so that *area* — not
48    /// radius — is proportional to `|value|`, with 0 mapping to 0:
49    /// `size = max_size · √(|value| / max(|lo|, |hi|))`, where `lo`/`hi`
50    /// are the trained domain. A non-finite or non-positive `max_size` is
51    /// corrected to the ggplot2 default (6) by [`validate`](Self::validate)
52    /// and by the mapping itself.
53    pub fn area(mut self, max_size: f64) -> Self {
54        self.area_max = Some(max_size);
55        self
56    }
57
58    fn sanitized_area_max(max_size: f64) -> f64 {
59        if max_size.is_finite() && max_size > 0.0 {
60            max_size
61        } else {
62            6.0
63        }
64    }
65}
66
67impl Default for ScaleSizeContinuous {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Scale for ScaleSizeContinuous {
74    fn set_default_locale(&mut self, locale: &crate::locale::Locale) {
75        if self.locale.is_none() {
76            self.locale = Some(locale.clone());
77        }
78    }
79
80    fn locale(&self) -> Option<&crate::locale::Locale> {
81        self.locale.as_ref()
82    }
83
84    fn format_value(&self, value: &Value) -> Option<String> {
85        let f = value.as_f64()?;
86        if !f.is_finite() {
87            return None;
88        }
89        Some(super::util::format_number_opt(f, self.locale.as_ref()))
90    }
91
92    fn aesthetic(&self) -> Aesthetic {
93        Aesthetic::Size
94    }
95
96    fn train(&mut self, values: &[Value]) {
97        for v in values {
98            if let Some(f) = v.as_f64() {
99                if f.is_finite() {
100                    if f < self.min {
101                        self.min = f;
102                    }
103                    if f > self.max {
104                        self.max = f;
105                    }
106                }
107            }
108        }
109        self.trained = true;
110    }
111
112    fn map(&self, value: &Value) -> f64 {
113        let f = match value.as_f64() {
114            Some(f) => f,
115            None => return 0.0,
116        };
117        let range = self.max - self.min;
118        if range.abs() < f64::EPSILON {
119            0.5
120        } else {
121            ((f - self.min) / range).clamp(0.0, 1.0)
122        }
123    }
124
125    fn breaks(&self) -> Vec<(f64, String)> {
126        if !self.trained || self.min > self.max {
127            return vec![];
128        }
129        let range = self.max - self.min;
130        if super::util::is_degenerate_range(self.min, self.max) {
131            return vec![(
132                0.5,
133                super::util::format_number_opt(self.min, self.locale.as_ref()),
134            )];
135        }
136        let n_breaks = 4;
137        let step = super::util::nice_step(range / n_breaks as f64);
138        let start = (self.min / step).ceil() * step;
139        let values = super::util::stepped_breaks(start, self.max, step);
140        let labels = super::util::format_numbers_opt(&values, self.locale.as_ref());
141        values
142            .iter()
143            .zip(labels)
144            .map(|(&v, l)| (self.map(&Value::Float(v)), l))
145            .collect()
146    }
147
148    fn name(&self) -> &str {
149        &self.name
150    }
151
152    fn set_name(&mut self, name: &str) {
153        self.name = name.to_string();
154    }
155
156    fn map_to_size(&self, value: &Value) -> Option<f64> {
157        if let Some(max_size) = self.area_max {
158            let v = value.as_f64()?;
159            if !v.is_finite() {
160                return None;
161            }
162            let max_size = Self::sanitized_area_max(max_size);
163            let denom = self.min.abs().max(self.max.abs());
164            return Some(if denom < f64::EPSILON {
165                0.0
166            } else {
167                max_size * (v.abs() / denom).sqrt()
168            });
169        }
170        let t = self.map(value);
171        let (lo, hi) = self.range;
172        Some(lo + t * (hi - lo))
173    }
174
175    fn validate(&self, warnings: &mut Vec<String>) {
176        if let Some(max_size) = self.area_max {
177            if !(max_size.is_finite() && max_size > 0.0) {
178                warnings.push(format!(
179                    "scale_size_area: max_size({max_size}) must be finite and positive; falling back to 6"
180                ));
181            }
182        }
183    }
184
185    fn map_fraction_to_size(&self, fraction: f64) -> Option<f64> {
186        let (lo, hi) = self.range;
187        Some(lo + fraction.clamp(0.0, 1.0) * (hi - lo))
188    }
189
190    fn clone_box(&self) -> Box<dyn Scale> {
191        Box::new(self.clone())
192    }
193
194    fn reset_training(&mut self) {
195        self.min = f64::INFINITY;
196        self.max = f64::NEG_INFINITY;
197        self.trained = false;
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn format_value_formats_a_finite_number_and_rejects_non_finite() {
207        let s = ScaleSizeContinuous::new();
208        assert_eq!(
209            s.format_value(&Value::Float(1234.0)),
210            Some("1234".to_string())
211        );
212        assert_eq!(s.format_value(&Value::Float(f64::NAN)), None);
213        assert_eq!(s.format_value(&Value::Na), None);
214    }
215
216    #[test]
217    fn scale_size_area_matches_r_ggplot2() {
218        let mut s = ScaleSizeContinuous::new().area(6.0);
219        s.train(&[
220            Value::Float(0.0),
221            Value::Float(1.0),
222            Value::Float(4.0),
223            Value::Float(16.0),
224        ]);
225        let got: Vec<f64> = [0.0, 1.0, 4.0, 16.0]
226            .iter()
227            .map(|&v| s.map_to_size(&Value::Float(v)).unwrap())
228            .collect();
229        for (g, e) in got.iter().zip([0.0, 1.5, 3.0, 6.0]) {
230            assert!((g - e).abs() < 1e-9, "got {got:?}");
231        }
232    }
233
234    #[test]
235    fn scale_size_area_rejects_na_and_non_finite() {
236        let mut s = ScaleSizeContinuous::new().area(6.0);
237        s.train(&[Value::Float(0.0), Value::Float(10.0)]);
238        assert_eq!(s.map_to_size(&Value::Na), None);
239        assert_eq!(s.map_to_size(&Value::Float(f64::NAN)), None);
240    }
241
242    #[test]
243    fn scale_size_area_bad_max_warns_and_falls_back_to_six() {
244        let mut s = ScaleSizeContinuous::new().area(-1.0);
245        s.train(&[Value::Float(0.0), Value::Float(16.0)]);
246        let mut warnings = Vec::new();
247        s.validate(&mut warnings);
248        assert!(warnings.iter().any(|w| w.contains("max_size")));
249        // Falls back to the ggplot2 default (6) regardless of `validate`
250        // being called — `map_to_size` guards it inline too.
251        assert_eq!(
252            s.map_to_size(&Value::Float(16.0)),
253            Some(6.0),
254            "falls back to max_size 6"
255        );
256    }
257}