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