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 => 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 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
87fn 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; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
101 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let y = if m <= 2 { y + 1 } else { y };
106 (y, m, d)
107}
108
109pub 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); 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
143const Q: [f64; 6] = [1.0, 5.0, 2.0, 2.5, 4.0, 3.0];
150
151const W_SIMPLICITY: f64 = 0.2;
153const W_COVERAGE: f64 = 0.25;
154const W_DENSITY: f64 = 0.35;
155const W_LEGIBILITY: f64 = 0.2;
156
157fn generate_linear_ticks(data_min: f64, data_max: f64, target_count: usize) -> TickSet {
164 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 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 for (qi, &q) in Q.iter().enumerate() {
191 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 let density = density_score(j_f + 1.0, target, range);
201 let max_possible = W_SIMPLICITY + W_COVERAGE + W_DENSITY * density + W_LEGIBILITY;
203 if max_possible < best_score {
204 continue;
205 }
206
207 let ideal_step = range / j_f;
210 let k_float = (ideal_step / q).log10().floor();
212
213 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 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 if tick_max < dmax - step * 0.5 {
234 continue;
235 }
236 if tick_min > dmin + step * 0.5 {
237 continue;
238 }
239
240 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 snap_to_step(v, step)
247 })
248 .collect();
249
250 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 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
281fn simplicity_score(q_index: usize, ticks: &[f64]) -> f64 {
288 let q_len = Q.len() as f64;
289 let q_penalty = q_index as f64 / q_len;
291 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
300fn 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 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 let overshoot_ratio = (tick_range - data_range) / data_range;
319 (1.0 - 0.5 * overshoot_ratio * overshoot_ratio).max(0.0)
320}
321
322fn 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 let raw = 2.0 - ratio.max(1.0 / ratio);
333 raw.clamp(0.0, 1.0)
334}
335
336fn 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
346fn single_legibility(value: f64) -> f64 {
348 let label = format_tick(value);
349 let len = label.len();
350 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
364fn 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 return generate_linear_ticks(lo, hi, target_count);
382 }
383
384 let mut positions = Vec::new();
385
386 if decades <= 3 {
387 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 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 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
429pub 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
459fn generate_symlog_ticks(
465 data_min: f64,
466 data_max: f64,
467 target_count: usize,
468 linthresh: f64,
469) -> TickSet {
470 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 if data_min <= 0.0 && data_max >= 0.0 {
479 positions.push(0.0);
480 }
481
482 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 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 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 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 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 positions.len() < 2 {
530 return generate_linear_ticks(data_min, data_max, target_count);
531 }
532
533 make_tick_set(positions)
534}
535
536pub fn format_tick_value(value: f64) -> String {
545 format_tick(value)
546}
547
548fn 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 let decimals = needed_decimals(value);
563 let formatted = format!("{:.prec$}", value, prec = decimals);
564 strip_trailing_zeros(&formatted)
565 } else {
566 let formatted = format!("{:.6e}", value);
568 clean_scientific(&formatted)
569 }
570}
571
572fn needed_decimals(value: f64) -> usize {
575 let abs = value.abs();
576 if abs == abs.floor() && abs < 1e15 {
577 return 0;
578 }
579 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
590fn 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
600fn 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..]; let cleaned_mantissa = strip_trailing_zeros(mantissa);
606 format!("{}{}", cleaned_mantissa, exponent)
607 } else {
608 s.to_string()
609 }
610}
611
612fn snap_to_step(value: f64, step: f64) -> f64 {
623 if step == 0.0 {
624 return value;
625 }
626
627 let n = (value / step).round();
629 let mut result = n * step;
630
631 let magnitude = step.abs().log10().floor() as i32;
636 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 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 if result.abs() < step.abs() * 1e-10 {
661 0.0
662 } else {
663 result
664 }
665}
666
667fn 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#[cfg(test)]
678mod tests {
679 use super::*;
680
681 fn positions(ticks: &[Tick]) -> Vec<f64> {
687 ticks.iter().map(|t| t.value).collect()
688 }
689
690 fn labels(ticks: &[Tick]) -> Vec<&str> {
692 ticks.iter().map(|t| t.label.as_str()).collect()
693 }
694
695 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 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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 assert!(ticks.first().unwrap().value <= 0.0 + 0.01);
876 assert!(ticks.last().unwrap().value >= 10.0 - 0.01);
877 }
878
879 #[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 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 let ticks = generate_ticks(1.0, 5.0, 5, &Scale::Log10);
898 assert!(!ticks.is_empty());
899 }
900
901 #[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 #[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 #[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 #[test]
973 fn density_score_perfect() {
974 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); let s2 = simplicity_score(2, &ticks_with_zero); assert!(s0 > s2, "q=1 should score higher on simplicity than q=2");
1021 }
1022
1023 #[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 #[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 #[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 #[test]
1081 fn log_ticks_powers_of_10() {
1082 let ticks = generate_ticks(1.0, 10_000.0, 7, &Scale::Log10);
1084 assert_nice(&ticks);
1085 let pos = positions(&ticks);
1086 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 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 #[test]
1136 fn log_minor_ticks_basic() {
1137 let minor = generate_log_minor_ticks(1.0, 100.0);
1138 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 #[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 let ticks = generate_ticks(-0.5, 0.5, 5, &Scale::SymLog { linthresh: 1.0 });
1225 assert!(!ticks.is_empty());
1226 }
1227}