Skip to main content

ggplot_rs/scale/
linewidth.rs

1use std::sync::Arc;
2
3use crate::aes::Aesthetic;
4use crate::data::Value;
5
6use super::format::LabelFormatter;
7use super::transform::ScaleTransform;
8use super::Scale;
9
10/// Continuous linewidth scale — maps data values to a stroke-width range
11/// (R's `scale_linewidth`). Unlike the position (x/y) scales, `with_limits`
12/// squishes out-of-range values to the nearest limit rather than filtering
13/// them. `with_transform` is applied to the value and the limits *before*
14/// rescaling into `range`, inside [`map`](Scale::map) /
15/// [`map_to_size`](Scale::map_to_size) — it does *not* override
16/// [`Scale::transform`] (that's for pre-stat domain transforms like
17/// `scale_x_log10`, which linewidth has no use for).
18#[derive(Clone)]
19pub struct ScaleLinewidthContinuous {
20    name: String,
21    min: f64,
22    max: f64,
23    trained: bool,
24    /// Set by [`with_limits`](Self::with_limits): the domain is fixed and no
25    /// longer widened by [`train`](Scale::train) (ggplot2's non-position
26    /// scales squish to a fixed domain, unlike x/y's OOB filtering).
27    limits_fixed: bool,
28    /// Output range in pixels: (min_width, max_width).
29    range: (f64, f64),
30    scale_transform: ScaleTransform,
31    custom_breaks: Option<Vec<f64>>,
32    custom_labels: Option<Vec<String>>,
33    label_formatter: Option<LabelFormatter>,
34    /// Locale for the default break labels (`None` = default marks).
35    locale: Option<crate::locale::Locale>,
36}
37
38impl ScaleLinewidthContinuous {
39    pub fn new() -> Self {
40        ScaleLinewidthContinuous {
41            name: String::new(),
42            min: f64::INFINITY,
43            max: f64::NEG_INFINITY,
44            trained: false,
45            limits_fixed: false,
46            range: (1.0, 6.0),
47            scale_transform: ScaleTransform::Identity,
48            custom_breaks: None,
49            custom_labels: None,
50            label_formatter: None,
51            locale: None,
52        }
53    }
54
55    /// Output range in pixels. Default `(1.0, 6.0)`.
56    pub fn with_range(mut self, min: f64, max: f64) -> Self {
57        self.range = (min, max);
58        self
59    }
60
61    /// Fix the input domain; values outside `[min, max]` are squished to the
62    /// nearest bound rather than filtered.
63    pub fn with_limits(mut self, min: f64, max: f64) -> Self {
64        self.min = min;
65        self.max = max;
66        self.trained = true;
67        self.limits_fixed = true;
68        self
69    }
70
71    /// Transform the value and the (untransformed) `with_limits` domain
72    /// before rescaling into `range` (ggplot2's order for a scale `trans`):
73    /// `t = (T(v) - T(min)) / (T(max) - T(min))`. Default `Identity`, which
74    /// matches ggplot2's own default for `scale_linewidth` — a *linear*
75    /// `rescale_pal`, unlike `scale_size`'s default `area_pal` (sqrt-like,
76    /// since visual area grows with the square of a linear dimension).
77    /// Pass `Sqrt` to opt into that area-proportional look for linewidth
78    /// too.
79    pub fn with_transform(mut self, transform: ScaleTransform) -> Self {
80        self.scale_transform = transform;
81        self
82    }
83
84    /// Set custom break positions (data values where legend keys appear).
85    pub fn with_breaks(mut self, breaks: Vec<f64>) -> Self {
86        self.custom_breaks = Some(breaks);
87        self
88    }
89
90    /// Set custom labels for breaks. Must match the number of breaks.
91    pub fn with_labels(mut self, labels: Vec<String>) -> Self {
92        self.custom_labels = Some(labels);
93        self
94    }
95
96    /// Set a label formatter. Accepts a plain `fn` (e.g. `label_comma`) or a
97    /// configurable formatter such as `label_si()` / `label_number(...)`.
98    pub fn with_label_formatter<F>(mut self, f: F) -> Self
99    where
100        F: Fn(f64) -> String + Send + Sync + 'static,
101    {
102        self.label_formatter = Some(Arc::new(f));
103        self
104    }
105
106    pub fn with_name(mut self, name: &str) -> Self {
107        self.name = name.to_string();
108        self
109    }
110
111    /// Render the default break labels with `locale`'s decimal and
112    /// thousands marks. A custom [`with_label_formatter`](Self::with_label_formatter)
113    /// still wins.
114    pub fn with_locale(mut self, locale: crate::locale::Locale) -> Self {
115        self.locale = Some(locale);
116        self
117    }
118
119    fn format_label(&self, v: f64) -> String {
120        if let Some(f) = &self.label_formatter {
121            f(v)
122        } else {
123            super::util::format_number_opt(v, self.locale.as_ref())
124        }
125    }
126
127    /// Whether the configured transform is undefined somewhere in
128    /// `[min, max]` itself — e.g. `Log10` with a limit `<= 0` (common: a
129    /// flow-count column that legitimately contains 0, or an explicit
130    /// `with_limits(0, vmax)`). When this is true, no value in the domain
131    /// has a well-defined fraction under this transform; `validate()`
132    /// reports it once per scale, and `map_to_size` returns `None` for
133    /// every value (falling back to the geom's own width) rather than
134    /// guessing.
135    fn domain_transform_is_broken(&self) -> bool {
136        if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
137            return false;
138        }
139        let t_min = self.scale_transform.apply(self.min);
140        let t_max = self.scale_transform.apply(self.max);
141        !t_min.is_finite() || !t_max.is_finite()
142    }
143
144    /// Normalized fraction in `[0, 1]`, ggplot2's order: squish the value to
145    /// the domain, *then* transform both it and the (untransformed) limits,
146    /// then rescale over the transformed range — not the other way around.
147    /// Transforming an already-rescaled `[0, 1]` fraction (an earlier,
148    /// incorrect implementation) collapses most transforms: e.g.
149    /// `Log10`/`Reverse`/`Exp` of values near 0 and 1 all land near the same
150    /// couple of outputs, regardless of the input. Squishing *before* the
151    /// transform (rather than transforming the raw value) means an
152    /// ordinary out-of-limits value — e.g. `v = 0` under `Log10` with
153    /// `limits(1, 100)`, which is outside the domain but the domain itself
154    /// is fine — still squishes to the nearest end (here, the minimum
155    /// width) instead of landing on an undefined `Log10(0)`. The
156    /// transformed limits are sorted ascending before rescaling, so a
157    /// transform that reverses order (`Reverse`, or `Reciprocal` on
158    /// positive limits) still produces the expected result: the domain
159    /// minimum gets the *larger* output, since its transformed value is the
160    /// larger of the two transformed limits.
161    ///
162    /// Returns `0.5` (not NaN) when [`domain_transform_is_broken`](Self::domain_transform_is_broken)
163    /// — this keeps callers that need a finite `[0, 1]` position regardless
164    /// (e.g. a legend key's x/y) from ever seeing NaN; `map_to_size` makes
165    /// its own, separate check and returns `None` in that case instead of
166    /// this placeholder.
167    fn fraction(&self, value: &Value) -> f64 {
168        let f = match value.as_f64() {
169            Some(f) => f,
170            None => return 0.0,
171        };
172        if !f.is_finite() {
173            return 0.0;
174        }
175        if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
176            return 0.5;
177        }
178        if self.domain_transform_is_broken() {
179            return 0.5;
180        }
181        let (dom_lo, dom_hi) = (self.min.min(self.max), self.min.max(self.max));
182        let clamped = f.clamp(dom_lo, dom_hi);
183        let t_min = self.scale_transform.apply(self.min);
184        let t_max = self.scale_transform.apply(self.max);
185        let t_v = self.scale_transform.apply(clamped);
186        let (lo, hi) = (t_min.min(t_max), t_min.max(t_max));
187        let range = hi - lo;
188        if !t_v.is_finite() || range.abs() < f64::EPSILON {
189            return 0.5;
190        }
191        ((t_v - lo) / range).clamp(0.0, 1.0)
192    }
193}
194
195impl Default for ScaleLinewidthContinuous {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201impl Scale for ScaleLinewidthContinuous {
202    fn set_default_locale(&mut self, locale: &crate::locale::Locale) {
203        if self.locale.is_none() {
204            self.locale = Some(locale.clone());
205        }
206    }
207
208    fn locale(&self) -> Option<&crate::locale::Locale> {
209        self.locale.as_ref()
210    }
211
212    fn format_value(&self, value: &Value) -> Option<String> {
213        let f = value.as_f64()?;
214        if !f.is_finite() {
215            return None;
216        }
217        Some(self.format_label(f))
218    }
219
220    fn aesthetic(&self) -> Aesthetic {
221        Aesthetic::Linewidth
222    }
223
224    fn train(&mut self, values: &[Value]) {
225        if self.limits_fixed {
226            self.trained = true;
227            return;
228        }
229        for v in values {
230            if let Some(f) = v.as_f64() {
231                if f.is_finite() {
232                    if f < self.min {
233                        self.min = f;
234                    }
235                    if f > self.max {
236                        self.max = f;
237                    }
238                }
239            }
240        }
241        self.trained = true;
242    }
243
244    fn map(&self, value: &Value) -> f64 {
245        self.fraction(value)
246    }
247
248    fn breaks(&self) -> Vec<(f64, String)> {
249        if !self.trained || self.min > self.max {
250            return vec![];
251        }
252        if let Some(ref custom) = self.custom_breaks {
253            return custom
254                .iter()
255                .enumerate()
256                .map(|(i, &v)| {
257                    let pos = self.map(&Value::Float(v));
258                    let label = self
259                        .custom_labels
260                        .as_ref()
261                        .and_then(|labels| labels.get(i).cloned())
262                        .unwrap_or_else(|| self.format_label(v));
263                    (pos, label)
264                })
265                .collect();
266        }
267        if super::util::is_degenerate_range(self.min, self.max) {
268            return vec![(0.5, self.format_label(self.min))];
269        }
270        let n_breaks = 4;
271        let range = self.max - self.min;
272        let step = super::util::nice_step(range / n_breaks as f64);
273        let start = (self.min / step).ceil() * step;
274        let values = super::util::stepped_breaks(start, self.max, step);
275        values
276            .iter()
277            .map(|&v| (self.map(&Value::Float(v)), self.format_label(v)))
278            .collect()
279    }
280
281    fn name(&self) -> &str {
282        &self.name
283    }
284
285    fn set_name(&mut self, name: &str) {
286        self.name = name.to_string();
287    }
288
289    fn map_to_size(&self, value: &Value) -> Option<f64> {
290        // NA / non-numeric / non-finite: no width to report, so the caller
291        // (every geom calls this through `.unwrap_or(self.width)`) falls
292        // back to the geom's own configured width instead of silently
293        // getting the scale's minimum.
294        let f = value.as_f64()?;
295        if !f.is_finite() {
296            return None;
297        }
298        // The transform is undefined somewhere in the domain itself (e.g.
299        // Log10 with a limit <= 0): no well-defined width for *any* value,
300        // so every row falls back to the geom's own width too, instead of
301        // the previous behaviour of silently reporting the midpoint width.
302        // `validate()` reports this once per scale, at build time.
303        if self.domain_transform_is_broken() {
304            return None;
305        }
306        let t = self.fraction(value);
307        let (lo, hi) = self.range;
308        Some(lo + t * (hi - lo))
309    }
310
311    fn map_fraction_to_size(&self, fraction: f64) -> Option<f64> {
312        let (lo, hi) = self.range;
313        Some(lo + fraction.clamp(0.0, 1.0) * (hi - lo))
314    }
315
316    fn domain(&self) -> Option<(f64, f64)> {
317        if self.trained {
318            Some((self.min, self.max))
319        } else {
320            None
321        }
322    }
323
324    fn clone_box(&self) -> Box<dyn Scale> {
325        Box::new(self.clone())
326    }
327
328    fn reset_training(&mut self) {
329        if !self.limits_fixed {
330            self.min = f64::INFINITY;
331            self.max = f64::NEG_INFINITY;
332            self.trained = false;
333        }
334    }
335
336    fn validate(&self, warnings: &mut Vec<String>) {
337        if self.domain_transform_is_broken() {
338            warnings.push(format!(
339                "scale_linewidth: {:?} is undefined at the domain limits ({}, {}); \
340                 every row falls back to the geom's own linewidth instead of a mapped size \
341                 (ggplot2's equivalent: NA plus a warning)",
342                self.scale_transform, self.min, self.max
343            ));
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn format_value_falls_back_to_format_label_and_rejects_non_finite() {
354        let s = ScaleLinewidthContinuous::new().with_label_formatter(|v| format!("{v:.1}mm"));
355        assert_eq!(
356            s.format_value(&Value::Float(2.0)),
357            Some("2.0mm".to_string())
358        );
359        assert_eq!(s.format_value(&Value::Float(f64::NAN)), None);
360    }
361
362    #[test]
363    fn range_transform_and_limits_match_the_documented_formula() {
364        let vmax = 10.0;
365        let scale = ScaleLinewidthContinuous::new()
366            .with_range(0.75, 8.0)
367            .with_transform(ScaleTransform::Sqrt)
368            .with_limits(0.0, vmax);
369        for v in [0.0, 2.5, 5.0, 7.5, 10.0] {
370            let width = scale.map_to_size(&Value::Float(v)).unwrap();
371            let expected = 0.75 + 7.25 * (v / vmax).sqrt();
372            assert!(
373                (width - expected).abs() < 1e-9,
374                "v={v}: width={width}, expected={expected}"
375            );
376        }
377    }
378
379    #[test]
380    fn limits_squish_out_of_range_values() {
381        let scale = ScaleLinewidthContinuous::new()
382            .with_range(1.0, 6.0)
383            .with_limits(0.0, 10.0);
384        assert_eq!(scale.map_to_size(&Value::Float(-5.0)), Some(1.0));
385        assert_eq!(scale.map_to_size(&Value::Float(50.0)), Some(6.0));
386    }
387
388    #[test]
389    fn limits_fixed_domain_is_not_widened_by_training() {
390        let mut scale = ScaleLinewidthContinuous::new().with_limits(0.0, 10.0);
391        scale.train(&[Value::Float(-100.0), Value::Float(100.0)]);
392        assert_eq!(scale.domain(), Some((0.0, 10.0)));
393    }
394
395    #[test]
396    fn default_range_is_1_to_6() {
397        let mut scale = ScaleLinewidthContinuous::new();
398        scale.train(&[Value::Float(0.0), Value::Float(10.0)]);
399        assert_eq!(scale.map_to_size(&Value::Float(0.0)), Some(1.0));
400        assert_eq!(scale.map_to_size(&Value::Float(10.0)), Some(6.0));
401    }
402
403    // ─── Review finding #4: transform the value and the limits first, then
404    // rescale (ggplot2's order) — not the previous, incorrect order that
405    // transformed the already-rescaled [0,1] fraction, which collapsed
406    // every transform except Sqrt-with-a-zero-lower-limit. ───
407
408    #[test]
409    fn log10_transform_with_a_nonzero_lower_limit() {
410        let scale = ScaleLinewidthContinuous::new()
411            .with_range(1.0, 6.0)
412            .with_transform(ScaleTransform::Log10)
413            .with_limits(1.0, 100.0);
414        // log10(1)=0, log10(100)=2: v=10 (log10=1) is exactly the midpoint.
415        let cases = [(1.0, 1.0), (10.0, 3.5), (100.0, 6.0)];
416        for (v, expected) in cases {
417            let width = scale.map_to_size(&Value::Float(v)).unwrap();
418            assert!(
419                (width - expected).abs() < 1e-9,
420                "v={v}: width={width}, expected={expected}"
421            );
422        }
423    }
424
425    #[test]
426    fn reverse_transform_flips_which_end_gets_the_larger_width() {
427        let scale = ScaleLinewidthContinuous::new()
428            .with_range(1.0, 6.0)
429            .with_transform(ScaleTransform::Reverse)
430            .with_limits(1.0, 100.0);
431        // The domain minimum gets the *larger* output under Reverse.
432        let min_width = scale.map_to_size(&Value::Float(1.0)).unwrap();
433        let max_width = scale.map_to_size(&Value::Float(100.0)).unwrap();
434        assert!((min_width - 6.0).abs() < 1e-9, "min_width={min_width}");
435        assert!((max_width - 1.0).abs() < 1e-9, "max_width={max_width}");
436    }
437
438    #[test]
439    fn sqrt_transform_with_a_nonzero_lower_limit() {
440        let scale = ScaleLinewidthContinuous::new()
441            .with_range(1.0, 6.0)
442            .with_transform(ScaleTransform::Sqrt)
443            .with_limits(4.0, 100.0);
444        // sqrt(4)=2, sqrt(100)=10, sqrt(16)=4 -> fraction (4-2)/8 = 0.25.
445        let cases = [(4.0, 1.0), (16.0, 2.25), (100.0, 6.0)];
446        for (v, expected) in cases {
447            let width = scale.map_to_size(&Value::Float(v)).unwrap();
448            assert!(
449                (width - expected).abs() < 1e-9,
450                "v={v}: width={width}, expected={expected}"
451            );
452        }
453    }
454
455    /// Review finding #6: NA / non-numeric / non-finite input must not map
456    /// to the minimum width — the scale has no width to report, so callers
457    /// (`scales.map_linewidth(..).unwrap_or(self.width)`) fall back to the
458    /// geom's own configured width instead.
459    #[test]
460    fn na_or_non_finite_input_returns_none_not_the_minimum() {
461        let scale = ScaleLinewidthContinuous::new()
462            .with_range(1.0, 6.0)
463            .with_limits(0.0, 10.0);
464        assert_eq!(scale.map_to_size(&Value::Na), None);
465        assert_eq!(scale.map_to_size(&Value::Str("x".into())), None);
466        assert_eq!(scale.map_to_size(&Value::Float(f64::NAN)), None);
467        assert_eq!(scale.map_to_size(&Value::Float(f64::INFINITY)), None);
468    }
469
470    // ─── Review R-2: a transform undefined somewhere in the domain (e.g.
471    // Log10 with a limit <= 0) must map every value to None (falls back to
472    // the geom's own width) and warn once, instead of the previous
473    // behaviour of silently reporting the midpoint width. An ordinary
474    // out-of-limits value, when the domain itself is fine, still squishes
475    // to the nearest end. ───
476
477    #[test]
478    fn log10_with_a_zero_lower_limit_maps_every_value_to_none_and_warns() {
479        let scale = ScaleLinewidthContinuous::new()
480            .with_range(1.0, 6.0)
481            .with_transform(ScaleTransform::Log10)
482            .with_limits(0.0, 100.0);
483        for v in [0.0, 1.0, 50.0, 100.0] {
484            assert_eq!(
485                scale.map_to_size(&Value::Float(v)),
486                None,
487                "v={v} should map to None, not the midpoint width"
488            );
489        }
490        let mut warnings = Vec::new();
491        scale.validate(&mut warnings);
492        assert_eq!(warnings.len(), 1, "{warnings:?}");
493        assert!(warnings[0].contains("Log10"));
494    }
495
496    #[test]
497    fn a_value_trained_to_zero_breaks_log10_the_same_way() {
498        // The domain minimum comes from training on data that legitimately
499        // contains 0 (e.g. a flow count), not an explicit `with_limits`.
500        let mut scale = ScaleLinewidthContinuous::new()
501            .with_range(1.0, 6.0)
502            .with_transform(ScaleTransform::Log10);
503        scale.train(&[Value::Float(0.0), Value::Float(100.0)]);
504        assert_eq!(scale.map_to_size(&Value::Float(50.0)), None);
505        let mut warnings = Vec::new();
506        scale.validate(&mut warnings);
507        assert_eq!(warnings.len(), 1);
508    }
509
510    #[test]
511    fn out_of_limits_value_squishes_to_the_nearest_end_even_under_log10() {
512        // The domain (1, 100) is fine for Log10; v=0 is simply outside it,
513        // like any other oob value — squish to the minimum, not None.
514        let scale = ScaleLinewidthContinuous::new()
515            .with_range(1.0, 6.0)
516            .with_transform(ScaleTransform::Log10)
517            .with_limits(1.0, 100.0);
518        assert_eq!(scale.map_to_size(&Value::Float(0.0)), Some(1.0));
519        assert_eq!(scale.map_to_size(&Value::Float(-50.0)), Some(1.0));
520        assert_eq!(scale.map_to_size(&Value::Float(1000.0)), Some(6.0));
521
522        let mut warnings = Vec::new();
523        scale.validate(&mut warnings);
524        assert!(
525            warnings.is_empty(),
526            "a fine domain shouldn't warn: {warnings:?}"
527        );
528    }
529
530    #[test]
531    fn fraction_stays_finite_even_when_the_domain_transform_is_broken() {
532        // Legend/axis-style callers need a finite [0,1] position regardless.
533        let scale = ScaleLinewidthContinuous::new()
534            .with_transform(ScaleTransform::Log10)
535            .with_limits(0.0, 100.0);
536        let t = scale.map(&Value::Float(50.0));
537        assert!(t.is_finite());
538    }
539
540    /// End-to-end: the warning reaches `GGPlot`'s own warnings channel, not
541    /// just the scale's own `validate()`.
542    #[test]
543    fn broken_domain_transform_warning_reaches_the_plot_warnings() {
544        use crate::prelude::*;
545
546        let (_svg, warnings) = GGPlot::new(vec![
547            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
548            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
549            ("xend".into(), vec![Value::Float(0.5), Value::Float(1.5)]),
550            ("yend".into(), vec![Value::Float(0.5), Value::Float(1.5)]),
551            ("size".into(), vec![Value::Float(0.0), Value::Float(100.0)]),
552        ])
553        .aes(
554            Aes::new()
555                .x("x")
556                .y("y")
557                .xend("xend")
558                .yend("yend")
559                .linewidth("size"),
560        )
561        .geom_segment()
562        .scale_linewidth(ScaleLinewidthContinuous::new().with_transform(ScaleTransform::Log10))
563        .render_svg_native_with_warnings(200, 200)
564        .unwrap();
565
566        assert!(warnings.iter().any(|w| w.contains("Log10")), "{warnings:?}");
567    }
568
569    // ─── Coverage: builders and Scale trait methods not exercised above ───
570
571    #[test]
572    fn with_labels_overrides_the_formatted_label_for_each_break() {
573        let scale = ScaleLinewidthContinuous::new()
574            .with_name("flow size")
575            .with_breaks(vec![1.0, 2.0])
576            .with_labels(vec!["small".into(), "big".into()])
577            .with_limits(0.0, 10.0);
578        assert_eq!(scale.name(), "flow size");
579        let breaks = scale.breaks();
580        let labels: Vec<&str> = breaks.iter().map(|(_, l)| l.as_str()).collect();
581        assert_eq!(labels, vec!["small", "big"]);
582    }
583
584    #[test]
585    fn with_labels_shorter_than_breaks_falls_back_to_the_formatted_number() {
586        // Only the first break gets a custom label; the second falls back
587        // to `format_label` (no formatter, no locale -> plain number).
588        let scale = ScaleLinewidthContinuous::new()
589            .with_breaks(vec![1.0, 2.5])
590            .with_labels(vec!["small".into()])
591            .with_limits(0.0, 10.0);
592        let breaks = scale.breaks();
593        assert_eq!(breaks[0].1, "small");
594        assert_eq!(breaks[1].1, "2.5");
595    }
596
597    #[test]
598    fn with_label_formatter_formats_every_break() {
599        let scale = ScaleLinewidthContinuous::new()
600            .with_breaks(vec![1.0, 2.0])
601            .with_label_formatter(|v| format!("{v:.1}mm"))
602            .with_limits(0.0, 10.0);
603        let labels: Vec<String> = scale.breaks().into_iter().map(|(_, l)| l).collect();
604        assert_eq!(labels, vec!["1.0mm", "2.0mm"]);
605    }
606
607    #[test]
608    fn with_locale_formats_default_break_labels() {
609        let mut scale = ScaleLinewidthContinuous::new().with_locale(crate::locale::Locale::de_de());
610        scale.train(&[Value::Float(1000.0), Value::Float(2000.0)]);
611        let breaks = scale.breaks();
612        assert!(!breaks.is_empty());
613        // de-DE uses '.' as the thousands mark — at least one label should
614        // show it for a four-digit break value.
615        assert!(breaks.iter().any(|(_, l)| l.contains('.')), "{breaks:?}");
616    }
617
618    #[test]
619    fn set_default_locale_only_applies_when_no_locale_was_set() {
620        let mut with_own =
621            ScaleLinewidthContinuous::new().with_locale(crate::locale::Locale::de_de());
622        with_own.set_default_locale(&crate::locale::Locale::en_us());
623        assert_eq!(
624            with_own.locale().unwrap().tag,
625            crate::locale::Locale::de_de().tag
626        );
627
628        let mut without_own = ScaleLinewidthContinuous::new();
629        assert!(without_own.locale().is_none());
630        without_own.set_default_locale(&crate::locale::Locale::en_us());
631        assert_eq!(
632            without_own.locale().unwrap().tag,
633            crate::locale::Locale::en_us().tag
634        );
635    }
636
637    #[test]
638    fn degenerate_range_gives_a_single_break_at_the_midpoint() {
639        let mut scale = ScaleLinewidthContinuous::new();
640        scale.train(&[Value::Float(5.0), Value::Float(5.0)]);
641        let breaks = scale.breaks();
642        assert_eq!(breaks.len(), 1);
643        assert_eq!(breaks[0].0, 0.5);
644        assert_eq!(breaks[0].1, "5");
645    }
646
647    #[test]
648    fn untrained_scale_has_no_breaks() {
649        let scale = ScaleLinewidthContinuous::new();
650        assert!(scale.breaks().is_empty());
651    }
652
653    #[test]
654    fn clone_box_preserves_configuration() {
655        let scale = ScaleLinewidthContinuous::new()
656            .with_range(2.0, 9.0)
657            .with_name("w")
658            .with_limits(0.0, 5.0);
659        let cloned = scale.clone_box();
660        assert_eq!(cloned.name(), "w");
661        assert_eq!(
662            cloned.map_to_size(&Value::Float(5.0)),
663            scale.map_to_size(&Value::Float(5.0))
664        );
665    }
666
667    #[test]
668    fn reset_training_clears_an_auto_trained_domain_but_not_fixed_limits() {
669        let mut auto = ScaleLinewidthContinuous::new();
670        auto.train(&[Value::Float(1.0), Value::Float(9.0)]);
671        assert_eq!(auto.domain(), Some((1.0, 9.0)));
672        auto.reset_training();
673        assert_eq!(auto.domain(), None);
674
675        let mut fixed = ScaleLinewidthContinuous::new().with_limits(0.0, 10.0);
676        fixed.reset_training();
677        assert_eq!(fixed.domain(), Some((0.0, 10.0)));
678    }
679
680    #[test]
681    fn default_trait_impl_matches_new() {
682        let default_scale = ScaleLinewidthContinuous::default();
683        let new_scale = ScaleLinewidthContinuous::new();
684        assert_eq!(default_scale.name(), new_scale.name());
685        assert_eq!(default_scale.breaks(), new_scale.breaks());
686    }
687
688    #[test]
689    fn set_name_overrides_the_axis_legend_title() {
690        let mut scale = ScaleLinewidthContinuous::new();
691        scale.set_name("custom title");
692        assert_eq!(scale.name(), "custom title");
693    }
694
695    // `map` (the `Scale` trait method) calls `fraction` directly, without
696    // `map_to_size`'s own NA/non-finite/broken-domain short-circuits — so
697    // it has to handle those cases itself too, and always returns a finite
698    // `[0, 1]` number (never panics, never NaN) for any caller that needs
699    // a plain position rather than a width.
700    #[test]
701    fn map_handles_na_non_finite_and_untrained_without_panicking() {
702        let untrained = ScaleLinewidthContinuous::new();
703        assert_eq!(untrained.map(&Value::Float(5.0)), 0.5);
704
705        let trained = ScaleLinewidthContinuous::new().with_limits(0.0, 10.0);
706        assert_eq!(trained.map(&Value::Na), 0.0);
707        assert_eq!(trained.map(&Value::Str("x".into())), 0.0);
708        assert_eq!(trained.map(&Value::Float(f64::NAN)), 0.0);
709    }
710}