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 };
71 tick_set.into_ticks()
72}
73
74const Q: [f64; 6] = [1.0, 5.0, 2.0, 2.5, 4.0, 3.0];
81
82const W_SIMPLICITY: f64 = 0.2;
84const W_COVERAGE: f64 = 0.25;
85const W_DENSITY: f64 = 0.35;
86const W_LEGIBILITY: f64 = 0.2;
87
88fn generate_linear_ticks(data_min: f64, data_max: f64, target_count: usize) -> TickSet {
95 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 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 for (qi, &q) in Q.iter().enumerate() {
122 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 let density = density_score(j_f + 1.0, target, range);
132 let max_possible = W_SIMPLICITY + W_COVERAGE + W_DENSITY * density + W_LEGIBILITY;
134 if max_possible < best_score {
135 continue;
136 }
137
138 let ideal_step = range / j_f;
141 let k_float = (ideal_step / q).log10().floor();
143
144 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 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 if tick_max < dmax - step * 0.5 {
165 continue;
166 }
167 if tick_min > dmin + step * 0.5 {
168 continue;
169 }
170
171 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 snap_to_step(v, step)
178 })
179 .collect();
180
181 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 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
212fn simplicity_score(q_index: usize, ticks: &[f64]) -> f64 {
219 let q_len = Q.len() as f64;
220 let q_penalty = q_index as f64 / q_len;
222 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
231fn 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 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 let overshoot_ratio = (tick_range - data_range) / data_range;
250 (1.0 - 0.5 * overshoot_ratio * overshoot_ratio).max(0.0)
251}
252
253fn 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 let raw = 2.0 - ratio.max(1.0 / ratio);
264 raw.clamp(0.0, 1.0)
265}
266
267fn 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
277fn single_legibility(value: f64) -> f64 {
279 let label = format_tick(value);
280 let len = label.len();
281 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
295fn 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 return generate_linear_ticks(lo, hi, target_count);
313 }
314
315 let mut positions = Vec::new();
316
317 if decades <= 3 {
318 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 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 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
357pub 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
387fn generate_symlog_ticks(data_min: f64, data_max: f64, target_count: usize, linthresh: f64) -> TickSet {
393 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 if data_min <= 0.0 && data_max >= 0.0 {
402 positions.push(0.0);
403 }
404
405 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 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 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 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 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 positions.len() < 2 {
453 return generate_linear_ticks(data_min, data_max, target_count);
454 }
455
456 make_tick_set(positions)
457}
458
459pub fn format_tick_value(value: f64) -> String {
468 format_tick(value)
469}
470
471fn 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 let decimals = needed_decimals(value);
486 let formatted = format!("{:.prec$}", value, prec = decimals);
487 strip_trailing_zeros(&formatted)
488 } else {
489 let formatted = format!("{:.6e}", value);
491 clean_scientific(&formatted)
492 }
493}
494
495fn needed_decimals(value: f64) -> usize {
498 let abs = value.abs();
499 if abs == abs.floor() && abs < 1e15 {
500 return 0;
501 }
502 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
513fn 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
523fn 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..]; let cleaned_mantissa = strip_trailing_zeros(mantissa);
529 format!("{}{}", cleaned_mantissa, exponent)
530 } else {
531 s.to_string()
532 }
533}
534
535fn snap_to_step(value: f64, step: f64) -> f64 {
546 if step == 0.0 {
547 return value;
548 }
549
550 let n = (value / step).round();
552 let mut result = n * step;
553
554 let magnitude = step.abs().log10().floor() as i32;
559 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 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 if result.abs() < step.abs() * 1e-10 {
584 0.0
585 } else {
586 result
587 }
588}
589
590fn 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#[cfg(test)]
601mod tests {
602 use super::*;
603
604 fn positions(ticks: &[Tick]) -> Vec<f64> {
610 ticks.iter().map(|t| t.value).collect()
611 }
612
613 fn labels(ticks: &[Tick]) -> Vec<&str> {
615 ticks.iter().map(|t| t.label.as_str()).collect()
616 }
617
618 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 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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 assert!(ticks.first().unwrap().value <= 0.0 + 0.01);
796 assert!(ticks.last().unwrap().value >= 10.0 - 0.01);
797 }
798
799 #[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 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 let ticks = generate_ticks(1.0, 5.0, 5, &Scale::Log10);
818 assert!(!ticks.is_empty());
819 }
820
821 #[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 #[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 #[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 #[test]
893 fn density_score_perfect() {
894 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); let s2 = simplicity_score(2, &ticks_with_zero); assert!(s0 > s2, "q=1 should score higher on simplicity than q=2");
934 }
935
936 #[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 #[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 #[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 #[test]
990 fn log_ticks_powers_of_10() {
991 let ticks = generate_ticks(1.0, 10_000.0, 7, &Scale::Log10);
993 assert_nice(&ticks);
994 let pos = positions(&ticks);
995 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 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 #[test]
1033 fn log_minor_ticks_basic() {
1034 let minor = generate_log_minor_ticks(1.0, 100.0);
1035 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 #[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 let ticks = generate_ticks(-0.5, 0.5, 5, &Scale::SymLog { linthresh: 1.0 });
1105 assert!(!ticks.is_empty());
1106 }
1107}