Skip to main content

egui_charts/scales/
pricescale_marks.rs

1//! Smart price-axis tick mark generation.
2//!
3//! [`PriceMarkGenerator`] uses the "nice numbers" algorithm (Heckbert) to
4//! place tick marks at visually pleasing, round price intervals.  It supports
5//! both linear and logarithmic scales and automatically adjusts label
6//! precision based on the step size.
7
8use super::{PriceMap, PriceScaleMode};
9
10/// A single price tick mark on the Y axis
11#[derive(Debug, Clone)]
12pub struct PriceMark {
13    /// Axis value: the price in normal and logarithmic mode, the percentage or
14    /// index value otherwise
15    pub price: f64,
16
17    /// Formatted label text
18    pub label: String,
19
20    /// Y coord in pixels
21    pub y_coord: f32,
22
23    /// Weight of this mark (for hierarchical rendering)
24    pub weight: u8,
25}
26
27/// Configuration for price mark generation, set per scale through
28/// [`PriceScaleOptions::tick_marks`](super::PriceScaleOptions::tick_marks).
29#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
30pub struct PriceMarkGeneratorConfig {
31    /// Min spacing between marks in pixels, which keeps labels from overlapping at any density
32    pub min_spacing: f32,
33
34    /// Max number of marks, capped at 1,000 during generation.
35    pub max_marks: usize,
36
37    /// Target density (marks per 100 pixels): linear marks take the nice step nearest
38    /// `100 / target_density` pixels, logarithmic marks stay at least that far apart, and no
39    /// marks are closer than `min_spacing`
40    pub target_density: f32,
41
42    /// Minimum price tick size. Normal and logarithmic marks lie on its multiples;
43    /// invalid sizes are ignored. `None` uses the chart instrument's tick size when available.
44    pub min_price_step: Option<f64>,
45}
46
47impl PriceMarkGeneratorConfig {
48    pub(crate) const MAX_MARKS: usize = 1_000;
49}
50
51impl Default for PriceMarkGeneratorConfig {
52    fn default() -> Self {
53        Self {
54            min_spacing: 20.0,
55            max_marks: 100,
56            target_density: 2.5,
57            min_price_step: None,
58        }
59    }
60}
61
62/// Smart price axis mark generator
63/// Implements "nice numbers" algorithm for price ticks
64pub struct PriceMarkGenerator {
65    config: PriceMarkGeneratorConfig,
66}
67
68impl PriceMarkGenerator {
69    /// Create a new price mark generator with default config
70    pub fn new() -> Self {
71        Self {
72            config: PriceMarkGeneratorConfig::default(),
73        }
74    }
75
76    /// Create with custom config
77    pub fn with_config(mut config: PriceMarkGeneratorConfig) -> Self {
78        config.max_marks = config.max_marks.min(PriceMarkGeneratorConfig::MAX_MARKS);
79        Self { config }
80    }
81
82    /// Generates marks for the visible range of `map`, positioned through it.
83    ///
84    /// Logarithmic scales get marks at 1, 2 and 5 times powers of ten; the other
85    /// modes get evenly stepped marks in scale units, labelled as prices, signed
86    /// percentages or index values. Labels group their thousands with the map's separator.
87    pub fn generate_marks(&self, map: &PriceMap) -> Vec<PriceMark> {
88        let range = map.range();
89        let (top, bottom) = map.extent();
90        let height_pixels = bottom - top;
91        if range.min >= range.max || height_pixels <= 0.0 {
92            return Vec::new();
93        }
94
95        let transform = map.transform();
96        if transform.mode != PriceScaleMode::Logarithmic {
97            return self.generate_linear_marks(range.min, range.max, height_pixels, map, false);
98        }
99        let (min_price, max_price) = (transform.to_price(range.min), transform.to_price(range.max));
100        let marks = self.generate_log_marks(min_price, max_price, height_pixels, map);
101        if marks.len() >= 3 {
102            return marks;
103        }
104        // A narrow log range holds few multiples of powers of ten: step it in prices instead.
105        self.generate_linear_marks(min_price, max_price, height_pixels, map, true)
106    }
107
108    /// Generate evenly stepped marks between two scale values, or between two
109    /// prices when `prices` is set
110    fn generate_linear_marks(
111        &self,
112        min_price: f64,
113        max_price: f64,
114        height_pixels: f32,
115        map: &PriceMap,
116        prices: bool,
117    ) -> Vec<PriceMark> {
118        // Guard against invalid inputs (NaN, infinite, or invalid ranges)
119        if !min_price.is_finite()
120            || !max_price.is_finite()
121            || !height_pixels.is_finite()
122            || height_pixels <= 0.0
123        {
124            return Vec::new();
125        }
126
127        let price_range = max_price - min_price;
128
129        // An absolute epsilon would discard representable sub-unit ranges.
130        if price_range <= 0.0 {
131            return Vec::new();
132        }
133
134        // Calculate optimal number of marks
135        let target_marks = (height_pixels / 100.0 * self.config.target_density)
136            .max(3.0)
137            .min(self.config.max_marks as f32);
138
139        // Calculate raw step
140        let raw_step = price_range / target_marks as f64;
141
142        // Get "nice" step value
143        let mut nice_step = self.calculate_nice_step(raw_step);
144        if let Some(decimals) = map.price_precision() {
145            let unit = 10f64.powi(-(decimals as i32));
146            nice_step = Self::round_up_to_multiple(nice_step, unit);
147        }
148        let min_step = self
149            .min_step()
150            .filter(|_| prices || map.transform().mode == PriceScaleMode::Normal);
151        if let Some(min_step) = min_step {
152            nice_step = Self::round_up_to_multiple(nice_step, min_step);
153        }
154
155        // Guard against zero or invalid step (prevents infinite loop)
156        if nice_step <= 0.0 || !nice_step.is_finite() {
157            return Vec::new();
158        }
159
160        // Calculate starting price (round down to nice boundary)
161        let start_index = (min_price / nice_step).floor();
162        let start_price = start_index * nice_step;
163
164        // Guard against invalid start price
165        if !start_price.is_finite() {
166            return Vec::new();
167        }
168
169        // Generate marks with iteration limit to prevent infinite loops
170        let mut marks = Vec::new();
171        let mut curr_price = start_price;
172        let max_iterations = self.config.max_marks * 10; // Safety limit
173        let mut iterations = 0;
174
175        while curr_price <= max_price && iterations < max_iterations {
176            if curr_price >= min_price {
177                let y = if prices {
178                    map.price_to_y(curr_price)
179                } else {
180                    map.scale_to_y(curr_price)
181                };
182
183                // Determine precision based on price value and step
184                let precision = map.price_precision().unwrap_or_else(|| {
185                    let mut decimals = self.calculate_precision(nice_step);
186                    if min_step.is_some() {
187                        while decimals < 8 {
188                            let scaled = nice_step * 10f64.powi(decimals as i32);
189                            if (scaled - scaled.round()).abs() <= scaled * f64::EPSILON * 4.0 {
190                                break;
191                            }
192                            decimals += 1;
193                        }
194                    }
195                    if matches!(
196                        map.transform().mode,
197                        PriceScaleMode::Normal | PriceScaleMode::Logarithmic
198                    ) {
199                        decimals.max(2)
200                    } else {
201                        decimals
202                    }
203                });
204                let label = if map.price_precision().is_none() && precision > 8 {
205                    let mut label = self.format_price(curr_price, precision, true);
206                    if map.transform().mode == PriceScaleMode::Percentage {
207                        if !label.starts_with('-') {
208                            label.insert(0, '+');
209                        }
210                        label.push('%');
211                    }
212                    label
213                } else if prices {
214                    self.format_price(curr_price, precision, false)
215                } else {
216                    map.transform().label(curr_price, precision, None)
217                };
218                let label = super::price_formatter::group_thousands(label, map.thousands_sep());
219
220                // Calculate weight (higher for rounder numbers)
221                let weight = self.calculate_weight(curr_price, nice_step);
222
223                marks.push(PriceMark {
224                    price: curr_price,
225                    label,
226                    y_coord: y,
227                    weight,
228                });
229            }
230
231            iterations += 1;
232            curr_price = if min_step.is_some() {
233                (start_index + iterations as f64) * nice_step
234            } else {
235                curr_price + nice_step
236            };
237        }
238
239        self.apply_spacing_constraints(marks, self.config.min_spacing)
240    }
241
242    /// Generate marks for logarithmic price scale
243    fn generate_log_marks(
244        &self,
245        min_price: f64,
246        max_price: f64,
247        height_pixels: f32,
248        map: &PriceMap,
249    ) -> Vec<PriceMark> {
250        // Guard against invalid inputs
251        if min_price <= 0.0
252            || max_price <= 0.0
253            || !min_price.is_finite()
254            || !max_price.is_finite()
255            || !height_pixels.is_finite()
256            || height_pixels <= 0.0
257            || min_price >= max_price
258        {
259            return Vec::new();
260        }
261
262        let log_min = min_price.ln();
263        let log_max = max_price.ln();
264        let log_range = log_max - log_min;
265
266        // Guard against invalid log values
267        if !log_min.is_finite() || !log_max.is_finite() || log_range <= f64::EPSILON {
268            return Vec::new();
269        }
270
271        // For log scale, we want prices at nice round numbers
272        // Find the order of magnitude range
273        let mut min_order = min_price.log10().floor() as i32;
274        if let Some(decimals) = map.price_precision() {
275            min_order = min_order.max(-(decimals as i32));
276        }
277        let max_order = max_price.log10().ceil() as i32;
278
279        let mut marks = Vec::new();
280
281        // Generate marks at powers of 10 and their subdivisions
282        for order in min_order..=max_order {
283            let base = 10f64.powi(order);
284
285            // Major marks: 1x, 2x, 5x for each order of magnitude
286            for multiplier in [1.0, 2.0, 5.0] {
287                let price = base * multiplier;
288
289                if let Some(step) = self.min_step()
290                    && (price - (price / step).round() * step).abs()
291                        > price.abs().max(step) * f64::EPSILON * 4.0
292                {
293                    continue;
294                }
295
296                if price >= min_price && price <= max_price {
297                    let y = map.price_to_y(price);
298
299                    let precision = map
300                        .price_precision()
301                        .unwrap_or_else(|| self.calculate_precision(base).max(2));
302                    let label = super::price_formatter::group_thousands(
303                        self.format_price(price, precision, map.price_precision().is_none()),
304                        map.thousands_sep(),
305                    );
306
307                    let weight = if multiplier == 1.0 {
308                        100
309                    } else if multiplier == 5.0 {
310                        80
311                    } else {
312                        60
313                    };
314
315                    marks.push(PriceMark {
316                        price,
317                        label,
318                        y_coord: y,
319                        weight,
320                    });
321                }
322            }
323        }
324
325        // Logarithmic ticks can be closer at one end even when the average spacing fits.
326        self.apply_spacing_constraints(
327            marks,
328            self.config
329                .min_spacing
330                .max(100.0 / self.config.target_density),
331        )
332    }
333
334    fn round_up_to_multiple(step: f64, unit: f64) -> f64 {
335        let ratio = step / unit;
336        let rounded = ratio.round();
337        let multiple = if (ratio - rounded).abs() <= ratio * 1e-9 {
338            rounded
339        } else {
340            ratio.ceil()
341        };
342        multiple.max(1.0) * unit
343    }
344
345    /// The "nice" step nearest `raw_step`, so the marks land nearest their target spacing in
346    /// pixels: 1, 2, 2.5, 4 or 5 times a power of ten.
347    fn calculate_nice_step(&self, raw_step: f64) -> f64 {
348        if raw_step <= 0.0 {
349            return 1.0;
350        }
351
352        if let Some(min_step) = self.min_step()
353            && raw_step < min_step
354        {
355            return min_step;
356        }
357
358        // Get order of magnitude
359        let exponent = raw_step.log10().floor();
360        let fraction = raw_step / 10f64.powf(exponent);
361
362        let distance = |nice: f64| (fraction - nice).abs();
363        let nice_fraction = [1.0, 2.0, 2.5, 4.0, 5.0, 10.0]
364            .into_iter()
365            .min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
366            .unwrap_or(1.0);
367
368        nice_fraction * 10f64.powf(exponent)
369    }
370
371    /// Calculate the decimal precision for marks `step` apart, the same for every
372    /// mark including zero
373    fn calculate_precision(&self, step: f64) -> usize {
374        // For very small steps, we need more precision
375        if step <= 0.0001 {
376            (1.0 - step.log10().floor()).max(8.0) as usize
377        } else if step <= 0.001 {
378            6
379        } else if step <= 0.01 {
380            4
381        } else if step <= 0.1 {
382            3
383        } else if step <= 1.0 {
384            2
385        } else if step <= 10.0 {
386            1
387        } else {
388            0
389        }
390    }
391
392    /// The configured minimum tick size, unless it is not a positive finite number.
393    fn min_step(&self) -> Option<f64> {
394        self.config
395            .min_price_step
396            .filter(|s| s.is_finite() && *s > 0.0)
397    }
398
399    /// Format price with given precision
400    fn format_price(&self, price: f64, precision: usize, automatic: bool) -> String {
401        if automatic && precision > 8 {
402            let price = if price == 0.0 { 0.0 } else { price };
403            let digits = (price.abs().log10().floor() + precision as f64).clamp(0.0, 17.0) as usize;
404            format!("{price:.digits$e}")
405        } else {
406            format!("{price:.precision$}")
407        }
408    }
409
410    /// Calculate weight for a price mark (higher for rounder numbers)
411    fn calculate_weight(&self, price: f64, step: f64) -> u8 {
412        // Check if price is a multiple of larger steps
413        let step_10 = step * 10.0;
414        let step_5 = step * 5.0;
415        let step_2 = step * 2.0;
416
417        // Use a tolerance for floating point comparison
418        let tolerance = step * 0.01;
419
420        if (price % step_10).abs() < tolerance {
421            100
422        } else if (price % step_5).abs() < tolerance {
423            80
424        } else if (price % step_2).abs() < tolerance {
425            60
426        } else {
427            40
428        }
429    }
430
431    /// Keeps the roundest marks at least `spacing` pixels apart
432    fn apply_spacing_constraints(&self, mut marks: Vec<PriceMark>, spacing: f32) -> Vec<PriceMark> {
433        if marks.is_empty() {
434            return marks;
435        }
436
437        // Sort by weight descending, then by price
438        marks.sort_by(|a, b| {
439            b.weight
440                .cmp(&a.weight)
441                .then_with(|| a.price.total_cmp(&b.price))
442        });
443
444        // Keep marks with sufficient spacing
445        let mut filtered = vec![marks[0].clone()];
446
447        for mark in marks.iter().skip(1) {
448            if filtered
449                .iter()
450                .all(|m| (m.y_coord - mark.y_coord).abs() >= spacing)
451            {
452                filtered.push(mark.clone());
453            }
454        }
455
456        // Sort back by price
457        filtered.sort_by(|a, b| a.price.total_cmp(&b.price));
458        marks = filtered;
459
460        // Limit to max marks
461        if marks.len() > self.config.max_marks {
462            // Keep the highest weight marks
463            marks.sort_by(|a, b| {
464                b.weight
465                    .cmp(&a.weight)
466                    .then_with(|| a.price.total_cmp(&b.price))
467            });
468            marks.truncate(self.config.max_marks);
469            marks.sort_by(|a, b| a.price.total_cmp(&b.price));
470        }
471
472        marks
473    }
474}
475
476impl Default for PriceMarkGenerator {
477    fn default() -> Self {
478        Self::new()
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn test_nice_step_calculation() {
488        let generator = PriceMarkGenerator::new();
489
490        // Test various raw steps
491        assert_eq!(generator.calculate_nice_step(0.7), 0.5);
492        assert_eq!(generator.calculate_nice_step(1.8), 2.0);
493        assert_eq!(generator.calculate_nice_step(2.2), 2.0);
494        assert_eq!(generator.calculate_nice_step(3.6), 4.0);
495        assert_eq!(generator.calculate_nice_step(4.6), 5.0);
496        assert_eq!(generator.calculate_nice_step(8.0), 10.0);
497        assert_eq!(generator.calculate_nice_step(16.0), 20.0);
498        assert_eq!(generator.calculate_nice_step(35.0), 40.0);
499    }
500
501    #[test]
502    fn test_precision_calculation() {
503        let generator = PriceMarkGenerator::new();
504
505        assert_eq!(generator.calculate_precision(0.00001), 8);
506        assert_eq!(generator.calculate_precision(0.001), 6);
507        assert_eq!(generator.calculate_precision(0.01), 4);
508        assert_eq!(generator.calculate_precision(0.1), 3);
509        assert_eq!(generator.calculate_precision(1.0), 2);
510        assert_eq!(generator.calculate_precision(10.0), 1);
511        assert_eq!(generator.calculate_precision(100.0), 0);
512    }
513
514    #[test]
515    fn ticks_stay_on_the_minimum_price_grid() {
516        use PriceScaleMode::{Logarithmic, Normal};
517        for (mode, min, max, step, precision, expected) in [
518            (Normal, 4500.0, 4503.75, 0.25, None, None),
519            (Normal, -3.75, 0.5, 0.25, Some(2), None),
520            (Normal, 4500.0, 4503.75, 0.05, None, None),
521            (Normal, 0.01, 0.9, 0.03, Some(2), None),
522            (Logarithmic, 4500.0, 4503.75, 0.25, Some(2), None),
523            (Logarithmic, 0.01, 1.0, 0.03, None, None),
524            (Normal, 100.0, 101.0, 1.0 / 32.0, None, Some("100.125")),
525            (Normal, 0.012, 0.013, 1e-6, None, Some("0.01210000")),
526        ] {
527            let (min, max) = if mode == Logarithmic {
528                (f64::ln(min), f64::ln(max))
529            } else {
530                (min, max)
531            };
532            let marks = map(mode, min, max)
533                .with_price_precision(precision)
534                .with_tick_marks(PriceMarkGeneratorConfig {
535                    min_price_step: Some(step),
536                    ..Default::default()
537                })
538                .marks();
539            assert!(marks.len() >= 3, "{mode:?}: {marks:?}");
540            if let Some(expected) = expected {
541                assert!(
542                    marks.iter().any(|mark| mark.label == expected),
543                    "{mode:?}: expected {expected}, got {marks:?}"
544                );
545            }
546            for mark in marks {
547                let ticks = mark.price / step;
548                assert!(
549                    (ticks - ticks.round()).abs() < 1e-9,
550                    "{mode:?}: {} is off the {step} grid",
551                    mark.label
552                );
553            }
554        }
555    }
556
557    #[test]
558    fn percentage_and_indexed_ticks_keep_their_own_units() {
559        use PriceScaleMode::{IndexedTo100, Percentage};
560        for (mode, min, max, expected) in [
561            (Percentage, -2.0, 2.375, "-1.60%"),
562            (IndexedTo100, 98.0, 102.375, "98.40"),
563        ] {
564            let marks = map(mode, min, max)
565                .with_tick_marks(PriceMarkGeneratorConfig {
566                    min_price_step: Some(0.25),
567                    ..Default::default()
568                })
569                .marks();
570            assert!(
571                marks.iter().any(|mark| mark.label == expected),
572                "{mode:?}: expected {expected}, got {marks:?}"
573            );
574        }
575    }
576
577    #[test]
578    fn test_weight_calculation() {
579        let generator = PriceMarkGenerator::new();
580        let step = 1.0;
581
582        assert_eq!(generator.calculate_weight(10.0, step), 100); // Multiple of 10
583        assert_eq!(generator.calculate_weight(5.0, step), 80); // Multiple of 5
584        assert_eq!(generator.calculate_weight(2.0, step), 60); // Multiple of 2
585        assert_eq!(generator.calculate_weight(1.0, step), 40); // Base step
586    }
587
588    fn map(mode: PriceScaleMode, min: f64, max: f64) -> PriceMap {
589        let transform = crate::scales::PriceTransform { mode, base: 0.0 };
590        PriceMap::new(
591            transform,
592            crate::scales::PriceRange::new(min, max),
593            0.0,
594            500.0,
595            false,
596        )
597    }
598
599    #[test]
600    fn test_linear_marks_generation() {
601        let generator = PriceMarkGenerator::new();
602        let marks = generator.generate_marks(&map(PriceScaleMode::Normal, 100.0, 200.0));
603        for max_marks in [1_000, 1_001, usize::MAX] {
604            let bounded = PriceMarkGenerator::with_config(PriceMarkGeneratorConfig {
605                max_marks,
606                min_spacing: 0.0,
607                target_density: 100.0,
608                ..Default::default()
609            });
610            assert_eq!(
611                bounded
612                    .generate_marks(
613                        &map(PriceScaleMode::Normal, 0.0, 2_000.0).with_bounds(0.0, 2_000.0)
614                    )
615                    .len(),
616                1_000
617            );
618        }
619
620        assert!(!marks.is_empty());
621        assert!(marks.len() <= 20); // Max marks constraint
622
623        // Check marks are sorted by price
624        for i in 1..marks.len() {
625            assert!(marks[i].price > marks[i - 1].price);
626        }
627
628        // Check all marks are in range
629        for mark in &marks {
630            assert!(mark.price >= 100.0 && mark.price <= 200.0);
631        }
632
633        for (mode, min, max, precision, expected) in [
634            (PriceScaleMode::Normal, 18_200.0, 22_800.0, None, "20000.00"),
635            (PriceScaleMode::Normal, 700.0, 730.0, Some(3), "710.000"),
636            (PriceScaleMode::Normal, 0.012, 0.013, Some(6), "0.012100"),
637            (
638                PriceScaleMode::Normal,
639                1e-18,
640                9e-18,
641                Some(18),
642                "0.000000000000000005",
643            ),
644            (
645                PriceScaleMode::Logarithmic,
646                700f64.ln(),
647                730f64.ln(),
648                Some(3),
649                "710.000",
650            ),
651        ] {
652            let map = map(mode, min, max).with_price_precision(precision);
653            let marks = generator.generate_marks(&map);
654            assert!(
655                marks.iter().any(|mark| mark.label == expected),
656                "{mode:?}: {marks:?}"
657            );
658        }
659        for (mode, min, max, decimals) in [
660            (PriceScaleMode::Normal, 700.0, 700.04, 2),
661            (PriceScaleMode::Normal, 0.0, 30.0, 0),
662            (PriceScaleMode::Logarithmic, 0.001_f64.ln(), 0.1_f64.ln(), 2),
663        ] {
664            let marks =
665                generator.generate_marks(&map(mode, min, max).with_price_precision(Some(decimals)));
666            assert!(!marks.is_empty());
667            assert!(marks.windows(2).all(|pair| pair[0].label != pair[1].label));
668            assert!(
669                marks
670                    .iter()
671                    .all(|mark| (mark.label.parse::<f64>().unwrap() - mark.price).abs() < 1e-8)
672            );
673        }
674        // 0..70 on 500 pixels at 2.5 marks per 100 pixels wants a step of 5.6: the nearest nice
675        // step, 5, puts the marks 35.7 pixels apart, and all 15 stay.
676        let marks = generator.generate_marks(&map(PriceScaleMode::Normal, 0.0, 70.0));
677        assert_eq!(marks.len(), 15);
678        for (mode, min, max) in [
679            (PriceScaleMode::Normal, 1e-10, 2e-10),
680            (PriceScaleMode::Normal, -2e-18, 2e-18),
681            (PriceScaleMode::Normal, 1e-100, 2e-100),
682            (PriceScaleMode::Logarithmic, 1e-12_f64.ln(), 1e-9_f64.ln()),
683            (
684                PriceScaleMode::Logarithmic,
685                1e-10_f64.ln(),
686                1.1e-10_f64.ln(),
687            ),
688        ] {
689            let marks = generator.generate_marks(&map(mode, min, max));
690            assert!(marks.len() >= 3, "{mode:?}, {min}..{max}: {marks:?}");
691            assert!(marks.windows(2).all(|pair| pair[0].label != pair[1].label));
692            for mark in marks {
693                let labelled = mark.label.parse::<f64>().unwrap();
694                let tolerance = if mode == PriceScaleMode::Logarithmic {
695                    mark.price.abs() * 1e-9
696                } else {
697                    (max - min) * 1e-9
698                };
699                assert!(
700                    (labelled - mark.price).abs() <= tolerance,
701                    "{mode:?}: {} labelled {}",
702                    mark.price,
703                    mark.label
704                );
705            }
706        }
707    }
708
709    /// The scale's target density sets the number of labels: on 500 pixels showing 0 to 100,
710    /// 1, 2.5 and 5 marks per 100 pixels give steps of 20, 10 and 4.
711    #[test]
712    fn target_density_sets_the_number_of_marks() {
713        use crate::scales::{PriceScale, PriceScaleOptions, PriceTransform};
714        for (density, count) in [(1.0, 6), (2.5, 11), (5.0, 26)] {
715            let mut options = PriceScaleOptions::default();
716            options.tick_marks.target_density = density;
717            let mut scale = PriceScale::new(options);
718            scale.set_range(0.0, 100.0);
719            let marks = scale.map(PriceTransform::default(), 0.0, 500.0).marks();
720            assert_eq!(marks.len(), count, "{density}: {marks:?}");
721        }
722    }
723
724    #[test]
725    fn test_log_marks_generation() {
726        let generator = PriceMarkGenerator::new();
727        let (lo, hi) = (10f64.ln(), 1000f64.ln());
728        let marks = generator.generate_marks(&map(PriceScaleMode::Logarithmic, lo, hi));
729
730        // Axis endpoints are unlabelled; the interior 1/2/5 decade marks remain.
731        let prices: Vec<_> = marks.iter().map(|mark| mark.price).collect();
732        assert_eq!(prices, [20.0, 50.0, 100.0, 200.0, 500.0]);
733
734        // 18,200..22,800 holds one multiple of a power of ten (20,000), so the marks
735        // fall back to 400-unit price steps.
736        let (lo, hi) = (18_200f64.ln(), 22_800f64.ln());
737        let marks = generator.generate_marks(&map(PriceScaleMode::Logarithmic, lo, hi));
738        let labels: Vec<&str> = marks.iter().map(|m| m.label.as_str()).collect();
739        let expected = [
740            "18400.00", "18800.00", "19200.00", "19600.00", "20000.00", "20400.00", "20800.00",
741            "21200.00", "21600.00", "22000.00", "22400.00",
742        ];
743        assert_eq!(labels, expected);
744    }
745}