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    /// Set by [`with_limits`](Self::with_limits): the domain is fixed and
14    /// no longer widened by [`train`](Scale::train) — mirrors
15    /// `ScaleLinewidth::with_limits` (sipemu/ggplot-rs#270).
16    limits_fixed: bool,
17    /// Output range in pixels: (min_radius, max_radius).
18    range: (f64, f64),
19    /// Locale for the default break labels (`None` = default marks).
20    locale: Option<crate::locale::Locale>,
21    /// `scale_size_area(max_size)` (B3-6): private, set only via
22    /// [`area`](Self::area). `None` (the default) keeps the linear radius
23    /// mapping (`range`); `Some(max_size)` maps so *area* — not radius —
24    /// is proportional to the value, with 0 mapping to 0.
25    area_max: Option<f64>,
26}
27
28impl ScaleSizeContinuous {
29    pub fn new() -> Self {
30        ScaleSizeContinuous {
31            name: String::new(),
32            min: f64::INFINITY,
33            max: f64::NEG_INFINITY,
34            trained: false,
35            limits_fixed: false,
36            range: (1.0, 6.0),
37            locale: None,
38            area_max: None,
39        }
40    }
41
42    pub fn with_range(mut self, min: f64, max: f64) -> Self {
43        self.range = (min, max);
44        self
45    }
46
47    /// Fix the input domain; unlike the default (which widens to whatever
48    /// `train` sees), values outside `[min, max]` are clamped to the
49    /// nearest bound and later `train` calls don't widen it further
50    /// (#270) — a size encoding shared across facets/frames/renders needs
51    /// one domain fixed in advance, the same way `ScaleLinewidth::
52    /// with_limits` already lets linewidth do.
53    pub fn with_limits(mut self, min: f64, max: f64) -> Self {
54        self.min = min;
55        self.max = max;
56        self.trained = true;
57        self.limits_fixed = true;
58        self
59    }
60
61    pub fn with_name(mut self, name: &str) -> Self {
62        self.name = name.to_string();
63        self
64    }
65
66    /// `scale_size_area(max_size)` (ggplot2): maps so that *area* — not
67    /// radius — is proportional to `|value|`, with 0 mapping to 0:
68    /// `size = max_size · √(|value| / max(|lo|, |hi|))`, where `lo`/`hi`
69    /// are the trained domain. A non-finite or non-positive `max_size` is
70    /// corrected to the ggplot2 default (6) by [`validate`](Self::validate)
71    /// and by the mapping itself.
72    pub fn area(mut self, max_size: f64) -> Self {
73        self.area_max = Some(max_size);
74        self
75    }
76
77    fn sanitized_area_max(max_size: f64) -> f64 {
78        if max_size.is_finite() && max_size > 0.0 {
79            max_size
80        } else {
81            6.0
82        }
83    }
84}
85
86impl Default for ScaleSizeContinuous {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl Scale for ScaleSizeContinuous {
93    fn set_default_locale(&mut self, locale: &crate::locale::Locale) {
94        if self.locale.is_none() {
95            self.locale = Some(locale.clone());
96        }
97    }
98
99    fn locale(&self) -> Option<&crate::locale::Locale> {
100        self.locale.as_ref()
101    }
102
103    fn format_value(&self, value: &Value) -> Option<String> {
104        let f = value.as_f64()?;
105        if !f.is_finite() {
106            return None;
107        }
108        Some(super::util::format_number_opt(f, self.locale.as_ref()))
109    }
110
111    fn aesthetic(&self) -> Aesthetic {
112        Aesthetic::Size
113    }
114
115    fn train(&mut self, values: &[Value]) {
116        if self.limits_fixed {
117            self.trained = true;
118            return;
119        }
120        for v in values {
121            if let Some(f) = v.as_f64() {
122                if f.is_finite() {
123                    if f < self.min {
124                        self.min = f;
125                    }
126                    if f > self.max {
127                        self.max = f;
128                    }
129                }
130            }
131        }
132        self.trained = true;
133    }
134
135    fn map(&self, value: &Value) -> f64 {
136        let f = match value.as_f64() {
137            Some(f) => f,
138            None => return 0.0,
139        };
140        if !f.is_finite() {
141            return 0.0;
142        }
143        let range = self.max - self.min;
144        if range.abs() < f64::EPSILON {
145            0.5
146        } else {
147            // #270: a `with_limits` domain can be narrower than the data
148            // (unlike the default, data-trained domain, which never is)
149            // — clamping to [0, 1] here squishes an out-of-domain value
150            // to the nearest end, the same as `ScaleLinewidth`.
151            ((f - self.min) / range).clamp(0.0, 1.0)
152        }
153    }
154
155    fn breaks(&self) -> Vec<(f64, String)> {
156        if !self.trained || self.min > self.max {
157            return vec![];
158        }
159        let range = self.max - self.min;
160        if super::util::is_degenerate_range(self.min, self.max) {
161            return vec![(
162                0.5,
163                super::util::format_number_opt(self.min, self.locale.as_ref()),
164            )];
165        }
166        let n_breaks = 4;
167        let step = super::util::nice_step(range / n_breaks as f64);
168        let start = (self.min / step).ceil() * step;
169        let values = super::util::stepped_breaks(start, self.max, step);
170        let labels = super::util::format_numbers_opt(&values, self.locale.as_ref());
171        values
172            .iter()
173            .zip(labels)
174            .map(|(&v, l)| (self.map(&Value::Float(v)), l))
175            .collect()
176    }
177
178    fn name(&self) -> &str {
179        &self.name
180    }
181
182    fn set_name(&mut self, name: &str) {
183        self.name = name.to_string();
184    }
185
186    fn map_to_size(&self, value: &Value) -> Option<f64> {
187        if let Some(max_size) = self.area_max {
188            let v = value.as_f64()?;
189            if !v.is_finite() {
190                return None;
191            }
192            let max_size = Self::sanitized_area_max(max_size);
193            let denom = self.min.abs().max(self.max.abs());
194            return Some(if denom < f64::EPSILON {
195                0.0
196            } else {
197                max_size * (v.abs() / denom).sqrt()
198            });
199        }
200        let t = self.map(value);
201        let (lo, hi) = self.range;
202        Some(lo + t * (hi - lo))
203    }
204
205    fn validate(&self, warnings: &mut Vec<String>) {
206        if let Some(max_size) = self.area_max {
207            if !(max_size.is_finite() && max_size > 0.0) {
208                warnings.push(format!(
209                    "scale_size_area: max_size({max_size}) must be finite and positive; falling back to 6"
210                ));
211            }
212        }
213    }
214
215    /// #180: under `scale_size_area` (`area_max` set) this must use the
216    /// same area-proportional formula as [`map_to_size`](Self::map_to_size)
217    /// — not the plain linear `range` — or a graduated-symbol legend's
218    /// circles don't match the radii the data itself draws at (a legend
219    /// key for "break at 50% of the domain" rendered at 50% of the
220    /// *radius* range, when the data point at that value is actually drawn
221    /// at `max_size · √0.5 ≈ 71%` of `max_size`, say).
222    fn map_fraction_to_size(&self, fraction: f64) -> Option<f64> {
223        let t = fraction.clamp(0.0, 1.0);
224        if let Some(max_size) = self.area_max {
225            if !self.trained || self.max <= self.min {
226                return Some(0.0);
227            }
228            let max_size = Self::sanitized_area_max(max_size);
229            let v = self.min + t * (self.max - self.min);
230            let denom = self.min.abs().max(self.max.abs());
231            return Some(if denom < f64::EPSILON {
232                0.0
233            } else {
234                max_size * (v.abs() / denom).sqrt()
235            });
236        }
237        let (lo, hi) = self.range;
238        Some(lo + t * (hi - lo))
239    }
240
241    fn clone_box(&self) -> Box<dyn Scale> {
242        Box::new(self.clone())
243    }
244
245    fn reset_training(&mut self) {
246        if !self.limits_fixed {
247            self.min = f64::INFINITY;
248            self.max = f64::NEG_INFINITY;
249            self.trained = false;
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn format_value_formats_a_finite_number_and_rejects_non_finite() {
260        let s = ScaleSizeContinuous::new();
261        assert_eq!(
262            s.format_value(&Value::Float(1234.0)),
263            Some("1234".to_string())
264        );
265        assert_eq!(s.format_value(&Value::Float(f64::NAN)), None);
266        assert_eq!(s.format_value(&Value::Na), None);
267    }
268
269    #[test]
270    fn scale_size_area_matches_r_ggplot2() {
271        let mut s = ScaleSizeContinuous::new().area(6.0);
272        s.train(&[
273            Value::Float(0.0),
274            Value::Float(1.0),
275            Value::Float(4.0),
276            Value::Float(16.0),
277        ]);
278        let got: Vec<f64> = [0.0, 1.0, 4.0, 16.0]
279            .iter()
280            .map(|&v| s.map_to_size(&Value::Float(v)).unwrap())
281            .collect();
282        for (g, e) in got.iter().zip([0.0, 1.5, 3.0, 6.0]) {
283            assert!((g - e).abs() < 1e-9, "got {got:?}");
284        }
285    }
286
287    /// #180: a graduated-symbol legend (`draw_legend_swatch` calls
288    /// `map_fraction_to_size(pos)` where `pos = map(value)`) must draw the
289    /// same radius `map_to_size(value)` does for that value — not the
290    /// unrelated default linear `range`.
291    #[test]
292    fn scale_size_area_legend_fraction_matches_map_to_size() {
293        let mut s = ScaleSizeContinuous::new().area(6.0);
294        s.train(&[Value::Float(0.0), Value::Float(16.0)]);
295        for v in [0.0, 1.0, 4.0, 9.0, 16.0] {
296            let direct = s.map_to_size(&Value::Float(v)).unwrap();
297            let pos = s.map(&Value::Float(v));
298            let via_fraction = s.map_fraction_to_size(pos).unwrap();
299            assert!(
300                (direct - via_fraction).abs() < 1e-9,
301                "value {v}: map_to_size={direct}, map_fraction_to_size={via_fraction}"
302            );
303        }
304    }
305
306    /// Without `.area(..)` (the plain linear size scale), `range` still
307    /// drives both the data mapping and the legend fraction mapping.
308    #[test]
309    fn linear_size_legend_fraction_still_uses_range() {
310        let mut s = ScaleSizeContinuous::new().with_range(2.0, 10.0);
311        s.train(&[Value::Float(0.0), Value::Float(100.0)]);
312        assert_eq!(s.map_fraction_to_size(0.0), Some(2.0));
313        assert_eq!(s.map_fraction_to_size(1.0), Some(10.0));
314        assert_eq!(s.map_fraction_to_size(0.5), Some(6.0));
315    }
316
317    #[test]
318    fn scale_size_area_rejects_na_and_non_finite() {
319        let mut s = ScaleSizeContinuous::new().area(6.0);
320        s.train(&[Value::Float(0.0), Value::Float(10.0)]);
321        assert_eq!(s.map_to_size(&Value::Na), None);
322        assert_eq!(s.map_to_size(&Value::Float(f64::NAN)), None);
323    }
324
325    #[test]
326    fn scale_size_area_bad_max_warns_and_falls_back_to_six() {
327        let mut s = ScaleSizeContinuous::new().area(-1.0);
328        s.train(&[Value::Float(0.0), Value::Float(16.0)]);
329        let mut warnings = Vec::new();
330        s.validate(&mut warnings);
331        assert!(warnings.iter().any(|w| w.contains("max_size")));
332        // Falls back to the ggplot2 default (6) regardless of `validate`
333        // being called — `map_to_size` guards it inline too.
334        assert_eq!(
335            s.map_to_size(&Value::Float(16.0)),
336            Some(6.0),
337            "falls back to max_size 6"
338        );
339    }
340
341    // ─── #270: `with_limits` ───────────────────────────────────────────
342
343    /// Two renders of different data, sharing the same `with_limits`
344    /// domain, give identical radii for equal values and identical
345    /// legend breaks — the motivating case (a size shared across
346    /// frames/facets/renders).
347    #[test]
348    fn with_limits_gives_identical_radii_and_breaks_across_different_data() {
349        let mut a = ScaleSizeContinuous::new()
350            .with_range(2.0, 10.0)
351            .with_limits(0.0, 100.0);
352        let mut b = ScaleSizeContinuous::new()
353            .with_range(2.0, 10.0)
354            .with_limits(0.0, 100.0);
355        a.train(&[Value::Float(1.0), Value::Float(2.0)]);
356        b.train(&[Value::Float(50.0), Value::Float(99.0), Value::Float(0.0)]);
357
358        for v in [0.0, 25.0, 50.0, 75.0, 100.0] {
359            assert_eq!(
360                a.map_to_size(&Value::Float(v)),
361                b.map_to_size(&Value::Float(v)),
362                "value {v} should map to the same radius regardless of trained data"
363            );
364        }
365        assert_eq!(a.breaks(), b.breaks());
366    }
367
368    #[test]
369    fn with_limits_clamps_out_of_range_values() {
370        let s = ScaleSizeContinuous::new()
371            .with_range(2.0, 10.0)
372            .with_limits(0.0, 100.0);
373        assert_eq!(s.map_to_size(&Value::Float(-50.0)), Some(2.0));
374        assert_eq!(s.map_to_size(&Value::Float(500.0)), Some(10.0));
375    }
376
377    /// `area(max_size)` under `with_limits`: the denominator is
378    /// `max(|min|, |max|)` of the *fixed* limits, not whatever happens to
379    /// be trained — so two different data batches sharing limits give
380    /// the same area-proportional radius for the same value.
381    #[test]
382    fn with_limits_and_area_share_radii_across_different_data() {
383        let mut a = ScaleSizeContinuous::new().area(6.0).with_limits(0.0, 16.0);
384        let mut b = ScaleSizeContinuous::new().area(6.0).with_limits(0.0, 16.0);
385        a.train(&[Value::Float(1.0)]);
386        b.train(&[Value::Float(1.0), Value::Float(16.0), Value::Float(8.0)]);
387        for v in [0.0, 1.0, 4.0, 16.0] {
388            assert_eq!(
389                a.map_to_size(&Value::Float(v)),
390                b.map_to_size(&Value::Float(v))
391            );
392        }
393    }
394
395    #[test]
396    fn with_limits_training_does_not_change_map_to_size() {
397        let mut fixed = ScaleSizeContinuous::new()
398            .with_range(1.0, 6.0)
399            .with_limits(0.0, 10.0);
400        let before = fixed.map_to_size(&Value::Float(5.0));
401        fixed.train(&[Value::Float(-1000.0), Value::Float(1000.0)]);
402        let after = fixed.map_to_size(&Value::Float(5.0));
403        assert_eq!(before, after, "training must not widen a fixed domain");
404        assert_eq!(before, Some(3.5));
405    }
406}