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    /// #180: under `scale_size_area` (`area_max` set) this must use the
186    /// same area-proportional formula as [`map_to_size`](Self::map_to_size)
187    /// — not the plain linear `range` — or a graduated-symbol legend's
188    /// circles don't match the radii the data itself draws at (a legend
189    /// key for "break at 50% of the domain" rendered at 50% of the
190    /// *radius* range, when the data point at that value is actually drawn
191    /// at `max_size · √0.5 ≈ 71%` of `max_size`, say).
192    fn map_fraction_to_size(&self, fraction: f64) -> Option<f64> {
193        let t = fraction.clamp(0.0, 1.0);
194        if let Some(max_size) = self.area_max {
195            if !self.trained || self.max <= self.min {
196                return Some(0.0);
197            }
198            let max_size = Self::sanitized_area_max(max_size);
199            let v = self.min + t * (self.max - self.min);
200            let denom = self.min.abs().max(self.max.abs());
201            return Some(if denom < f64::EPSILON {
202                0.0
203            } else {
204                max_size * (v.abs() / denom).sqrt()
205            });
206        }
207        let (lo, hi) = self.range;
208        Some(lo + t * (hi - lo))
209    }
210
211    fn clone_box(&self) -> Box<dyn Scale> {
212        Box::new(self.clone())
213    }
214
215    fn reset_training(&mut self) {
216        self.min = f64::INFINITY;
217        self.max = f64::NEG_INFINITY;
218        self.trained = false;
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn format_value_formats_a_finite_number_and_rejects_non_finite() {
228        let s = ScaleSizeContinuous::new();
229        assert_eq!(
230            s.format_value(&Value::Float(1234.0)),
231            Some("1234".to_string())
232        );
233        assert_eq!(s.format_value(&Value::Float(f64::NAN)), None);
234        assert_eq!(s.format_value(&Value::Na), None);
235    }
236
237    #[test]
238    fn scale_size_area_matches_r_ggplot2() {
239        let mut s = ScaleSizeContinuous::new().area(6.0);
240        s.train(&[
241            Value::Float(0.0),
242            Value::Float(1.0),
243            Value::Float(4.0),
244            Value::Float(16.0),
245        ]);
246        let got: Vec<f64> = [0.0, 1.0, 4.0, 16.0]
247            .iter()
248            .map(|&v| s.map_to_size(&Value::Float(v)).unwrap())
249            .collect();
250        for (g, e) in got.iter().zip([0.0, 1.5, 3.0, 6.0]) {
251            assert!((g - e).abs() < 1e-9, "got {got:?}");
252        }
253    }
254
255    /// #180: a graduated-symbol legend (`draw_legend_swatch` calls
256    /// `map_fraction_to_size(pos)` where `pos = map(value)`) must draw the
257    /// same radius `map_to_size(value)` does for that value — not the
258    /// unrelated default linear `range`.
259    #[test]
260    fn scale_size_area_legend_fraction_matches_map_to_size() {
261        let mut s = ScaleSizeContinuous::new().area(6.0);
262        s.train(&[Value::Float(0.0), Value::Float(16.0)]);
263        for v in [0.0, 1.0, 4.0, 9.0, 16.0] {
264            let direct = s.map_to_size(&Value::Float(v)).unwrap();
265            let pos = s.map(&Value::Float(v));
266            let via_fraction = s.map_fraction_to_size(pos).unwrap();
267            assert!(
268                (direct - via_fraction).abs() < 1e-9,
269                "value {v}: map_to_size={direct}, map_fraction_to_size={via_fraction}"
270            );
271        }
272    }
273
274    /// Without `.area(..)` (the plain linear size scale), `range` still
275    /// drives both the data mapping and the legend fraction mapping.
276    #[test]
277    fn linear_size_legend_fraction_still_uses_range() {
278        let mut s = ScaleSizeContinuous::new().with_range(2.0, 10.0);
279        s.train(&[Value::Float(0.0), Value::Float(100.0)]);
280        assert_eq!(s.map_fraction_to_size(0.0), Some(2.0));
281        assert_eq!(s.map_fraction_to_size(1.0), Some(10.0));
282        assert_eq!(s.map_fraction_to_size(0.5), Some(6.0));
283    }
284
285    #[test]
286    fn scale_size_area_rejects_na_and_non_finite() {
287        let mut s = ScaleSizeContinuous::new().area(6.0);
288        s.train(&[Value::Float(0.0), Value::Float(10.0)]);
289        assert_eq!(s.map_to_size(&Value::Na), None);
290        assert_eq!(s.map_to_size(&Value::Float(f64::NAN)), None);
291    }
292
293    #[test]
294    fn scale_size_area_bad_max_warns_and_falls_back_to_six() {
295        let mut s = ScaleSizeContinuous::new().area(-1.0);
296        s.train(&[Value::Float(0.0), Value::Float(16.0)]);
297        let mut warnings = Vec::new();
298        s.validate(&mut warnings);
299        assert!(warnings.iter().any(|w| w.contains("max_size")));
300        // Falls back to the ggplot2 default (6) regardless of `validate`
301        // being called — `map_to_size` guards it inline too.
302        assert_eq!(
303            s.map_to_size(&Value::Float(16.0)),
304            Some(6.0),
305            "falls back to max_size 6"
306        );
307    }
308}