1use crate::aes::Aesthetic;
2use crate::data::Value;
3
4use super::Scale;
5
6#[derive(Clone, Debug)]
8pub struct ScaleSizeContinuous {
9 name: String,
10 min: f64,
11 max: f64,
12 trained: bool,
13 limits_fixed: bool,
17 range: (f64, f64),
19 locale: Option<crate::locale::Locale>,
21 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 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 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 ((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 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 #[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 #[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 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 #[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 #[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}