Skip to main content

plotkit_core/
ticks.rs

1//! Axis tick generation using the Talbot/Wilkinson extended algorithm.
2//!
3//! This module implements the "An Extension of Wilkinson's Algorithm for Positioning Tick Labels
4//! on Axes" algorithm (Talbot, Lin, Hanrahan, 2010). It produces aesthetically pleasing tick
5//! positions by jointly optimizing four objectives: simplicity, coverage, density, and legibility.
6
7use crate::scale::Scale;
8
9// ---------------------------------------------------------------------------
10// Public types
11// ---------------------------------------------------------------------------
12
13/// A single tick mark with its position in data space and a formatted label.
14#[derive(Debug, Clone)]
15pub struct Tick {
16    /// The position of this tick in data coordinates.
17    pub value: f64,
18    /// A human-readable label for this tick (e.g. "0.2", "100").
19    pub label: String,
20}
21
22/// A set of tick positions with formatted labels.
23#[derive(Debug, Clone)]
24pub struct TickSet {
25    /// Tick positions in data space.
26    pub positions: Vec<f64>,
27    /// Formatted string labels corresponding to each position.
28    pub labels: Vec<String>,
29}
30
31impl TickSet {
32    /// Converts this `TickSet` into a `Vec<Tick>`.
33    pub fn into_ticks(self) -> Vec<Tick> {
34        self.positions
35            .into_iter()
36            .zip(self.labels)
37            .map(|(value, label)| Tick { value, label })
38            .collect()
39    }
40}
41
42// ---------------------------------------------------------------------------
43// Public entry point
44// ---------------------------------------------------------------------------
45
46/// Generates optimal tick positions for an axis range.
47///
48/// Uses the Talbot/Wilkinson extended algorithm to find "nice" tick values
49/// that balance simplicity, coverage, density, and legibility.
50///
51/// # Arguments
52/// - `data_min` / `data_max`: the data range
53/// - `target_count`: desired number of ticks (typically 5-10)
54/// - `scale`: the axis scale (Linear, Log10, etc.)
55///
56/// # Returns
57/// A `Vec<Tick>` with positions and formatted labels.
58pub fn generate_ticks(
59    data_min: f64,
60    data_max: f64,
61    target_count: usize,
62    scale: &Scale,
63) -> Vec<Tick> {
64    let tick_set = match scale {
65        Scale::Linear | Scale::SymLog { .. } => {
66            generate_linear_ticks(data_min, data_max, target_count)
67        }
68        Scale::Log10 => generate_log_ticks(data_min, data_max, target_count),
69    };
70    tick_set.into_ticks()
71}
72
73// ---------------------------------------------------------------------------
74// Talbot/Wilkinson extended algorithm for linear scales
75// ---------------------------------------------------------------------------
76
77/// Preference-ordered "nice" step base numbers (Q sequence from the paper).
78/// Earlier entries score higher on simplicity.
79const Q: [f64; 6] = [1.0, 5.0, 2.0, 2.5, 4.0, 3.0];
80
81/// Weights for the four scoring objectives.
82const W_SIMPLICITY: f64 = 0.2;
83const W_COVERAGE: f64 = 0.25;
84const W_DENSITY: f64 = 0.35;
85const W_LEGIBILITY: f64 = 0.2;
86
87/// Generates optimally-spaced ticks for a linear (or symlog) axis.
88///
89/// Implements the Talbot/Wilkinson extended algorithm:
90///   For each candidate base `q` in Q, for a range of exponents `k`, and for
91///   a range of tick counts `j`, compute candidate tick sequences and score them.
92///   Return the sequence with the highest weighted score.
93fn generate_linear_ticks(data_min: f64, data_max: f64, target_count: usize) -> TickSet {
94    // Handle degenerate ranges.
95    if !data_min.is_finite() || !data_max.is_finite() {
96        return make_tick_set(vec![0.0]);
97    }
98
99    let (dmin, dmax) = if (data_max - data_min).abs() < f64::EPSILON * 100.0 {
100        // Degenerate: single-point range -- widen it.
101        if data_min == 0.0 {
102            (-1.0, 1.0)
103        } else {
104            let delta = data_min.abs() * 0.1;
105            (data_min - delta, data_min + delta)
106        }
107    } else if data_min > data_max {
108        (data_max, data_min)
109    } else {
110        (data_min, data_max)
111    };
112
113    let target = target_count.max(2) as f64;
114    let range = dmax - dmin;
115
116    let mut best_score = f64::NEG_INFINITY;
117    let mut best_ticks: Option<Vec<f64>> = None;
118
119    // Iterate over candidate base numbers.
120    for (qi, &q) in Q.iter().enumerate() {
121        // Iterate over candidate tick counts (j = number of ticks - 1, i.e. intervals).
122        // We search a range around the target count.
123        let j_min = 1_usize;
124        let j_max = (target as usize * 3).max(12);
125
126        for j in j_min..=j_max {
127            let j_f = j as f64;
128
129            // Density score for this tick count (before we know the exact positions).
130            let density = density_score(j_f + 1.0, target, range);
131            // Early skip: if the maximum possible total score can't beat the best, skip.
132            let max_possible = W_SIMPLICITY + W_COVERAGE + W_DENSITY * density + W_LEGIBILITY;
133            if max_possible < best_score {
134                continue;
135            }
136
137            // Determine the magnitude (power of 10) for the step size.
138            // step = q * 10^k, and we want j * step ~ range.
139            let ideal_step = range / j_f;
140            // k such that q * 10^k ~ ideal_step  =>  k ~ log10(ideal_step / q)
141            let k_float = (ideal_step / q).log10().floor();
142
143            // Search a small neighbourhood of k to find the best exponent.
144            for k_offset in -2_i32..=2 {
145                let k = k_float as i32 + k_offset;
146                let step = q * 10.0_f64.powi(k);
147
148                if step <= 0.0 || !step.is_finite() {
149                    continue;
150                }
151
152                // We try a few different starting points to find the best coverage.
153                // The paper iterates over `i` (the index offset for the starting tick).
154                // We search offsets that keep the ticks close to covering [dmin, dmax].
155                let i_min = ((dmin / step).ceil() - j_f) as i64;
156                let i_max = (dmin / step).floor() as i64 + 1;
157
158                for i in i_min..=i_max {
159                    let tick_min = i as f64 * step;
160                    let tick_max = tick_min + j_f * step;
161
162                    // Ticks must span at least the data range (or come close).
163                    if tick_max < dmax - step * 0.5 {
164                        continue;
165                    }
166                    if tick_min > dmin + step * 0.5 {
167                        continue;
168                    }
169
170                    // Generate the tick positions.
171                    let num_ticks = j + 1;
172                    let ticks: Vec<f64> = (0..num_ticks)
173                        .map(|t| {
174                            let v = tick_min + t as f64 * step;
175                            // Round to cancel floating-point accumulation error.
176                            snap_to_step(v, step)
177                        })
178                        .collect();
179
180                    // Score this candidate.
181                    let simplicity = simplicity_score(qi, &ticks);
182                    let coverage = coverage_score(tick_min, tick_max, dmin, dmax);
183                    let density = density_score(num_ticks as f64, target, range);
184                    let legibility = legibility_score(&ticks);
185
186                    let score = W_SIMPLICITY * simplicity
187                        + W_COVERAGE * coverage
188                        + W_DENSITY * density
189                        + W_LEGIBILITY * legibility;
190
191                    if score > best_score {
192                        best_score = score;
193                        best_ticks = Some(ticks);
194                    }
195                }
196            }
197        }
198    }
199
200    let ticks = best_ticks.unwrap_or_else(|| {
201        // Ultimate fallback: evenly divide.
202        let step = range / target;
203        (0..=target as usize)
204            .map(|i| dmin + i as f64 * step)
205            .collect()
206    });
207
208    make_tick_set(ticks)
209}
210
211// ---------------------------------------------------------------------------
212// Scoring functions
213// ---------------------------------------------------------------------------
214
215/// Simplicity score: rewards choosing earlier (more "round") entries in Q,
216/// and gives a bonus when zero is included among the ticks.
217fn simplicity_score(q_index: usize, ticks: &[f64]) -> f64 {
218    let q_len = Q.len() as f64;
219    // i ranges from 0 (best) to Q.len()-1 (worst). Map to [0, 1].
220    let q_penalty = q_index as f64 / q_len;
221    // Bonus for including zero.
222    let zero_bonus = if ticks.iter().any(|&v| v.abs() < f64::EPSILON * 100.0) {
223        1.0
224    } else {
225        0.0
226    };
227    1.0 - q_penalty + zero_bonus * 0.2
228}
229
230/// Coverage score: rewards tick ranges that tightly cover [dmin, dmax].
231///
232/// The score is based on the ratio `data_range / tick_range`. A perfect score
233/// of 1.0 means the tick range exactly matches the data range. Overshoot
234/// (tick_range > data_range) is penalized mildly. Undershoot (ticks that don't
235/// span the data) returns 0.0 -- we never want ticks that miss part of the data.
236fn coverage_score(tick_min: f64, tick_max: f64, dmin: f64, dmax: f64) -> f64 {
237    let data_range = dmax - dmin;
238    if data_range <= 0.0 {
239        return 1.0;
240    }
241    // Hard reject: ticks must fully cover the data range.
242    if tick_min > dmin + data_range * 0.001 || tick_max < dmax - data_range * 0.001 {
243        return 0.0;
244    }
245    let tick_range = tick_max - tick_min;
246    // coverage = 1 - 0.5 * ((tick_range - data_range) / data_range)^2
247    // This gently penalizes overshoot while rewarding tight coverage.
248    let overshoot_ratio = (tick_range - data_range) / data_range;
249    (1.0 - 0.5 * overshoot_ratio * overshoot_ratio).max(0.0)
250}
251
252/// Density score: rewards tick counts close to the target. Uses a Gaussian-like
253/// falloff centered on `target`.
254fn density_score(num_ticks: f64, target: f64, _range: f64) -> f64 {
255    let ratio = if target > 0.0 {
256        num_ticks / target
257    } else {
258        1.0
259    };
260    // Score peaks at ratio = 1, falls off symmetrically.
261    // Using 2 - max(ratio, 1/ratio) gives a nice [0,1] range for reasonable ratios.
262    let raw = 2.0 - ratio.max(1.0 / ratio);
263    raw.clamp(0.0, 1.0)
264}
265
266/// Legibility score: rewards ticks that produce short, clean formatted strings.
267/// Penalizes very long labels (many decimal places) or scientific notation.
268fn legibility_score(ticks: &[f64]) -> f64 {
269    if ticks.is_empty() {
270        return 1.0;
271    }
272    let total: f64 = ticks.iter().map(|&v| single_legibility(v)).sum();
273    total / ticks.len() as f64
274}
275
276/// Legibility of a single tick value.
277fn single_legibility(value: f64) -> f64 {
278    let label = format_tick(value);
279    let len = label.len();
280    // Short labels are best. Penalize progressively.
281    if len <= 3 {
282        1.0
283    } else if len <= 5 {
284        0.9
285    } else if len <= 7 {
286        0.75
287    } else if len <= 10 {
288        0.5
289    } else {
290        0.3
291    }
292}
293
294// ---------------------------------------------------------------------------
295// Log-scale ticks
296// ---------------------------------------------------------------------------
297
298/// Generates ticks for a log10 scale. Places ticks at powers of 10, with
299/// optional sub-decade ticks when the range is small.
300fn generate_log_ticks(data_min: f64, data_max: f64, target_count: usize) -> TickSet {
301    let lo = data_min.max(f64::EPSILON);
302    let hi = data_max.max(lo);
303
304    let log_lo = lo.log10().floor() as i32;
305    let log_hi = hi.log10().ceil() as i32;
306
307    let decades = (log_hi - log_lo) as usize;
308
309    if decades <= 1 {
310        // Very narrow log range: fall back to linear-style ticks within this range.
311        return generate_linear_ticks(lo, hi, target_count);
312    }
313
314    let mut positions = Vec::new();
315
316    if decades <= 3 {
317        // Few decades: include sub-decade ticks at 2, 5.
318        for exp in log_lo..=log_hi {
319            let base = 10.0_f64.powi(exp);
320            for &mult in &[1.0, 2.0, 5.0] {
321                let val = base * mult;
322                if val >= lo * 0.999 && val <= hi * 1.001 {
323                    positions.push(val);
324                }
325            }
326        }
327    } else {
328        // Many decades: only powers of 10.
329        // If there are too many decades, skip some.
330        let skip = ((decades as f64) / (target_count.max(2) as f64)).ceil() as i32;
331        let skip = skip.max(1);
332        let mut exp = log_lo;
333        while exp <= log_hi {
334            let val = 10.0_f64.powi(exp);
335            if val >= lo * 0.999 && val <= hi * 1.001 {
336                positions.push(val);
337            }
338            exp += skip;
339        }
340        // Always include the last power.
341        let last = 10.0_f64.powi(log_hi);
342        if positions.last().map_or(true, |&v| (v - last).abs() > f64::EPSILON)
343            && last <= hi * 1.001 {
344                positions.push(last);
345        }
346    }
347
348    if positions.is_empty() {
349        positions.push(lo);
350        positions.push(hi);
351    }
352
353    make_tick_set(positions)
354}
355
356// ---------------------------------------------------------------------------
357// Tick formatting
358// ---------------------------------------------------------------------------
359
360/// Formats a tick value to a compact string.
361///
362/// Uses fixed notation for values whose absolute magnitude is in [0.001, 999_999].
363/// Uses scientific notation otherwise. Strips trailing zeros from the fractional part.
364fn format_tick(value: f64) -> String {
365    if value == 0.0 {
366        return "0".to_string();
367    }
368
369    let abs = value.abs();
370
371    if (0.001..1_000_000.0).contains(&abs) {
372        // Determine how many decimal places we need.
373        // We want enough to distinguish this value, but not excessive.
374        let decimals = needed_decimals(value);
375        let formatted = format!("{:.prec$}", value, prec = decimals);
376        strip_trailing_zeros(&formatted)
377    } else {
378        // Scientific notation.
379        let formatted = format!("{:.6e}", value);
380        clean_scientific(&formatted)
381    }
382}
383
384/// Determines how many decimal places are needed to represent a value faithfully.
385/// Returns at most 10 decimal places.
386fn needed_decimals(value: f64) -> usize {
387    let abs = value.abs();
388    if abs == abs.floor() && abs < 1e15 {
389        return 0;
390    }
391    // Try increasing precision until the round-trip is faithful.
392    for d in 1..=10 {
393        let factor = 10.0_f64.powi(d as i32);
394        let rounded = (value * factor).round() / factor;
395        if (rounded - value).abs() < f64::EPSILON * abs.max(1.0) * 10.0 {
396            return d;
397        }
398    }
399    10
400}
401
402/// Strips trailing zeros after a decimal point. E.g. "1.200" -> "1.2", "3.0" -> "3".
403fn strip_trailing_zeros(s: &str) -> String {
404    if !s.contains('.') {
405        return s.to_string();
406    }
407    let trimmed = s.trim_end_matches('0');
408    let trimmed = trimmed.trim_end_matches('.');
409    trimmed.to_string()
410}
411
412/// Cleans scientific notation: "1.000000e0" -> "1e0", strips trailing zeros in mantissa.
413fn clean_scientific(s: &str) -> String {
414    if let Some(e_pos) = s.find('e') {
415        let mantissa = &s[..e_pos];
416        let exponent = &s[e_pos..]; // includes 'e'
417        let cleaned_mantissa = strip_trailing_zeros(mantissa);
418        format!("{}{}", cleaned_mantissa, exponent)
419    } else {
420        s.to_string()
421    }
422}
423
424// ---------------------------------------------------------------------------
425// Helpers
426// ---------------------------------------------------------------------------
427
428/// Rounds a value to the nearest multiple of `step`, eliminating floating-point
429/// accumulation errors such as `0.2 + 0.2 + 0.2 = 0.6000000000000001`.
430///
431/// Works by normalizing the step to remove its power-of-10 magnitude, computing
432/// the snap in that normalized space, then scaling back. This ensures correct
433/// behavior for both large steps (e.g. 200000) and tiny steps (e.g. 2e-11).
434fn snap_to_step(value: f64, step: f64) -> f64 {
435    if step == 0.0 {
436        return value;
437    }
438
439    // Compute the integer multiple of step closest to value.
440    let n = (value / step).round();
441    let mut result = n * step;
442
443    // Normalize: figure out how many digits the step's "significant part" needs.
444    // E.g. step=0.2 -> magnitude=-1, mantissa=2.0
445    //      step=200  -> magnitude=2, mantissa=2.0
446    //      step=2.5  -> magnitude=0, mantissa=2.5
447    let magnitude = step.abs().log10().floor() as i32;
448    // Number of decimals needed for the mantissa (step / 10^magnitude).
449    let mantissa = step.abs() / 10.0_f64.powi(magnitude);
450    let mantissa_decimals = {
451        let mut d = 0usize;
452        for test_d in 0..=5 {
453            let factor = 10.0_f64.powi(test_d as i32);
454            let scaled = mantissa * factor;
455            if (scaled - scaled.round()).abs() < 1e-6 {
456                d = test_d;
457                break;
458            }
459            d = test_d;
460        }
461        d
462    };
463    // Total number of fractional digits in step = mantissa_decimals - magnitude
464    // (when magnitude is negative, we need more decimals).
465    let total_decimals = (mantissa_decimals as i32 - magnitude).max(0) as u32;
466    if total_decimals <= 15 {
467        let factor = 10.0_f64.powi(total_decimals as i32);
468        result = (result * factor).round() / factor;
469    }
470
471    // Snap very-near-zero results to exactly zero.
472    if result.abs() < step.abs() * 1e-10 {
473        0.0
474    } else {
475        result
476    }
477}
478
479/// Creates a `TickSet` from a list of positions, generating labels via `format_tick`.
480fn make_tick_set(positions: Vec<f64>) -> TickSet {
481    let labels = positions.iter().map(|&v| format_tick(v)).collect();
482    TickSet { positions, labels }
483}
484
485// ===========================================================================
486// Tests
487// ===========================================================================
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    // -----------------------------------------------------------------------
494    // Helpers
495    // -----------------------------------------------------------------------
496
497    /// Helper to extract positions from the Vec<Tick> returned by generate_ticks.
498    fn positions(ticks: &[Tick]) -> Vec<f64> {
499        ticks.iter().map(|t| t.value).collect()
500    }
501
502    /// Helper to extract labels from the Vec<Tick> returned by generate_ticks.
503    fn labels(ticks: &[Tick]) -> Vec<&str> {
504        ticks.iter().map(|t| t.label.as_str()).collect()
505    }
506
507    /// Asserts that a Vec<Tick> has valid, sorted, non-empty entries.
508    fn assert_nice(ticks: &[Tick]) {
509        assert!(!ticks.is_empty(), "tick set should not be empty");
510        for w in ticks.windows(2) {
511            assert!(
512                w[1].value >= w[0].value,
513                "ticks must be sorted: {} came before {}",
514                w[0].value,
515                w[1].value
516            );
517        }
518    }
519
520    /// Asserts that the ticks cover the data range (first tick <= dmin, last tick >= dmax,
521    /// or within one step of the boundary).
522    fn assert_covers(ticks: &[Tick], dmin: f64, dmax: f64) {
523        let first = ticks.first().unwrap().value;
524        let last = ticks.last().unwrap().value;
525        let step = if ticks.len() >= 2 {
526            ticks[1].value - ticks[0].value
527        } else {
528            (dmax - dmin).abs().max(1.0)
529        };
530        assert!(
531            first <= dmin + step * 0.01,
532            "first tick {} should be <= data_min {} (step={})",
533            first,
534            dmin,
535            step
536        );
537        assert!(
538            last >= dmax - step * 0.01,
539            "last tick {} should be >= data_max {} (step={})",
540            last,
541            dmax,
542            step
543        );
544    }
545
546    // -----------------------------------------------------------------------
547    // Range [0, 10]
548    // -----------------------------------------------------------------------
549
550    #[test]
551    fn range_0_10() {
552        let ticks = generate_ticks(0.0, 10.0, 6, &Scale::Linear);
553        assert_nice(&ticks);
554        assert_covers(&ticks, 0.0, 10.0);
555        // Should produce ticks with step 2: 0, 2, 4, 6, 8, 10.
556        assert_eq!(positions(&ticks), vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]);
557        assert_eq!(labels(&ticks), vec!["0", "2", "4", "6", "8", "10"]);
558    }
559
560    // -----------------------------------------------------------------------
561    // Range [0, 1]
562    // -----------------------------------------------------------------------
563
564    #[test]
565    fn range_0_1() {
566        let ticks = generate_ticks(0.0, 1.0, 6, &Scale::Linear);
567        assert_nice(&ticks);
568        assert_covers(&ticks, 0.0, 1.0);
569        assert_eq!(positions(&ticks), vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
570        assert_eq!(labels(&ticks), vec!["0", "0.2", "0.4", "0.6", "0.8", "1"]);
571    }
572
573    // -----------------------------------------------------------------------
574    // Range [-5, 5]
575    // -----------------------------------------------------------------------
576
577    #[test]
578    fn range_neg5_pos5() {
579        let ticks = generate_ticks(-5.0, 5.0, 6, &Scale::Linear);
580        assert_nice(&ticks);
581        assert_covers(&ticks, -5.0, 5.0);
582        let pos = positions(&ticks);
583        // The algorithm should include 0 and cover [-5, 5].
584        assert!(
585            pos.contains(&0.0),
586            "ticks for [-5,5] should include zero: {:?}",
587            pos
588        );
589        assert!(*pos.first().unwrap() <= -5.0);
590        assert!(*pos.last().unwrap() >= 5.0);
591    }
592
593    // -----------------------------------------------------------------------
594    // Range [0, 100]
595    // -----------------------------------------------------------------------
596
597    #[test]
598    fn range_0_100() {
599        let ticks = generate_ticks(0.0, 100.0, 6, &Scale::Linear);
600        assert_nice(&ticks);
601        assert_covers(&ticks, 0.0, 100.0);
602        assert_eq!(positions(&ticks), vec![0.0, 20.0, 40.0, 60.0, 80.0, 100.0]);
603        assert_eq!(labels(&ticks), vec!["0", "20", "40", "60", "80", "100"]);
604    }
605
606    // -----------------------------------------------------------------------
607    // Range [0, 1_000_000]
608    // -----------------------------------------------------------------------
609
610    #[test]
611    fn range_0_1e6() {
612        let ticks = generate_ticks(0.0, 1_000_000.0, 6, &Scale::Linear);
613        assert_nice(&ticks);
614        assert_covers(&ticks, 0.0, 1_000_000.0);
615        // Should produce ticks at multiples of 200000.
616        assert_eq!(
617            positions(&ticks),
618            vec![0.0, 200_000.0, 400_000.0, 600_000.0, 800_000.0, 1_000_000.0]
619        );
620    }
621
622    // -----------------------------------------------------------------------
623    // Range [0.001, 0.01]
624    // -----------------------------------------------------------------------
625
626    #[test]
627    fn range_0001_001() {
628        let ticks = generate_ticks(0.001, 0.01, 6, &Scale::Linear);
629        assert_nice(&ticks);
630        assert_covers(&ticks, 0.001, 0.01);
631        let pos = positions(&ticks);
632        let first = *pos.first().unwrap();
633        let last = *pos.last().unwrap();
634        assert!(first <= 0.001 + 1e-12);
635        assert!(last >= 0.01 - 1e-12);
636    }
637
638    // -----------------------------------------------------------------------
639    // Tick count is reasonable
640    // -----------------------------------------------------------------------
641
642    #[test]
643    fn tick_count_reasonable() {
644        for (lo, hi) in &[
645            (0.0, 10.0),
646            (0.0, 1.0),
647            (-100.0, 100.0),
648            (0.0, 0.005),
649            (1.0, 2.0),
650        ] {
651            let ticks = generate_ticks(*lo, *hi, 6, &Scale::Linear);
652            assert!(
653                ticks.len() >= 3 && ticks.len() <= 15,
654                "range [{}, {}] produced {} ticks (expected 3-15): {:?}",
655                lo,
656                hi,
657                ticks.len(),
658                positions(&ticks)
659            );
660        }
661    }
662
663    // -----------------------------------------------------------------------
664    // Degenerate / edge cases
665    // -----------------------------------------------------------------------
666
667    #[test]
668    fn degenerate_same_min_max() {
669        let ticks = generate_ticks(5.0, 5.0, 6, &Scale::Linear);
670        assert!(!ticks.is_empty(), "should produce ticks even for degenerate range");
671    }
672
673    #[test]
674    fn degenerate_zero_range() {
675        let ticks = generate_ticks(0.0, 0.0, 6, &Scale::Linear);
676        assert!(!ticks.is_empty());
677    }
678
679    #[test]
680    fn reversed_range() {
681        let ticks = generate_ticks(10.0, 0.0, 6, &Scale::Linear);
682        assert_nice(&ticks);
683        // Should be equivalent to [0, 10].
684        assert!(ticks.first().unwrap().value <= 0.0 + 0.01);
685        assert!(ticks.last().unwrap().value >= 10.0 - 0.01);
686    }
687
688    // -----------------------------------------------------------------------
689    // Log-scale ticks
690    // -----------------------------------------------------------------------
691
692    #[test]
693    fn log_ticks_basic() {
694        let ticks = generate_ticks(1.0, 10000.0, 5, &Scale::Log10);
695        assert_nice(&ticks);
696        assert!(!ticks.is_empty());
697        // All positions should be positive.
698        for t in &ticks {
699            assert!(t.value > 0.0, "log tick should be positive: {}", t.value);
700        }
701    }
702
703    #[test]
704    fn log_ticks_narrow() {
705        // Narrow range within a single decade: falls back to linear.
706        let ticks = generate_ticks(1.0, 5.0, 5, &Scale::Log10);
707        assert!(!ticks.is_empty());
708    }
709
710    // -----------------------------------------------------------------------
711    // Formatting
712    // -----------------------------------------------------------------------
713
714    #[test]
715    fn format_zero() {
716        assert_eq!(format_tick(0.0), "0");
717    }
718
719    #[test]
720    fn format_integer() {
721        assert_eq!(format_tick(42.0), "42");
722        assert_eq!(format_tick(-7.0), "-7");
723    }
724
725    #[test]
726    fn format_decimal() {
727        assert_eq!(format_tick(0.5), "0.5");
728        assert_eq!(format_tick(2.5), "2.5");
729        assert_eq!(format_tick(0.25), "0.25");
730    }
731
732    #[test]
733    fn format_no_trailing_zeros() {
734        assert_eq!(format_tick(1.0), "1");
735        assert_eq!(format_tick(10.0), "10");
736        assert_eq!(format_tick(0.2), "0.2");
737    }
738
739    #[test]
740    fn format_scientific() {
741        let label = format_tick(1e-8);
742        assert!(
743            label.contains('e'),
744            "very small numbers should use scientific notation: {}",
745            label
746        );
747    }
748
749    // -----------------------------------------------------------------------
750    // SymLog scale dispatch
751    // -----------------------------------------------------------------------
752
753    #[test]
754    fn symlog_ticks() {
755        let ticks = generate_ticks(-100.0, 100.0, 6, &Scale::SymLog { linthresh: 1.0 });
756        assert_nice(&ticks);
757        let pos = positions(&ticks);
758        assert!(
759            pos.contains(&0.0),
760            "symlog ticks for symmetric range should include zero: {:?}",
761            pos
762        );
763    }
764
765    // -----------------------------------------------------------------------
766    // Strip trailing zeros helper
767    // -----------------------------------------------------------------------
768
769    #[test]
770    fn strip_zeros() {
771        assert_eq!(strip_trailing_zeros("1.200"), "1.2");
772        assert_eq!(strip_trailing_zeros("3.0"), "3");
773        assert_eq!(strip_trailing_zeros("100"), "100");
774        assert_eq!(strip_trailing_zeros("0.00100"), "0.001");
775    }
776
777    // -----------------------------------------------------------------------
778    // Scoring functions
779    // -----------------------------------------------------------------------
780
781    #[test]
782    fn density_score_perfect() {
783        // When num_ticks == target, score should be 1.0.
784        let s = density_score(6.0, 6.0, 10.0);
785        assert!((s - 1.0).abs() < 1e-10, "perfect density score should be 1.0, got {}", s);
786    }
787
788    #[test]
789    fn density_score_degrades() {
790        let s6 = density_score(6.0, 6.0, 10.0);
791        let s12 = density_score(12.0, 6.0, 10.0);
792        assert!(s6 > s12, "density should degrade as tick count diverges from target");
793    }
794
795    #[test]
796    fn coverage_score_perfect() {
797        let s = coverage_score(0.0, 10.0, 0.0, 10.0);
798        assert!(
799            (s - 1.0).abs() < 1e-10,
800            "perfect coverage should be 1.0, got {}",
801            s
802        );
803    }
804
805    #[test]
806    fn coverage_score_overshoot() {
807        let s_tight = coverage_score(0.0, 10.0, 0.0, 10.0);
808        let s_wide = coverage_score(-5.0, 15.0, 0.0, 10.0);
809        assert!(
810            s_tight > s_wide,
811            "tighter coverage should score higher: {} vs {}",
812            s_tight,
813            s_wide
814        );
815    }
816
817    #[test]
818    fn simplicity_prefers_earlier_q() {
819        let ticks_with_zero = vec![0.0, 1.0, 2.0];
820        let s0 = simplicity_score(0, &ticks_with_zero); // q=1
821        let s2 = simplicity_score(2, &ticks_with_zero); // q=2
822        assert!(s0 > s2, "q=1 should score higher on simplicity than q=2");
823    }
824
825    // -----------------------------------------------------------------------
826    // Large-range stress test
827    // -----------------------------------------------------------------------
828
829    #[test]
830    fn large_range_no_panic() {
831        let ticks = generate_ticks(0.0, 1e12, 6, &Scale::Linear);
832        assert_nice(&ticks);
833        assert!(!ticks.is_empty());
834    }
835
836    #[test]
837    fn tiny_range_no_panic() {
838        let ticks = generate_ticks(1e-10, 2e-10, 6, &Scale::Linear);
839        assert_nice(&ticks);
840        assert!(!ticks.is_empty());
841    }
842
843    // -----------------------------------------------------------------------
844    // Negative range
845    // -----------------------------------------------------------------------
846
847    #[test]
848    fn negative_range() {
849        let ticks = generate_ticks(-100.0, -10.0, 6, &Scale::Linear);
850        assert_nice(&ticks);
851        assert_covers(&ticks, -100.0, -10.0);
852        for t in &ticks {
853            assert!(t.value <= 0.0, "ticks for negative range should be non-positive: {}", t.value);
854        }
855    }
856
857    // -----------------------------------------------------------------------
858    // TickSet -> Vec<Tick> conversion
859    // -----------------------------------------------------------------------
860
861    #[test]
862    fn tick_set_into_ticks() {
863        let ts = make_tick_set(vec![0.0, 5.0, 10.0]);
864        let ticks = ts.into_ticks();
865        assert_eq!(ticks.len(), 3);
866        assert_eq!(ticks[0].value, 0.0);
867        assert_eq!(ticks[0].label, "0");
868        assert_eq!(ticks[1].value, 5.0);
869        assert_eq!(ticks[1].label, "5");
870        assert_eq!(ticks[2].value, 10.0);
871        assert_eq!(ticks[2].label, "10");
872    }
873}