1use crate::scale::Scale;
8
9#[derive(Debug, Clone)]
15pub struct Tick {
16 pub value: f64,
18 pub label: String,
20}
21
22#[derive(Debug, Clone)]
24pub struct TickSet {
25 pub positions: Vec<f64>,
27 pub labels: Vec<String>,
29}
30
31impl TickSet {
32 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
42pub 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
73const Q: [f64; 6] = [1.0, 5.0, 2.0, 2.5, 4.0, 3.0];
80
81const W_SIMPLICITY: f64 = 0.2;
83const W_COVERAGE: f64 = 0.25;
84const W_DENSITY: f64 = 0.35;
85const W_LEGIBILITY: f64 = 0.2;
86
87fn generate_linear_ticks(data_min: f64, data_max: f64, target_count: usize) -> TickSet {
94 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 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 for (qi, &q) in Q.iter().enumerate() {
121 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 let density = density_score(j_f + 1.0, target, range);
131 let max_possible = W_SIMPLICITY + W_COVERAGE + W_DENSITY * density + W_LEGIBILITY;
133 if max_possible < best_score {
134 continue;
135 }
136
137 let ideal_step = range / j_f;
140 let k_float = (ideal_step / q).log10().floor();
142
143 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 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 if tick_max < dmax - step * 0.5 {
164 continue;
165 }
166 if tick_min > dmin + step * 0.5 {
167 continue;
168 }
169
170 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 snap_to_step(v, step)
177 })
178 .collect();
179
180 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 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
211fn simplicity_score(q_index: usize, ticks: &[f64]) -> f64 {
218 let q_len = Q.len() as f64;
219 let q_penalty = q_index as f64 / q_len;
221 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
230fn 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 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 let overshoot_ratio = (tick_range - data_range) / data_range;
249 (1.0 - 0.5 * overshoot_ratio * overshoot_ratio).max(0.0)
250}
251
252fn 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 let raw = 2.0 - ratio.max(1.0 / ratio);
263 raw.clamp(0.0, 1.0)
264}
265
266fn 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
276fn single_legibility(value: f64) -> f64 {
278 let label = format_tick(value);
279 let len = label.len();
280 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
294fn 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 return generate_linear_ticks(lo, hi, target_count);
312 }
313
314 let mut positions = Vec::new();
315
316 if decades <= 3 {
317 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 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 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
356fn 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 let decimals = needed_decimals(value);
375 let formatted = format!("{:.prec$}", value, prec = decimals);
376 strip_trailing_zeros(&formatted)
377 } else {
378 let formatted = format!("{:.6e}", value);
380 clean_scientific(&formatted)
381 }
382}
383
384fn needed_decimals(value: f64) -> usize {
387 let abs = value.abs();
388 if abs == abs.floor() && abs < 1e15 {
389 return 0;
390 }
391 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
402fn 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
412fn 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..]; let cleaned_mantissa = strip_trailing_zeros(mantissa);
418 format!("{}{}", cleaned_mantissa, exponent)
419 } else {
420 s.to_string()
421 }
422}
423
424fn snap_to_step(value: f64, step: f64) -> f64 {
435 if step == 0.0 {
436 return value;
437 }
438
439 let n = (value / step).round();
441 let mut result = n * step;
442
443 let magnitude = step.abs().log10().floor() as i32;
448 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 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 if result.abs() < step.abs() * 1e-10 {
473 0.0
474 } else {
475 result
476 }
477}
478
479fn 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#[cfg(test)]
490mod tests {
491 use super::*;
492
493 fn positions(ticks: &[Tick]) -> Vec<f64> {
499 ticks.iter().map(|t| t.value).collect()
500 }
501
502 fn labels(ticks: &[Tick]) -> Vec<&str> {
504 ticks.iter().map(|t| t.label.as_str()).collect()
505 }
506
507 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 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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 assert!(ticks.first().unwrap().value <= 0.0 + 0.01);
685 assert!(ticks.last().unwrap().value >= 10.0 - 0.01);
686 }
687
688 #[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 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 let ticks = generate_ticks(1.0, 5.0, 5, &Scale::Log10);
707 assert!(!ticks.is_empty());
708 }
709
710 #[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 #[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 #[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 #[test]
782 fn density_score_perfect() {
783 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); let s2 = simplicity_score(2, &ticks_with_zero); assert!(s0 > s2, "q=1 should score higher on simplicity than q=2");
823 }
824
825 #[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 #[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 #[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}