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 => generate_linear_ticks(data_min, data_max, target_count),
66        Scale::Log10 => generate_log_ticks(data_min, data_max, target_count),
67        Scale::SymLog { linthresh } => {
68            generate_symlog_ticks(data_min, data_max, target_count, *linthresh)
69        }
70    };
71    tick_set.into_ticks()
72}
73
74// ---------------------------------------------------------------------------
75// Talbot/Wilkinson extended algorithm for linear scales
76// ---------------------------------------------------------------------------
77
78/// Preference-ordered "nice" step base numbers (Q sequence from the paper).
79/// Earlier entries score higher on simplicity.
80const Q: [f64; 6] = [1.0, 5.0, 2.0, 2.5, 4.0, 3.0];
81
82/// Weights for the four scoring objectives.
83const W_SIMPLICITY: f64 = 0.2;
84const W_COVERAGE: f64 = 0.25;
85const W_DENSITY: f64 = 0.35;
86const W_LEGIBILITY: f64 = 0.2;
87
88/// Generates optimally-spaced ticks for a linear (or symlog) axis.
89///
90/// Implements the Talbot/Wilkinson extended algorithm:
91///   For each candidate base `q` in Q, for a range of exponents `k`, and for
92///   a range of tick counts `j`, compute candidate tick sequences and score them.
93///   Return the sequence with the highest weighted score.
94fn generate_linear_ticks(data_min: f64, data_max: f64, target_count: usize) -> TickSet {
95    // Handle degenerate ranges.
96    if !data_min.is_finite() || !data_max.is_finite() {
97        return make_tick_set(vec![0.0]);
98    }
99
100    let (dmin, dmax) = if (data_max - data_min).abs() < f64::EPSILON * 100.0 {
101        // Degenerate: single-point range -- widen it.
102        if data_min == 0.0 {
103            (-1.0, 1.0)
104        } else {
105            let delta = data_min.abs() * 0.1;
106            (data_min - delta, data_min + delta)
107        }
108    } else if data_min > data_max {
109        (data_max, data_min)
110    } else {
111        (data_min, data_max)
112    };
113
114    let target = target_count.max(2) as f64;
115    let range = dmax - dmin;
116
117    let mut best_score = f64::NEG_INFINITY;
118    let mut best_ticks: Option<Vec<f64>> = None;
119
120    // Iterate over candidate base numbers.
121    for (qi, &q) in Q.iter().enumerate() {
122        // Iterate over candidate tick counts (j = number of ticks - 1, i.e. intervals).
123        // We search a range around the target count.
124        let j_min = 1_usize;
125        let j_max = (target as usize * 3).max(12);
126
127        for j in j_min..=j_max {
128            let j_f = j as f64;
129
130            // Density score for this tick count (before we know the exact positions).
131            let density = density_score(j_f + 1.0, target, range);
132            // Early skip: if the maximum possible total score can't beat the best, skip.
133            let max_possible = W_SIMPLICITY + W_COVERAGE + W_DENSITY * density + W_LEGIBILITY;
134            if max_possible < best_score {
135                continue;
136            }
137
138            // Determine the magnitude (power of 10) for the step size.
139            // step = q * 10^k, and we want j * step ~ range.
140            let ideal_step = range / j_f;
141            // k such that q * 10^k ~ ideal_step  =>  k ~ log10(ideal_step / q)
142            let k_float = (ideal_step / q).log10().floor();
143
144            // Search a small neighbourhood of k to find the best exponent.
145            for k_offset in -2_i32..=2 {
146                let k = k_float as i32 + k_offset;
147                let step = q * 10.0_f64.powi(k);
148
149                if step <= 0.0 || !step.is_finite() {
150                    continue;
151                }
152
153                // We try a few different starting points to find the best coverage.
154                // The paper iterates over `i` (the index offset for the starting tick).
155                // We search offsets that keep the ticks close to covering [dmin, dmax].
156                let i_min = ((dmin / step).ceil() - j_f) as i64;
157                let i_max = (dmin / step).floor() as i64 + 1;
158
159                for i in i_min..=i_max {
160                    let tick_min = i as f64 * step;
161                    let tick_max = tick_min + j_f * step;
162
163                    // Ticks must span at least the data range (or come close).
164                    if tick_max < dmax - step * 0.5 {
165                        continue;
166                    }
167                    if tick_min > dmin + step * 0.5 {
168                        continue;
169                    }
170
171                    // Generate the tick positions.
172                    let num_ticks = j + 1;
173                    let ticks: Vec<f64> = (0..num_ticks)
174                        .map(|t| {
175                            let v = tick_min + t as f64 * step;
176                            // Round to cancel floating-point accumulation error.
177                            snap_to_step(v, step)
178                        })
179                        .collect();
180
181                    // Score this candidate.
182                    let simplicity = simplicity_score(qi, &ticks);
183                    let coverage = coverage_score(tick_min, tick_max, dmin, dmax);
184                    let density = density_score(num_ticks as f64, target, range);
185                    let legibility = legibility_score(&ticks);
186
187                    let score = W_SIMPLICITY * simplicity
188                        + W_COVERAGE * coverage
189                        + W_DENSITY * density
190                        + W_LEGIBILITY * legibility;
191
192                    if score > best_score {
193                        best_score = score;
194                        best_ticks = Some(ticks);
195                    }
196                }
197            }
198        }
199    }
200
201    let ticks = best_ticks.unwrap_or_else(|| {
202        // Ultimate fallback: evenly divide.
203        let step = range / target;
204        (0..=target as usize)
205            .map(|i| dmin + i as f64 * step)
206            .collect()
207    });
208
209    make_tick_set(ticks)
210}
211
212// ---------------------------------------------------------------------------
213// Scoring functions
214// ---------------------------------------------------------------------------
215
216/// Simplicity score: rewards choosing earlier (more "round") entries in Q,
217/// and gives a bonus when zero is included among the ticks.
218fn simplicity_score(q_index: usize, ticks: &[f64]) -> f64 {
219    let q_len = Q.len() as f64;
220    // i ranges from 0 (best) to Q.len()-1 (worst). Map to [0, 1].
221    let q_penalty = q_index as f64 / q_len;
222    // Bonus for including zero.
223    let zero_bonus = if ticks.iter().any(|&v| v.abs() < f64::EPSILON * 100.0) {
224        1.0
225    } else {
226        0.0
227    };
228    1.0 - q_penalty + zero_bonus * 0.2
229}
230
231/// Coverage score: rewards tick ranges that tightly cover [dmin, dmax].
232///
233/// The score is based on the ratio `data_range / tick_range`. A perfect score
234/// of 1.0 means the tick range exactly matches the data range. Overshoot
235/// (tick_range > data_range) is penalized mildly. Undershoot (ticks that don't
236/// span the data) returns 0.0 -- we never want ticks that miss part of the data.
237fn coverage_score(tick_min: f64, tick_max: f64, dmin: f64, dmax: f64) -> f64 {
238    let data_range = dmax - dmin;
239    if data_range <= 0.0 {
240        return 1.0;
241    }
242    // Hard reject: ticks must fully cover the data range.
243    if tick_min > dmin + data_range * 0.001 || tick_max < dmax - data_range * 0.001 {
244        return 0.0;
245    }
246    let tick_range = tick_max - tick_min;
247    // coverage = 1 - 0.5 * ((tick_range - data_range) / data_range)^2
248    // This gently penalizes overshoot while rewarding tight coverage.
249    let overshoot_ratio = (tick_range - data_range) / data_range;
250    (1.0 - 0.5 * overshoot_ratio * overshoot_ratio).max(0.0)
251}
252
253/// Density score: rewards tick counts close to the target. Uses a Gaussian-like
254/// falloff centered on `target`.
255fn density_score(num_ticks: f64, target: f64, _range: f64) -> f64 {
256    let ratio = if target > 0.0 {
257        num_ticks / target
258    } else {
259        1.0
260    };
261    // Score peaks at ratio = 1, falls off symmetrically.
262    // Using 2 - max(ratio, 1/ratio) gives a nice [0,1] range for reasonable ratios.
263    let raw = 2.0 - ratio.max(1.0 / ratio);
264    raw.clamp(0.0, 1.0)
265}
266
267/// Legibility score: rewards ticks that produce short, clean formatted strings.
268/// Penalizes very long labels (many decimal places) or scientific notation.
269fn legibility_score(ticks: &[f64]) -> f64 {
270    if ticks.is_empty() {
271        return 1.0;
272    }
273    let total: f64 = ticks.iter().map(|&v| single_legibility(v)).sum();
274    total / ticks.len() as f64
275}
276
277/// Legibility of a single tick value.
278fn single_legibility(value: f64) -> f64 {
279    let label = format_tick(value);
280    let len = label.len();
281    // Short labels are best. Penalize progressively.
282    if len <= 3 {
283        1.0
284    } else if len <= 5 {
285        0.9
286    } else if len <= 7 {
287        0.75
288    } else if len <= 10 {
289        0.5
290    } else {
291        0.3
292    }
293}
294
295// ---------------------------------------------------------------------------
296// Log-scale ticks
297// ---------------------------------------------------------------------------
298
299/// Generates ticks for a log10 scale. Places ticks at powers of 10, with
300/// optional sub-decade ticks when the range is small.
301fn generate_log_ticks(data_min: f64, data_max: f64, target_count: usize) -> TickSet {
302    let lo = data_min.max(f64::EPSILON);
303    let hi = data_max.max(lo);
304
305    let log_lo = lo.log10().floor() as i32;
306    let log_hi = hi.log10().ceil() as i32;
307
308    let decades = (log_hi - log_lo) as usize;
309
310    if decades <= 1 {
311        // Very narrow log range: fall back to linear-style ticks within this range.
312        return generate_linear_ticks(lo, hi, target_count);
313    }
314
315    let mut positions = Vec::new();
316
317    if decades <= 3 {
318        // Few decades: include sub-decade ticks at 2, 5.
319        for exp in log_lo..=log_hi {
320            let base = 10.0_f64.powi(exp);
321            for &mult in &[1.0, 2.0, 5.0] {
322                let val = base * mult;
323                if val >= lo * 0.999 && val <= hi * 1.001 {
324                    positions.push(val);
325                }
326            }
327        }
328    } else {
329        // Many decades: only powers of 10.
330        // If there are too many decades, skip some.
331        let skip = ((decades as f64) / (target_count.max(2) as f64)).ceil() as i32;
332        let skip = skip.max(1);
333        let mut exp = log_lo;
334        while exp <= log_hi {
335            let val = 10.0_f64.powi(exp);
336            if val >= lo * 0.999 && val <= hi * 1.001 {
337                positions.push(val);
338            }
339            exp += skip;
340        }
341        // Always include the last power.
342        let last = 10.0_f64.powi(log_hi);
343        if positions.last().map_or(true, |&v| (v - last).abs() > f64::EPSILON)
344            && last <= hi * 1.001 {
345                positions.push(last);
346        }
347    }
348
349    if positions.is_empty() {
350        positions.push(lo);
351        positions.push(hi);
352    }
353
354    make_tick_set(positions)
355}
356
357/// Generates minor tick positions for a log10 scale.
358///
359/// Minor ticks are placed at multiples 2, 3, 4, 5, 6, 7, 8, 9 of each power
360/// of 10 within the given data range. These are the sub-decade ticks that give
361/// log-scale plots their characteristic visual pattern.
362///
363/// Returns only positions (no labels), since minor ticks are typically drawn
364/// without labels.
365pub fn generate_log_minor_ticks(data_min: f64, data_max: f64) -> Vec<f64> {
366    let lo = data_min.max(f64::EPSILON);
367    let hi = data_max.max(lo);
368
369    let log_lo = lo.log10().floor() as i32;
370    let log_hi = hi.log10().ceil() as i32;
371
372    let mut positions = Vec::new();
373
374    for exp in log_lo..=log_hi {
375        let base = 10.0_f64.powi(exp);
376        for mult in 2..=9 {
377            let val = base * mult as f64;
378            if val >= lo * 0.999 && val <= hi * 1.001 {
379                positions.push(val);
380            }
381        }
382    }
383
384    positions
385}
386
387/// Generates ticks for a symlog (symmetric log) scale.
388///
389/// Produces ticks that reflect the symmetry of the symlog transform: logarithmic
390/// ticks for the positive and negative regions beyond `linthresh`, and linear
391/// ticks in the `[-linthresh, linthresh]` region.
392fn generate_symlog_ticks(data_min: f64, data_max: f64, target_count: usize, linthresh: f64) -> TickSet {
393    // Guard: if linthresh is non-positive or non-finite, fall back to linear ticks.
394    if linthresh <= 0.0 || !linthresh.is_finite() {
395        return generate_linear_ticks(data_min, data_max, target_count);
396    }
397
398    let mut positions = Vec::new();
399
400    // Always include zero if the range crosses it.
401    if data_min <= 0.0 && data_max >= 0.0 {
402        positions.push(0.0);
403    }
404
405    // Add +-linthresh markers if they fall within the range.
406    if linthresh <= data_max && linthresh >= data_min {
407        positions.push(linthresh);
408    }
409    if -linthresh >= data_min && -linthresh <= data_max {
410        positions.push(-linthresh);
411    }
412
413    // Positive logarithmic region: powers of 10 beyond linthresh.
414    if data_max > linthresh {
415        let log_lo = linthresh.log10().ceil() as i32;
416        let log_hi = data_max.abs().log10().ceil() as i32;
417        for exp in log_lo..=log_hi {
418            let val = 10.0_f64.powi(exp);
419            if val > linthresh && val <= data_max * 1.001 {
420                positions.push(val);
421            }
422        }
423    }
424
425    // Negative logarithmic region: negative powers of 10 beyond -linthresh.
426    if data_min < -linthresh {
427        let log_lo = linthresh.log10().ceil() as i32;
428        let log_hi = data_min.abs().log10().ceil() as i32;
429        for exp in log_lo..=log_hi {
430            let val = -10.0_f64.powi(exp);
431            if val < -linthresh && val >= data_min * 1.001 {
432                positions.push(val);
433            }
434        }
435    }
436
437    // If the linear region is significant, add a few linear ticks within it.
438    let lin_lo = data_min.max(-linthresh);
439    let lin_hi = data_max.min(linthresh);
440    if lin_hi > lin_lo {
441        let lin_ticks = generate_linear_ticks(lin_lo, lin_hi, (target_count / 3).max(2));
442        for &pos in &lin_ticks.positions {
443            positions.push(pos);
444        }
445    }
446
447    // Deduplicate and sort.
448    positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
449    positions.dedup_by(|a, b| (*a - *b).abs() < f64::EPSILON * 100.0);
450
451    // If we ended up with too few ticks, fall back to linear.
452    if positions.len() < 2 {
453        return generate_linear_ticks(data_min, data_max, target_count);
454    }
455
456    make_tick_set(positions)
457}
458
459// ---------------------------------------------------------------------------
460// Tick formatting
461// ---------------------------------------------------------------------------
462
463/// Formats a single tick value to a compact, human-readable string.
464///
465/// This is the public entry point used when custom tick positions are set
466/// without explicit labels. Delegates to the internal `format_tick` function.
467pub fn format_tick_value(value: f64) -> String {
468    format_tick(value)
469}
470
471/// Formats a tick value to a compact string.
472///
473/// Uses fixed notation for values whose absolute magnitude is in [0.001, 999_999].
474/// Uses scientific notation otherwise. Strips trailing zeros from the fractional part.
475fn format_tick(value: f64) -> String {
476    if value == 0.0 {
477        return "0".to_string();
478    }
479
480    let abs = value.abs();
481
482    if (0.001..1_000_000.0).contains(&abs) {
483        // Determine how many decimal places we need.
484        // We want enough to distinguish this value, but not excessive.
485        let decimals = needed_decimals(value);
486        let formatted = format!("{:.prec$}", value, prec = decimals);
487        strip_trailing_zeros(&formatted)
488    } else {
489        // Scientific notation.
490        let formatted = format!("{:.6e}", value);
491        clean_scientific(&formatted)
492    }
493}
494
495/// Determines how many decimal places are needed to represent a value faithfully.
496/// Returns at most 10 decimal places.
497fn needed_decimals(value: f64) -> usize {
498    let abs = value.abs();
499    if abs == abs.floor() && abs < 1e15 {
500        return 0;
501    }
502    // Try increasing precision until the round-trip is faithful.
503    for d in 1..=10 {
504        let factor = 10.0_f64.powi(d as i32);
505        let rounded = (value * factor).round() / factor;
506        if (rounded - value).abs() < f64::EPSILON * abs.max(1.0) * 10.0 {
507            return d;
508        }
509    }
510    10
511}
512
513/// Strips trailing zeros after a decimal point. E.g. "1.200" -> "1.2", "3.0" -> "3".
514fn strip_trailing_zeros(s: &str) -> String {
515    if !s.contains('.') {
516        return s.to_string();
517    }
518    let trimmed = s.trim_end_matches('0');
519    let trimmed = trimmed.trim_end_matches('.');
520    trimmed.to_string()
521}
522
523/// Cleans scientific notation: "1.000000e0" -> "1e0", strips trailing zeros in mantissa.
524fn clean_scientific(s: &str) -> String {
525    if let Some(e_pos) = s.find('e') {
526        let mantissa = &s[..e_pos];
527        let exponent = &s[e_pos..]; // includes 'e'
528        let cleaned_mantissa = strip_trailing_zeros(mantissa);
529        format!("{}{}", cleaned_mantissa, exponent)
530    } else {
531        s.to_string()
532    }
533}
534
535// ---------------------------------------------------------------------------
536// Helpers
537// ---------------------------------------------------------------------------
538
539/// Rounds a value to the nearest multiple of `step`, eliminating floating-point
540/// accumulation errors such as `0.2 + 0.2 + 0.2 = 0.6000000000000001`.
541///
542/// Works by normalizing the step to remove its power-of-10 magnitude, computing
543/// the snap in that normalized space, then scaling back. This ensures correct
544/// behavior for both large steps (e.g. 200000) and tiny steps (e.g. 2e-11).
545fn snap_to_step(value: f64, step: f64) -> f64 {
546    if step == 0.0 {
547        return value;
548    }
549
550    // Compute the integer multiple of step closest to value.
551    let n = (value / step).round();
552    let mut result = n * step;
553
554    // Normalize: figure out how many digits the step's "significant part" needs.
555    // E.g. step=0.2 -> magnitude=-1, mantissa=2.0
556    //      step=200  -> magnitude=2, mantissa=2.0
557    //      step=2.5  -> magnitude=0, mantissa=2.5
558    let magnitude = step.abs().log10().floor() as i32;
559    // Number of decimals needed for the mantissa (step / 10^magnitude).
560    let mantissa = step.abs() / 10.0_f64.powi(magnitude);
561    let mantissa_decimals = {
562        let mut d = 0usize;
563        for test_d in 0..=5 {
564            let factor = 10.0_f64.powi(test_d as i32);
565            let scaled = mantissa * factor;
566            if (scaled - scaled.round()).abs() < 1e-6 {
567                d = test_d;
568                break;
569            }
570            d = test_d;
571        }
572        d
573    };
574    // Total number of fractional digits in step = mantissa_decimals - magnitude
575    // (when magnitude is negative, we need more decimals).
576    let total_decimals = (mantissa_decimals as i32 - magnitude).max(0) as u32;
577    if total_decimals <= 15 {
578        let factor = 10.0_f64.powi(total_decimals as i32);
579        result = (result * factor).round() / factor;
580    }
581
582    // Snap very-near-zero results to exactly zero.
583    if result.abs() < step.abs() * 1e-10 {
584        0.0
585    } else {
586        result
587    }
588}
589
590/// Creates a `TickSet` from a list of positions, generating labels via `format_tick`.
591fn make_tick_set(positions: Vec<f64>) -> TickSet {
592    let labels = positions.iter().map(|&v| format_tick(v)).collect();
593    TickSet { positions, labels }
594}
595
596// ===========================================================================
597// Tests
598// ===========================================================================
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    // -----------------------------------------------------------------------
605    // Helpers
606    // -----------------------------------------------------------------------
607
608    /// Helper to extract positions from the Vec<Tick> returned by generate_ticks.
609    fn positions(ticks: &[Tick]) -> Vec<f64> {
610        ticks.iter().map(|t| t.value).collect()
611    }
612
613    /// Helper to extract labels from the Vec<Tick> returned by generate_ticks.
614    fn labels(ticks: &[Tick]) -> Vec<&str> {
615        ticks.iter().map(|t| t.label.as_str()).collect()
616    }
617
618    /// Asserts that a Vec<Tick> has valid, sorted, non-empty entries.
619    fn assert_nice(ticks: &[Tick]) {
620        assert!(!ticks.is_empty(), "tick set should not be empty");
621        for w in ticks.windows(2) {
622            assert!(
623                w[1].value >= w[0].value,
624                "ticks must be sorted: {} came before {}",
625                w[0].value,
626                w[1].value
627            );
628        }
629    }
630
631    /// Asserts that the ticks cover the data range (first tick <= dmin, last tick >= dmax,
632    /// or within one step of the boundary).
633    fn assert_covers(ticks: &[Tick], dmin: f64, dmax: f64) {
634        let first = ticks.first().unwrap().value;
635        let last = ticks.last().unwrap().value;
636        let step = if ticks.len() >= 2 {
637            ticks[1].value - ticks[0].value
638        } else {
639            (dmax - dmin).abs().max(1.0)
640        };
641        assert!(
642            first <= dmin + step * 0.01,
643            "first tick {} should be <= data_min {} (step={})",
644            first,
645            dmin,
646            step
647        );
648        assert!(
649            last >= dmax - step * 0.01,
650            "last tick {} should be >= data_max {} (step={})",
651            last,
652            dmax,
653            step
654        );
655    }
656
657    // -----------------------------------------------------------------------
658    // Range [0, 10]
659    // -----------------------------------------------------------------------
660
661    #[test]
662    fn range_0_10() {
663        let ticks = generate_ticks(0.0, 10.0, 6, &Scale::Linear);
664        assert_nice(&ticks);
665        assert_covers(&ticks, 0.0, 10.0);
666        // Should produce ticks with step 2: 0, 2, 4, 6, 8, 10.
667        assert_eq!(positions(&ticks), vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]);
668        assert_eq!(labels(&ticks), vec!["0", "2", "4", "6", "8", "10"]);
669    }
670
671    // -----------------------------------------------------------------------
672    // Range [0, 1]
673    // -----------------------------------------------------------------------
674
675    #[test]
676    fn range_0_1() {
677        let ticks = generate_ticks(0.0, 1.0, 6, &Scale::Linear);
678        assert_nice(&ticks);
679        assert_covers(&ticks, 0.0, 1.0);
680        assert_eq!(positions(&ticks), vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
681        assert_eq!(labels(&ticks), vec!["0", "0.2", "0.4", "0.6", "0.8", "1"]);
682    }
683
684    // -----------------------------------------------------------------------
685    // Range [-5, 5]
686    // -----------------------------------------------------------------------
687
688    #[test]
689    fn range_neg5_pos5() {
690        let ticks = generate_ticks(-5.0, 5.0, 6, &Scale::Linear);
691        assert_nice(&ticks);
692        assert_covers(&ticks, -5.0, 5.0);
693        let pos = positions(&ticks);
694        // The algorithm should include 0 and cover [-5, 5].
695        assert!(
696            pos.contains(&0.0),
697            "ticks for [-5,5] should include zero: {:?}",
698            pos
699        );
700        assert!(*pos.first().unwrap() <= -5.0);
701        assert!(*pos.last().unwrap() >= 5.0);
702    }
703
704    // -----------------------------------------------------------------------
705    // Range [0, 100]
706    // -----------------------------------------------------------------------
707
708    #[test]
709    fn range_0_100() {
710        let ticks = generate_ticks(0.0, 100.0, 6, &Scale::Linear);
711        assert_nice(&ticks);
712        assert_covers(&ticks, 0.0, 100.0);
713        assert_eq!(positions(&ticks), vec![0.0, 20.0, 40.0, 60.0, 80.0, 100.0]);
714        assert_eq!(labels(&ticks), vec!["0", "20", "40", "60", "80", "100"]);
715    }
716
717    // -----------------------------------------------------------------------
718    // Range [0, 1_000_000]
719    // -----------------------------------------------------------------------
720
721    #[test]
722    fn range_0_1e6() {
723        let ticks = generate_ticks(0.0, 1_000_000.0, 6, &Scale::Linear);
724        assert_nice(&ticks);
725        assert_covers(&ticks, 0.0, 1_000_000.0);
726        // Should produce ticks at multiples of 200000.
727        assert_eq!(
728            positions(&ticks),
729            vec![0.0, 200_000.0, 400_000.0, 600_000.0, 800_000.0, 1_000_000.0]
730        );
731    }
732
733    // -----------------------------------------------------------------------
734    // Range [0.001, 0.01]
735    // -----------------------------------------------------------------------
736
737    #[test]
738    fn range_0001_001() {
739        let ticks = generate_ticks(0.001, 0.01, 6, &Scale::Linear);
740        assert_nice(&ticks);
741        assert_covers(&ticks, 0.001, 0.01);
742        let pos = positions(&ticks);
743        let first = *pos.first().unwrap();
744        let last = *pos.last().unwrap();
745        assert!(first <= 0.001 + 1e-12);
746        assert!(last >= 0.01 - 1e-12);
747    }
748
749    // -----------------------------------------------------------------------
750    // Tick count is reasonable
751    // -----------------------------------------------------------------------
752
753    #[test]
754    fn tick_count_reasonable() {
755        for (lo, hi) in &[
756            (0.0, 10.0),
757            (0.0, 1.0),
758            (-100.0, 100.0),
759            (0.0, 0.005),
760            (1.0, 2.0),
761        ] {
762            let ticks = generate_ticks(*lo, *hi, 6, &Scale::Linear);
763            assert!(
764                ticks.len() >= 3 && ticks.len() <= 15,
765                "range [{}, {}] produced {} ticks (expected 3-15): {:?}",
766                lo,
767                hi,
768                ticks.len(),
769                positions(&ticks)
770            );
771        }
772    }
773
774    // -----------------------------------------------------------------------
775    // Degenerate / edge cases
776    // -----------------------------------------------------------------------
777
778    #[test]
779    fn degenerate_same_min_max() {
780        let ticks = generate_ticks(5.0, 5.0, 6, &Scale::Linear);
781        assert!(!ticks.is_empty(), "should produce ticks even for degenerate range");
782    }
783
784    #[test]
785    fn degenerate_zero_range() {
786        let ticks = generate_ticks(0.0, 0.0, 6, &Scale::Linear);
787        assert!(!ticks.is_empty());
788    }
789
790    #[test]
791    fn reversed_range() {
792        let ticks = generate_ticks(10.0, 0.0, 6, &Scale::Linear);
793        assert_nice(&ticks);
794        // Should be equivalent to [0, 10].
795        assert!(ticks.first().unwrap().value <= 0.0 + 0.01);
796        assert!(ticks.last().unwrap().value >= 10.0 - 0.01);
797    }
798
799    // -----------------------------------------------------------------------
800    // Log-scale ticks
801    // -----------------------------------------------------------------------
802
803    #[test]
804    fn log_ticks_basic() {
805        let ticks = generate_ticks(1.0, 10000.0, 5, &Scale::Log10);
806        assert_nice(&ticks);
807        assert!(!ticks.is_empty());
808        // All positions should be positive.
809        for t in &ticks {
810            assert!(t.value > 0.0, "log tick should be positive: {}", t.value);
811        }
812    }
813
814    #[test]
815    fn log_ticks_narrow() {
816        // Narrow range within a single decade: falls back to linear.
817        let ticks = generate_ticks(1.0, 5.0, 5, &Scale::Log10);
818        assert!(!ticks.is_empty());
819    }
820
821    // -----------------------------------------------------------------------
822    // Formatting
823    // -----------------------------------------------------------------------
824
825    #[test]
826    fn format_zero() {
827        assert_eq!(format_tick(0.0), "0");
828    }
829
830    #[test]
831    fn format_integer() {
832        assert_eq!(format_tick(42.0), "42");
833        assert_eq!(format_tick(-7.0), "-7");
834    }
835
836    #[test]
837    fn format_decimal() {
838        assert_eq!(format_tick(0.5), "0.5");
839        assert_eq!(format_tick(2.5), "2.5");
840        assert_eq!(format_tick(0.25), "0.25");
841    }
842
843    #[test]
844    fn format_no_trailing_zeros() {
845        assert_eq!(format_tick(1.0), "1");
846        assert_eq!(format_tick(10.0), "10");
847        assert_eq!(format_tick(0.2), "0.2");
848    }
849
850    #[test]
851    fn format_scientific() {
852        let label = format_tick(1e-8);
853        assert!(
854            label.contains('e'),
855            "very small numbers should use scientific notation: {}",
856            label
857        );
858    }
859
860    // -----------------------------------------------------------------------
861    // SymLog scale dispatch
862    // -----------------------------------------------------------------------
863
864    #[test]
865    fn symlog_ticks() {
866        let ticks = generate_ticks(-100.0, 100.0, 6, &Scale::SymLog { linthresh: 1.0 });
867        assert_nice(&ticks);
868        let pos = positions(&ticks);
869        assert!(
870            pos.contains(&0.0),
871            "symlog ticks for symmetric range should include zero: {:?}",
872            pos
873        );
874    }
875
876    // -----------------------------------------------------------------------
877    // Strip trailing zeros helper
878    // -----------------------------------------------------------------------
879
880    #[test]
881    fn strip_zeros() {
882        assert_eq!(strip_trailing_zeros("1.200"), "1.2");
883        assert_eq!(strip_trailing_zeros("3.0"), "3");
884        assert_eq!(strip_trailing_zeros("100"), "100");
885        assert_eq!(strip_trailing_zeros("0.00100"), "0.001");
886    }
887
888    // -----------------------------------------------------------------------
889    // Scoring functions
890    // -----------------------------------------------------------------------
891
892    #[test]
893    fn density_score_perfect() {
894        // When num_ticks == target, score should be 1.0.
895        let s = density_score(6.0, 6.0, 10.0);
896        assert!((s - 1.0).abs() < 1e-10, "perfect density score should be 1.0, got {}", s);
897    }
898
899    #[test]
900    fn density_score_degrades() {
901        let s6 = density_score(6.0, 6.0, 10.0);
902        let s12 = density_score(12.0, 6.0, 10.0);
903        assert!(s6 > s12, "density should degrade as tick count diverges from target");
904    }
905
906    #[test]
907    fn coverage_score_perfect() {
908        let s = coverage_score(0.0, 10.0, 0.0, 10.0);
909        assert!(
910            (s - 1.0).abs() < 1e-10,
911            "perfect coverage should be 1.0, got {}",
912            s
913        );
914    }
915
916    #[test]
917    fn coverage_score_overshoot() {
918        let s_tight = coverage_score(0.0, 10.0, 0.0, 10.0);
919        let s_wide = coverage_score(-5.0, 15.0, 0.0, 10.0);
920        assert!(
921            s_tight > s_wide,
922            "tighter coverage should score higher: {} vs {}",
923            s_tight,
924            s_wide
925        );
926    }
927
928    #[test]
929    fn simplicity_prefers_earlier_q() {
930        let ticks_with_zero = vec![0.0, 1.0, 2.0];
931        let s0 = simplicity_score(0, &ticks_with_zero); // q=1
932        let s2 = simplicity_score(2, &ticks_with_zero); // q=2
933        assert!(s0 > s2, "q=1 should score higher on simplicity than q=2");
934    }
935
936    // -----------------------------------------------------------------------
937    // Large-range stress test
938    // -----------------------------------------------------------------------
939
940    #[test]
941    fn large_range_no_panic() {
942        let ticks = generate_ticks(0.0, 1e12, 6, &Scale::Linear);
943        assert_nice(&ticks);
944        assert!(!ticks.is_empty());
945    }
946
947    #[test]
948    fn tiny_range_no_panic() {
949        let ticks = generate_ticks(1e-10, 2e-10, 6, &Scale::Linear);
950        assert_nice(&ticks);
951        assert!(!ticks.is_empty());
952    }
953
954    // -----------------------------------------------------------------------
955    // Negative range
956    // -----------------------------------------------------------------------
957
958    #[test]
959    fn negative_range() {
960        let ticks = generate_ticks(-100.0, -10.0, 6, &Scale::Linear);
961        assert_nice(&ticks);
962        assert_covers(&ticks, -100.0, -10.0);
963        for t in &ticks {
964            assert!(t.value <= 0.0, "ticks for negative range should be non-positive: {}", t.value);
965        }
966    }
967
968    // -----------------------------------------------------------------------
969    // TickSet -> Vec<Tick> conversion
970    // -----------------------------------------------------------------------
971
972    #[test]
973    fn tick_set_into_ticks() {
974        let ts = make_tick_set(vec![0.0, 5.0, 10.0]);
975        let ticks = ts.into_ticks();
976        assert_eq!(ticks.len(), 3);
977        assert_eq!(ticks[0].value, 0.0);
978        assert_eq!(ticks[0].label, "0");
979        assert_eq!(ticks[1].value, 5.0);
980        assert_eq!(ticks[1].label, "5");
981        assert_eq!(ticks[2].value, 10.0);
982        assert_eq!(ticks[2].label, "10");
983    }
984
985    // -----------------------------------------------------------------------
986    // Log10 tick generation
987    // -----------------------------------------------------------------------
988
989    #[test]
990    fn log_ticks_powers_of_10() {
991        // Range spanning 4 decades: should produce ticks at powers of 10.
992        let ticks = generate_ticks(1.0, 10_000.0, 7, &Scale::Log10);
993        assert_nice(&ticks);
994        let pos = positions(&ticks);
995        // Must include 1, 10, 100, 1000, 10000 (or at least the endpoints).
996        assert!(pos.contains(&1.0), "should include 10^0 = 1: {:?}", pos);
997        assert!(pos.contains(&10.0), "should include 10^1 = 10: {:?}", pos);
998        assert!(pos.contains(&100.0), "should include 10^2 = 100: {:?}", pos);
999        assert!(pos.contains(&1000.0), "should include 10^3 = 1000: {:?}", pos);
1000        assert!(pos.contains(&10000.0), "should include 10^4 = 10000: {:?}", pos);
1001    }
1002
1003    #[test]
1004    fn log_ticks_all_positive() {
1005        let ticks = generate_ticks(0.01, 1_000_000.0, 7, &Scale::Log10);
1006        for t in &ticks {
1007            assert!(t.value > 0.0, "log tick must be positive, got {}", t.value);
1008        }
1009    }
1010
1011    #[test]
1012    fn log_ticks_large_range() {
1013        // 10 decades.
1014        let ticks = generate_ticks(1e-5, 1e5, 7, &Scale::Log10);
1015        assert_nice(&ticks);
1016        assert!(ticks.len() >= 3, "should have at least 3 ticks: {:?}", positions(&ticks));
1017    }
1018
1019    #[test]
1020    fn log_ticks_small_values() {
1021        let ticks = generate_ticks(0.001, 0.1, 5, &Scale::Log10);
1022        assert!(!ticks.is_empty());
1023        for t in &ticks {
1024            assert!(t.value > 0.0);
1025        }
1026    }
1027
1028    // -----------------------------------------------------------------------
1029    // Log minor ticks
1030    // -----------------------------------------------------------------------
1031
1032    #[test]
1033    fn log_minor_ticks_basic() {
1034        let minor = generate_log_minor_ticks(1.0, 100.0);
1035        // Between 1 and 10: should have 2,3,4,5,6,7,8,9
1036        // Between 10 and 100: should have 20,30,40,50,60,70,80,90
1037        assert!(!minor.is_empty());
1038        assert!(minor.contains(&2.0), "should include 2: {:?}", minor);
1039        assert!(minor.contains(&5.0), "should include 5: {:?}", minor);
1040        assert!(minor.contains(&9.0), "should include 9: {:?}", minor);
1041        assert!(minor.contains(&20.0), "should include 20: {:?}", minor);
1042        assert!(minor.contains(&50.0), "should include 50: {:?}", minor);
1043        assert!(minor.contains(&90.0), "should include 90: {:?}", minor);
1044    }
1045
1046    #[test]
1047    fn log_minor_ticks_all_positive() {
1048        let minor = generate_log_minor_ticks(0.01, 1000.0);
1049        for &v in &minor {
1050            assert!(v > 0.0, "minor tick must be positive, got {}", v);
1051        }
1052    }
1053
1054    #[test]
1055    fn log_minor_ticks_sorted() {
1056        let minor = generate_log_minor_ticks(1.0, 10000.0);
1057        for w in minor.windows(2) {
1058            assert!(w[1] >= w[0], "minor ticks not sorted: {} before {}", w[0], w[1]);
1059        }
1060    }
1061
1062    // -----------------------------------------------------------------------
1063    // SymLog tick generation (dedicated)
1064    // -----------------------------------------------------------------------
1065
1066    #[test]
1067    fn symlog_ticks_include_zero_dedicated() {
1068        let ticks = generate_ticks(-100.0, 100.0, 7, &Scale::SymLog { linthresh: 1.0 });
1069        let pos = positions(&ticks);
1070        assert!(pos.contains(&0.0), "symlog ticks should include zero: {:?}", pos);
1071    }
1072
1073    #[test]
1074    fn symlog_ticks_include_linthresh() {
1075        let ticks = generate_ticks(-1000.0, 1000.0, 7, &Scale::SymLog { linthresh: 10.0 });
1076        let pos = positions(&ticks);
1077        assert!(pos.contains(&10.0), "should include +linthresh=10: {:?}", pos);
1078        assert!(pos.contains(&-10.0), "should include -linthresh=-10: {:?}", pos);
1079    }
1080
1081    #[test]
1082    fn symlog_ticks_sorted_dedicated() {
1083        let ticks = generate_ticks(-1000.0, 1000.0, 7, &Scale::SymLog { linthresh: 1.0 });
1084        assert_nice(&ticks);
1085    }
1086
1087    #[test]
1088    fn symlog_ticks_positive_only() {
1089        let ticks = generate_ticks(0.1, 10000.0, 7, &Scale::SymLog { linthresh: 1.0 });
1090        assert!(!ticks.is_empty());
1091        assert_nice(&ticks);
1092    }
1093
1094    #[test]
1095    fn symlog_ticks_negative_only() {
1096        let ticks = generate_ticks(-10000.0, -0.1, 7, &Scale::SymLog { linthresh: 1.0 });
1097        assert!(!ticks.is_empty());
1098        assert_nice(&ticks);
1099    }
1100
1101    #[test]
1102    fn symlog_ticks_degenerate() {
1103        // Entirely within linear region.
1104        let ticks = generate_ticks(-0.5, 0.5, 5, &Scale::SymLog { linthresh: 1.0 });
1105        assert!(!ticks.is_empty());
1106    }
1107}