1use super::horizontal::median_bar_duration;
10use super::time_formatter::{DefaultTimeFormatter, TimeFormatter};
11use chrono::{DateTime, Datelike, Duration, Months, TimeZone, Timelike, Utc};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum TickMarkType {
17 Year,
19
20 Month,
22
23 DayOfMonth,
25
26 Time,
28
29 TimeWithSeconds,
31
32 TimeWithMilliseconds,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
39pub struct TickMarkWeight(u8);
40
41impl TickMarkWeight {
42 pub fn new(weight: u8) -> Self {
44 Self(weight.min(100))
45 }
46
47 pub fn value(&self) -> u8 {
49 self.0
50 }
51
52 pub const YEAR: TickMarkWeight = TickMarkWeight(100);
54
55 pub const MONTH: TickMarkWeight = TickMarkWeight(80);
57
58 pub const WEEK: TickMarkWeight = TickMarkWeight(70);
60
61 pub const DAY: TickMarkWeight = TickMarkWeight(60);
63
64 pub const HOUR_4: TickMarkWeight = TickMarkWeight(50);
66
67 pub const HOUR: TickMarkWeight = TickMarkWeight(40);
69
70 pub const MIN_30: TickMarkWeight = TickMarkWeight(35);
72
73 pub const MIN_15: TickMarkWeight = TickMarkWeight(30);
75
76 pub const MIN_5: TickMarkWeight = TickMarkWeight(25);
78
79 pub const MINUTE: TickMarkWeight = TickMarkWeight(20);
81
82 pub const SEC_10: TickMarkWeight = TickMarkWeight(15);
84
85 pub const SECOND: TickMarkWeight = TickMarkWeight(10);
87
88 pub const SUBSECOND: TickMarkWeight = TickMarkWeight(5);
90}
91
92#[derive(Debug, Clone)]
94pub struct TickMark {
95 pub time: DateTime<Utc>,
97
98 pub mark_type: TickMarkType,
100
101 pub weight: TickMarkWeight,
103
104 pub label: String,
106
107 pub index: usize,
109
110 pub bold: bool,
112}
113
114#[derive(Debug, Clone)]
116pub struct TickMarkGeneratorConfig {
117 pub min_spacing: f32,
119
120 pub max_marks: usize,
122
123 pub show_subseconds: bool,
125
126 pub use_24_hour: bool,
128
129 pub target_density: f32,
131}
132
133impl Default for TickMarkGeneratorConfig {
134 fn default() -> Self {
135 Self {
136 min_spacing: 50.0,
137 max_marks: 50,
138 show_subseconds: true,
139 use_24_hour: true,
140 target_density: 2.0,
141 }
142 }
143}
144
145pub struct TickMarkGenerator {
148 config: TickMarkGeneratorConfig,
149 formatter: Box<dyn TimeFormatter>,
150 uniform_distribution: bool,
151}
152
153impl TickMarkGenerator {
154 pub fn new() -> Self {
156 Self {
157 config: TickMarkGeneratorConfig::default(),
158 formatter: Box::new(DefaultTimeFormatter::default()),
159 uniform_distribution: false,
160 }
161 }
162
163 pub fn with_config(config: TickMarkGeneratorConfig) -> Self {
165 let formatter = Box::new(DefaultTimeFormatter {
167 use_24_hour: config.use_24_hour,
168 show_seconds: config.show_subseconds,
169 date_format: None,
170 });
171
172 Self {
173 config,
174 formatter,
175 uniform_distribution: false,
176 }
177 }
178
179 pub fn with_formatter(
181 config: TickMarkGeneratorConfig,
182 formatter: Box<dyn TimeFormatter>,
183 ) -> Self {
184 Self {
185 config,
186 formatter,
187 uniform_distribution: false,
188 }
189 }
190
191 pub fn with_uniform_distribution(mut self, uniform: bool) -> Self {
195 self.uniform_distribution = uniform;
196 self
197 }
198
199 pub fn generate_marks(
201 &self,
202 start_time: DateTime<Utc>,
203 end_time: DateTime<Utc>,
204 width_pixels: f32,
205 bars: &[(DateTime<Utc>, usize)], ) -> Vec<TickMark> {
207 if bars.is_empty() || width_pixels <= 0.0 {
208 return Vec::new();
209 }
210
211 let intraday = bars.windows(2).any(|pair| {
212 let delta = pair[1].0 - pair[0].0;
213 delta > Duration::zero() && delta < Duration::days(1)
214 });
215 let time_span = if intraday {
217 let bar_ms = median_bar_duration(bars.iter().map(|bar| bar.0)).num_milliseconds();
218 Duration::milliseconds(bar_ms.saturating_mul(bars.len().saturating_sub(1) as i64))
219 } else {
220 end_time - start_time
221 };
222 let mut interval = self.calculate_optimal_interval(time_span, width_pixels);
223 if !self.formatter.time_visible() {
224 interval = interval.max(Duration::days(1));
225 }
226 if !intraday && interval >= Duration::days(7) {
228 interval = interval.max(Duration::days(30));
229 }
230 let (mark_type, weight_threshold) = self.determine_mark_type_and_weight(interval);
231
232 let mut marks = if intraday {
234 self.generate_bar_marks(bars, interval, mark_type)
235 } else {
236 self.generate_candidate_marks(
237 self.formatter.wall_time(start_time),
238 self.formatter.wall_time(end_time),
239 interval,
240 mark_type,
241 )
242 .into_iter()
243 .filter_map(|mut mark| {
244 mark.time = self.formatter.utc_time(mark.time)?;
245 (mark.time >= start_time && mark.time <= end_time).then_some(mark)
246 })
247 .collect()
248 };
249
250 marks.retain(|mark| mark.weight >= weight_threshold);
252
253 marks = self.map_marks_to_bars(marks, bars);
255
256 marks = if intraday {
258 let span = bars.last().unwrap().1.saturating_sub(bars[0].1).max(1) as f32;
259 let min_bars = self.config.min_spacing * span / width_pixels;
260 let mut kept = keep_spaced(marks, min_bars, self.uniform_distribution);
261 kept.sort_by_key(|mark| mark.time);
262 kept
263 } else {
264 self.apply_spacing_constraints(marks, width_pixels)
265 };
266
267 if marks.len() > self.config.max_marks {
269 marks = self.reduce_marks_cnt(marks, self.config.max_marks);
270 }
271
272 for mark in &mut marks {
273 mark.label = self.format_time_label(mark.time, mark.mark_type);
274 mark.bold = matches!(
275 mark.mark_type,
276 TickMarkType::DayOfMonth | TickMarkType::Month | TickMarkType::Year
277 );
278 }
279 marks
280 }
281
282 fn generate_bar_marks(
284 &self,
285 bars: &[(DateTime<Utc>, usize)],
286 interval: Duration,
287 primary_type: TickMarkType,
288 ) -> Vec<TickMark> {
289 let mut marks = Vec::new();
290 let mut previous: Option<DateTime<Utc>> = None;
291 for &(time, index) in bars {
292 let local = self.formatter.wall_time(time);
293 let boundary = previous.filter(|previous| previous.date_naive() != local.date_naive());
294 let (kind, weight) = if boundary.is_some_and(|previous| previous.year() != local.year())
295 {
296 (TickMarkType::Year, TickMarkWeight::YEAR)
297 } else if boundary.is_some_and(|previous| previous.month() != local.month()) {
298 (TickMarkType::Month, TickMarkWeight::MONTH)
299 } else {
300 self.classify_time_boundary(local, primary_type)
301 };
302 let day = boundary.is_some() || local.time() == chrono::NaiveTime::MIN;
303 let millis = interval.num_milliseconds().max(1);
304 let clock_bucket = |time: DateTime<Utc>| {
305 (i64::from(time.time().num_seconds_from_midnight()) * 1000
306 + i64::from(time.timestamp_subsec_millis()))
307 / millis
308 };
309 let clock_mark = interval < Duration::days(1)
310 && (previous.is_some_and(|previous| clock_bucket(previous) != clock_bucket(local))
311 || local.timestamp_millis().rem_euclid(millis) == 0);
312 let day_bucket = |time: DateTime<Utc>| {
314 (i64::from(time.num_days_from_ce()) - 1).div_euclid(interval.num_days().max(1))
315 };
316 let day_mark = if interval < Duration::days(1) {
317 day
318 } else {
319 previous.is_none_or(|previous| day_bucket(previous) != day_bucket(local))
320 };
321 if kind == TickMarkType::Year || kind == TickMarkType::Month || day_mark || clock_mark {
322 let (mark_type, weight) = if day && weight < TickMarkWeight::DAY {
323 (TickMarkType::DayOfMonth, TickMarkWeight::DAY)
324 } else if clock_mark
325 && weight < TickMarkWeight::DAY
326 && interval >= Duration::seconds(1)
327 {
328 let boundary = local
329 - Duration::nanoseconds(i64::from(local.timestamp_subsec_nanos()))
330 - Duration::seconds(
331 i64::from(local.time().num_seconds_from_midnight())
332 .rem_euclid(interval.num_seconds()),
333 );
334 self.classify_time_boundary(boundary, primary_type)
335 } else {
336 (kind, weight)
337 };
338 marks.push(TickMark {
339 time,
340 mark_type,
341 weight,
342 label: String::new(),
343 index,
344 bold: false,
345 });
346 }
347 previous = Some(local);
348 }
349 marks
350 }
351
352 fn calculate_optimal_interval(&self, time_span: Duration, width_pixels: f32) -> Duration {
354 let target_marks = (width_pixels / 100.0 * self.config.target_density).max(2.0);
355 let seconds_per_mark = time_span.num_seconds() as f32 / target_marks;
356
357 let seconds = if seconds_per_mark < 1.0 {
359 if seconds_per_mark < 0.25 {
361 0.1
362 } else if seconds_per_mark < 0.5 {
363 0.25
364 } else {
365 0.5
366 }
367 } else if seconds_per_mark < 60.0 {
368 if seconds_per_mark < 2.0 {
370 1.0
371 } else if seconds_per_mark < 5.0 {
372 2.0
373 } else if seconds_per_mark < 10.0 {
374 5.0
375 } else if seconds_per_mark < 15.0 {
376 10.0
377 } else if seconds_per_mark < 30.0 {
378 15.0
379 } else {
380 30.0
381 }
382 } else if seconds_per_mark < 3600.0 {
383 let minutes = seconds_per_mark / 60.0;
385 if minutes < 2.0 {
386 60.0
387 } else if minutes < 5.0 {
388 120.0
389 } else if minutes < 10.0 {
390 300.0
391 } else if minutes < 15.0 {
392 600.0
393 } else if minutes < 30.0 {
394 900.0
395 } else {
396 1800.0
397 }
398 } else if seconds_per_mark < 86400.0 {
399 let hours = seconds_per_mark / 3600.0;
401 if hours < 2.0 {
402 3600.0
403 } else if hours < 4.0 {
404 7200.0
405 } else if hours < 6.0 {
406 14400.0
407 } else if hours < 12.0 {
408 21600.0
409 } else {
410 43200.0
411 }
412 } else if seconds_per_mark < 2592000.0 {
413 let days = seconds_per_mark / 86400.0;
415 if days < 2.0 {
416 86400.0
417 } else if days < 7.0 {
418 172800.0
419 } else {
420 604800.0
421 }
422 } else {
423 let days = seconds_per_mark / 86400.0;
425 if days < 90.0 {
426 2592000.0 } else if days < 180.0 {
428 7776000.0 } else if days < 365.0 {
430 15552000.0 } else {
432 31536000.0 }
434 };
435
436 let candidate_budget = (target_marks * 8.0).ceil().max(256.0) as i64;
438 let interval = Duration::milliseconds((seconds * 1000.0) as i64);
439 if time_span.num_milliseconds() / candidate_budget < interval.num_milliseconds() {
440 return interval;
441 }
442 Duration::days(365 * ((seconds_per_mark / 31_536_000.0) as i64 + 1))
443 }
444
445 fn determine_mark_type_and_weight(&self, interval: Duration) -> (TickMarkType, TickMarkWeight) {
447 let seconds = interval.num_seconds();
448
449 if seconds < 1 {
450 (TickMarkType::TimeWithSeconds, TickMarkWeight::SUBSECOND)
451 } else if seconds < 60 {
452 (TickMarkType::TimeWithSeconds, TickMarkWeight::SECOND)
453 } else if seconds < 3600 {
454 (TickMarkType::Time, TickMarkWeight::MINUTE)
455 } else if seconds < 86400 {
456 (TickMarkType::Time, TickMarkWeight::HOUR)
457 } else if seconds < 2592000 {
458 (TickMarkType::DayOfMonth, TickMarkWeight::DAY)
459 } else if seconds < 31536000 {
460 (TickMarkType::Month, TickMarkWeight::MONTH)
461 } else {
462 (TickMarkType::Year, TickMarkWeight::YEAR)
463 }
464 }
465
466 fn generate_candidate_marks(
468 &self,
469 start_time: DateTime<Utc>,
470 end_time: DateTime<Utc>,
471 interval: Duration,
472 primary_type: TickMarkType,
473 ) -> Vec<TickMark> {
474 let mut marks = Vec::new();
475 let years = interval.num_days() / 365;
477 if years > 1 {
478 let first = start_time.year() - start_time.year().rem_euclid(years as i32);
479 let mut current = Utc.with_ymd_and_hms(first, 1, 1, 0, 0, 0).single();
480 while let Some(time) = current.filter(|time| *time <= end_time) {
481 if time >= start_time {
482 marks.push(TickMark {
483 time,
484 mark_type: TickMarkType::Year,
485 weight: TickMarkWeight::YEAR,
486 label: String::new(),
487 index: 0, bold: false,
489 });
490 }
491 current = time.checked_add_months(Months::new(12 * years as u32));
492 }
493 return marks;
494 }
495 if interval >= Duration::days(30) {
496 let months = (interval.num_days() / 30).max(1) as u32;
497 let month = start_time.month0() / months * months + 1;
498 let mut current = Utc
499 .with_ymd_and_hms(start_time.year(), month, 1, 0, 0, 0)
500 .single();
501 while let Some(time) = current.filter(|time| *time <= end_time) {
502 if time >= start_time {
503 let (mark_type, weight) =
504 self.classify_time_boundary(time, TickMarkType::Month);
505 marks.push(TickMark {
506 time,
507 mark_type,
508 weight,
509 label: String::new(),
510 index: 0,
511 bold: false,
512 });
513 }
514 current = time.checked_add_months(Months::new(months));
515 }
516 return marks;
517 }
518 let mut current = self.round_time_to_boundary(start_time, interval);
519
520 while current <= end_time {
521 let (mark_type, weight) = self.classify_time_boundary(current, primary_type);
522
523 marks.push(TickMark {
524 time: current,
525 mark_type,
526 weight,
527 label: String::new(),
528 index: 0, bold: false,
530 });
531
532 let Some(next) = current.checked_add_signed(interval) else {
533 break;
534 };
535 current = next;
536 }
537
538 if interval >= Duration::days(1) {
540 marks.extend(self.generate_candidate_marks(
541 start_time,
542 end_time,
543 Duration::days(30),
544 TickMarkType::Month,
545 ));
546 marks.sort_by_key(|mark| mark.time);
547 marks.dedup_by_key(|mark| mark.time);
548 }
549 marks
550 }
551
552 fn round_time_to_boundary(&self, time: DateTime<Utc>, interval: Duration) -> DateTime<Utc> {
554 let seconds = interval.num_seconds();
555
556 if seconds < 1 {
557 let millis = interval.num_milliseconds();
559 if millis == 0 {
562 return time;
563 }
564 let timestamp_millis = time.timestamp_millis();
565 let rounded = (timestamp_millis / millis) * millis;
566 DateTime::from_timestamp_millis(rounded).unwrap_or(time)
567 } else if seconds < 86400 {
568 let midnight = time.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
571 let since_midnight = (time - midnight).num_seconds();
572 let first = (since_midnight + seconds - 1) / seconds * seconds;
573 midnight
574 .checked_add_signed(Duration::seconds(first))
575 .unwrap_or(time)
576 } else {
577 let anchor = if seconds == 604_800 { 4 * 86_400 } else { 0 };
579 let elapsed = time.timestamp() - anchor;
580 let first = elapsed.div_euclid(seconds) + i64::from(elapsed.rem_euclid(seconds) != 0);
581 DateTime::from_timestamp(anchor + first * seconds, 0).unwrap_or(time)
582 }
583 }
584
585 fn classify_time_boundary(
588 &self,
589 time: DateTime<Utc>,
590 primary_type: TickMarkType,
591 ) -> (TickMarkType, TickMarkWeight) {
592 if time.timestamp_subsec_nanos() != 0
593 && matches!(
594 primary_type,
595 TickMarkType::Time
596 | TickMarkType::TimeWithSeconds
597 | TickMarkType::TimeWithMilliseconds
598 )
599 {
600 return (
601 TickMarkType::TimeWithMilliseconds,
602 TickMarkWeight::SUBSECOND,
603 );
604 }
605
606 if time.month() == 1
608 && time.day() == 1
609 && time.hour() == 0
610 && time.minute() == 0
611 && time.second() == 0
612 {
613 return (TickMarkType::Year, TickMarkWeight::YEAR);
614 }
615
616 if time.day() == 1 && time.hour() == 0 && time.minute() == 0 && time.second() == 0 {
618 return (TickMarkType::Month, TickMarkWeight::MONTH);
619 }
620
621 if time.weekday().num_days_from_monday() == 0
623 && time.hour() == 0
624 && time.minute() == 0
625 && time.second() == 0
626 {
627 return (TickMarkType::DayOfMonth, TickMarkWeight::WEEK);
628 }
629
630 if time.hour() == 0 && time.minute() == 0 && time.second() == 0 {
632 return (TickMarkType::DayOfMonth, TickMarkWeight::DAY);
633 }
634
635 let is_day_or_higher = matches!(
638 primary_type,
639 TickMarkType::Year | TickMarkType::Month | TickMarkType::DayOfMonth
640 );
641
642 if is_day_or_higher {
643 return (TickMarkType::DayOfMonth, TickMarkWeight::DAY);
646 }
647
648 if time.hour().is_multiple_of(4) && time.minute() == 0 && time.second() == 0 {
652 return (TickMarkType::Time, TickMarkWeight::HOUR_4);
653 }
654
655 if time.minute() == 0 && time.second() == 0 {
657 return (TickMarkType::Time, TickMarkWeight::HOUR);
658 }
659
660 if time.minute().is_multiple_of(30) && time.second() == 0 {
662 return (TickMarkType::Time, TickMarkWeight::MIN_30);
663 }
664
665 if time.minute().is_multiple_of(15) && time.second() == 0 {
667 return (TickMarkType::Time, TickMarkWeight::MIN_15);
668 }
669
670 if time.minute().is_multiple_of(5) && time.second() == 0 {
672 return (TickMarkType::Time, TickMarkWeight::MIN_5);
673 }
674
675 if time.second() == 0 {
677 return (TickMarkType::Time, TickMarkWeight::MINUTE);
678 }
679
680 if time.second().is_multiple_of(10) {
682 return (TickMarkType::TimeWithSeconds, TickMarkWeight::SEC_10);
683 }
684
685 (primary_type, TickMarkWeight::SECOND)
687 }
688
689 fn format_time_label(&self, time: DateTime<Utc>, mark_type: TickMarkType) -> String {
691 self.formatter.format(time, mark_type)
692 }
693
694 fn map_marks_to_bars(
696 &self,
697 mut marks: Vec<TickMark>,
698 bars: &[(DateTime<Utc>, usize)],
699 ) -> Vec<TickMark> {
700 marks.retain_mut(|mark| {
701 let position = bars.partition_point(|(time, _)| *time < mark.time);
702 let Some(&(time, index)) = bars.get(position) else {
703 return false;
704 };
705 mark.time = time;
706 mark.index = index;
707 true
708 });
709 marks.sort_by(|a, b| a.time.cmp(&b.time).then_with(|| b.weight.cmp(&a.weight)));
711 marks.dedup_by_key(|mark| mark.index);
712
713 marks
714 }
715
716 fn apply_spacing_constraints(
718 &self,
719 mut marks: Vec<TickMark>,
720 width_pixels: f32,
721 ) -> Vec<TickMark> {
722 if marks.is_empty() {
723 return marks;
724 }
725
726 let pixels_per_mark = width_pixels / marks.len() as f32;
728
729 if pixels_per_mark < self.config.min_spacing {
731 let min_time = marks.iter().map(|m| m.time).min().unwrap();
734 let max_time = marks.iter().map(|m| m.time).max().unwrap();
735 let time_span = (max_time - min_time).num_seconds() as f32;
736 let min_time_diff =
737 Duration::seconds((self.config.min_spacing * (time_span / width_pixels)) as i64);
738
739 marks.sort_by(|a, b| b.weight.cmp(&a.weight).then_with(|| a.time.cmp(&b.time)));
742
743 let mut filtered = vec![marks[0].clone()];
745
746 for mark in marks.iter().skip(1) {
747 if filtered.iter().all(|m| {
748 let diff = if m.time > mark.time {
749 m.time - mark.time
750 } else {
751 mark.time - m.time
752 };
753 diff >= min_time_diff
754 }) {
755 filtered.push(mark.clone());
756 } else if self.uniform_distribution {
757 filtered.retain(|other| other.weight > mark.weight);
758 break;
759 }
760 }
761
762 filtered.sort_by_key(|m| m.time);
764 marks = filtered;
765 }
766
767 marks
768 }
769
770 fn reduce_marks_cnt(&self, mut marks: Vec<TickMark>, max_marks: usize) -> Vec<TickMark> {
772 if marks.len() <= max_marks {
773 return marks;
774 }
775
776 marks.sort_by(|a, b| b.weight.cmp(&a.weight).then_with(|| a.time.cmp(&b.time)));
778 if self.uniform_distribution {
779 let cut = marks[max_marks].weight;
780 marks.retain(|mark| mark.weight > cut);
781 } else {
782 marks.truncate(max_marks);
783 }
784 marks.sort_by_key(|m| m.time);
785 marks
786 }
787}
788
789pub(super) fn keep_spaced(marks: Vec<TickMark>, min_bars: f32, uniform: bool) -> Vec<TickMark> {
793 keep_spaced_by(marks, f64::from(min_bars), uniform, |mark| {
794 mark.index as f64
795 })
796}
797
798pub(super) fn keep_spaced_by(
800 mut marks: Vec<TickMark>,
801 minimum: f64,
802 uniform: bool,
803 position: impl Fn(&TickMark) -> f64,
804) -> Vec<TickMark> {
805 marks.sort_by(|a, b| b.weight.cmp(&a.weight).then_with(|| a.index.cmp(&b.index)));
806 let mut kept: Vec<TickMark> = Vec::new();
807 for mark in marks {
808 if kept
809 .iter()
810 .all(|other| (position(&mark) - position(other)).abs() >= minimum)
811 {
812 kept.push(mark);
813 } else if uniform {
814 kept.retain(|other| other.weight > mark.weight);
815 break;
816 }
817 }
818 kept
819}
820
821impl Default for TickMarkGenerator {
822 fn default() -> Self {
823 Self::new()
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use chrono::TimeZone;
831
832 #[test]
833 fn test_optimal_interval_calculation() {
834 let generator = TickMarkGenerator::new();
835
836 let start = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap();
837 for (span, expected_interval) in [
840 (Duration::hours(1), Duration::minutes(2)),
841 (Duration::days(1), Duration::hours(1)),
842 (Duration::days(365 * 310), Duration::days(365 * 16)),
843 (Duration::days(365 * 100_010), Duration::days(365 * 5001)),
844 ] {
845 let interval = generator.calculate_optimal_interval(span, 1000.0);
846 assert_eq!(interval, expected_interval);
847 let candidates = generator.generate_candidate_marks(
848 start,
849 start + span,
850 interval,
851 TickMarkType::Year,
852 );
853 assert!(candidates.len() <= 257, "{} candidates", candidates.len());
854 if interval > Duration::days(365) {
855 let bars = [(start, 0), (start + span, 1)];
856 let marks = generator.generate_marks(start, start + span, 1000.0, &bars);
857 assert!(marks.iter().all(|mark| mark.time == bars[mark.index].0));
858 assert!(marks.len() <= bars.len());
859 let years: Vec<_> = candidates.iter().map(|mark| mark.time.year()).collect();
860 let step = years[1] - years[0];
861 assert!(
862 years.windows(2).all(|pair| pair[1] - pair[0] == step),
863 "{years:?}"
864 );
865 }
866 }
867 }
868
869 #[test]
873 fn uniform_distribution_keeps_each_weight_whole() {
874 let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
875 let bars: Vec<_> = (0..864)
876 .map(|i| (start + Duration::minutes(5 * i as i64), i))
877 .collect();
878 for (uniform, expected) in [
879 (
880 false,
881 vec![
882 (0, 100),
883 (96, 50),
884 (192, 50),
885 (288, 60),
886 (384, 50),
887 (480, 50),
888 (576, 60),
889 (672, 50),
890 (768, 50),
891 ],
892 ),
893 (true, vec![(0, 100), (288, 60), (576, 60)]),
894 ] {
895 let marks = TickMarkGenerator::new()
896 .with_uniform_distribution(uniform)
897 .generate_marks(start, bars[863].0, 600.0, &bars);
898 let marks: Vec<_> = marks
899 .iter()
900 .map(|mark| (mark.index, mark.weight.value()))
901 .collect();
902 assert_eq!(marks, expected, "uniform {uniform}");
903 }
904 }
905
906 fn assert_capped_time_marks(span: Duration, expected: &[(i64, TickMarkType, u8, &str, usize)]) {
907 let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
908 let generator = TickMarkGenerator::with_config(TickMarkGeneratorConfig {
909 max_marks: 3,
910 ..Default::default()
911 });
912 let bars: Vec<_> = (0..=span.num_seconds())
913 .step_by(30)
914 .enumerate()
915 .map(|(i, seconds)| (start + Duration::seconds(seconds), i))
916 .collect();
917 let marks = generator.generate_marks(start, start + span, 1000.0, &bars);
918 let actual: Vec<_> = marks
919 .iter()
920 .map(|mark| {
921 (
922 (mark.time - start).num_seconds(),
923 mark.mark_type,
924 mark.weight.value(),
925 mark.label.as_str(),
926 mark.index,
927 )
928 })
929 .collect();
930 assert_eq!(actual, expected);
931 }
932
933 #[test]
934 fn ten_minute_marks_keep_the_year_and_five_minute_boundaries() {
935 use TickMarkType::{Time, Year};
936 assert_capped_time_marks(
937 Duration::minutes(10),
938 &[
939 (0, Year, 100, "2024", 0),
940 (300, Time, 25, "00:05", 10),
941 (600, Time, 25, "00:10", 20),
942 ],
943 );
944 }
945
946 #[test]
947 fn hourly_marks_keep_the_year_half_hour_and_hour_boundaries() {
948 use TickMarkType::{Time, Year};
949 assert_capped_time_marks(
950 Duration::hours(1),
951 &[
952 (0, Year, 100, "2024", 0),
953 (1800, Time, 35, "00:30", 60),
954 (3600, Time, 40, "01:00", 120),
955 ],
956 );
957 }
958
959 #[test]
960 fn day_marks_keep_the_year_four_hour_and_day_boundaries() {
961 use TickMarkType::{DayOfMonth, Time, Year};
962 assert_capped_time_marks(
963 Duration::days(1),
964 &[
965 (0, Year, 100, "2024", 0),
966 (14400, Time, 50, "04:00", 480),
967 (86400, DayOfMonth, 60, "02", 2880),
968 ],
969 );
970 }
971
972 #[test]
973 fn custom_formatter_runs_once_per_surviving_mark() {
974 use crate::scales::CustomTimeFormatter;
975 use std::sync::{
976 Arc,
977 atomic::{AtomicUsize, Ordering},
978 };
979 let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
980 for (span, labels) in [
981 (
982 Duration::minutes(10),
983 ["host 2024", "host 00:05", "host 00:10"],
984 ),
985 (
986 Duration::hours(1),
987 ["host 2024", "host 00:30", "host 01:00"],
988 ),
989 (Duration::days(1), ["host 2024", "host 04:00", "host 02"]),
990 ] {
991 let calls = Arc::new(AtomicUsize::new(0));
992 let seen = Arc::clone(&calls);
993 let formatter = CustomTimeFormatter::new(move |time, kind| {
994 seen.fetch_add(1, Ordering::Relaxed);
995 format!(
996 "host {}",
997 DefaultTimeFormatter::default().format(time, kind)
998 )
999 });
1000 let generator = TickMarkGenerator::with_formatter(
1001 TickMarkGeneratorConfig {
1002 max_marks: 3,
1003 ..Default::default()
1004 },
1005 formatter.clone_box(),
1006 );
1007 let bars: Vec<_> = (0..=span.num_seconds())
1008 .step_by(30)
1009 .enumerate()
1010 .map(|(i, seconds)| (start + Duration::seconds(seconds), i))
1011 .collect();
1012 let marks = generator.generate_marks(start, start + span, 1000.0, &bars);
1013 assert_eq!(
1014 marks
1015 .iter()
1016 .map(|mark| mark.label.as_str())
1017 .collect::<Vec<_>>(),
1018 labels,
1019 "{span}"
1020 );
1021 assert_eq!(
1022 calls.load(Ordering::Relaxed),
1023 marks.len(),
1024 "{span}: format only surviving marks"
1025 );
1026 }
1027 }
1028
1029 #[test]
1030 fn intraday_marks_keep_midnights_when_panned() {
1031 use TickMarkType::{DayOfMonth, Time};
1032 let start = Utc.with_ymd_and_hms(2024, 1, 20, 15, 0, 0).unwrap();
1033 let bars: Vec<_> = (0..121)
1034 .map(|i| (start + Duration::hours(i), i as usize))
1035 .collect();
1036 let expected = [
1040 (1, Time, 50, "16:00", 1),
1041 (9, DayOfMonth, 60, "21", 9),
1042 (17, Time, 50, "08:00", 17),
1043 (25, Time, 50, "16:00", 25),
1044 (33, DayOfMonth, 70, "22", 33),
1045 (41, Time, 50, "08:00", 41),
1046 (49, Time, 50, "16:00", 49),
1047 (57, DayOfMonth, 60, "23", 57),
1048 (65, Time, 50, "08:00", 65),
1049 (73, Time, 50, "16:00", 73),
1050 (81, DayOfMonth, 60, "24", 81),
1051 (89, Time, 50, "08:00", 89),
1052 (97, Time, 50, "16:00", 97),
1053 (105, DayOfMonth, 60, "25", 105),
1054 (113, Time, 50, "08:00", 113),
1055 ];
1056 let generator = TickMarkGenerator::new();
1057 for pan in [0, 1] {
1058 let visible = &bars[pan..pan + 120];
1059 let marks = generator.generate_marks(visible[0].0, visible[119].0, 1100.0, visible);
1060 let actual: Vec<_> = marks
1061 .iter()
1062 .map(|mark| {
1063 (
1064 (mark.time - start).num_hours(),
1065 mark.mark_type,
1066 mark.weight.value(),
1067 mark.label.as_str(),
1068 mark.index,
1069 )
1070 })
1071 .collect();
1072 assert_eq!(actual, expected, "pan {pan}");
1073 }
1074 }
1075
1076 #[test]
1077 fn day_and_month_marks_follow_the_displayed_zone() {
1078 use crate::scales::{TimeFormatterBuilder, TimezoneMode};
1079 let start = Utc.with_ymd_and_hms(2024, 3, 30, 0, 0, 0).unwrap();
1081 let bars: Vec<_> = (0..120)
1082 .map(|i| (start + Duration::hours(i), i as usize))
1083 .collect();
1084 for zone in [
1085 TimezoneMode::Utc,
1086 TimezoneMode::Timezone("+05:45".parse().unwrap()),
1087 #[cfg(feature = "timezones")]
1088 TimezoneMode::nyse(),
1089 ] {
1090 let formatter = TimeFormatterBuilder::new().with_timezone(zone).build();
1091 let generator = TickMarkGenerator::with_formatter(
1092 TickMarkGeneratorConfig::default(),
1093 formatter.clone_box(),
1094 );
1095 let marks = generator.generate_marks(bars[0].0, bars[119].0, 1100.0, &bars);
1096 assert!(
1097 marks
1098 .iter()
1099 .any(|mark| mark.mark_type == TickMarkType::Month && mark.label == "Apr")
1100 );
1101 for mark in marks
1102 .iter()
1103 .filter(|mark| mark.weight >= TickMarkWeight::DAY)
1104 {
1105 assert_eq!(formatter.wall_time(mark.time).hour(), 0, "{mark:?}");
1106 }
1107 }
1108 }
1109
1110 #[test]
1111 fn day_marks_follow_short_and_long_dst_days() {
1112 use crate::scales::{TimeFormatterBuilder, TimezoneMode};
1113 for (zone, midnight, next, hours) in [
1115 (
1116 "America/New_York",
1117 "2024-03-10T05:00:00Z",
1118 "2024-03-11T04:00:00Z",
1119 23,
1120 ),
1121 (
1122 "America/New_York",
1123 "2024-11-03T04:00:00Z",
1124 "2024-11-04T05:00:00Z",
1125 25,
1126 ),
1127 (
1128 "Europe/Zurich",
1129 "2024-03-30T23:00:00Z",
1130 "2024-03-31T22:00:00Z",
1131 23,
1132 ),
1133 (
1134 "Europe/Zurich",
1135 "2024-10-26T22:00:00Z",
1136 "2024-10-27T23:00:00Z",
1137 25,
1138 ),
1139 ] {
1140 let Some(zone) = crate::model::TimezoneId::named(zone) else {
1141 continue;
1142 };
1143 let midnight: DateTime<Utc> = midnight.parse().unwrap();
1144 let next: DateTime<Utc> = next.parse().unwrap();
1145 let formatter = TimeFormatterBuilder::new()
1146 .with_timezone(TimezoneMode::Timezone(zone.clone()))
1147 .build();
1148 let generator = TickMarkGenerator::with_formatter(
1149 TickMarkGeneratorConfig::default(),
1150 formatter.clone_box(),
1151 );
1152 let bars: Vec<_> = (-24..=hours + 24)
1153 .enumerate()
1154 .map(|(i, hour)| (midnight + Duration::hours(hour), i))
1155 .collect();
1156 for width in [434.0, 734.0, 1274.0] {
1157 let marks =
1158 generator.generate_marks(bars[0].0, bars.last().unwrap().0, width, &bars);
1159 for time in [midnight, next] {
1160 assert!(
1161 marks
1162 .iter()
1163 .any(|mark| mark.time == time && mark.weight >= TickMarkWeight::DAY),
1164 "{zone} {width}: {marks:?}"
1165 );
1166 }
1167 assert_eq!(next - midnight, Duration::hours(hours));
1168 assert!(marks.iter().filter(|mark| mark.weight >= TickMarkWeight::DAY).all(|mark| formatter.wall_time(mark.time).time() == chrono::NaiveTime::MIN));
1169 }
1170 }
1171 }
1172
1173 #[test]
1174 fn monthly_candidates_follow_the_displayed_month() {
1175 use crate::scales::{TimeFormatterBuilder, TimezoneMode};
1176 for step in [Duration::hours(1), Duration::days(1)] {
1178 let first = Utc.with_ymd_and_hms(2024, 1, 15, 0, 0, 0).unwrap();
1179 let count = Duration::days(350).num_seconds() / step.num_seconds();
1180 let bars: Vec<_> = (0..count)
1181 .map(|i| (first + step * i as i32, i as usize))
1182 .collect();
1183 let formatter = TimeFormatterBuilder::new()
1184 .with_timezone(TimezoneMode::Timezone("-05:00".parse().unwrap()))
1185 .build();
1186 let generator = TickMarkGenerator::with_formatter(
1187 TickMarkGeneratorConfig::default(),
1188 formatter.clone_box(),
1189 );
1190 let marks = generator.generate_marks(bars[0].0, bars.last().unwrap().0, 434.0, &bars);
1191 assert!(marks.len() >= 2);
1192 assert!(
1193 marks
1194 .iter()
1195 .all(|mark| formatter.wall_time(mark.time).day() == 1
1196 && mark.time == bars[mark.index].0),
1197 "{marks:?}"
1198 );
1199 }
1200 }
1201
1202 #[test]
1203 fn daily_month_and_year_marks_survive_panning() {
1204 let start = Utc.with_ymd_and_hms(2024, 9, 1, 0, 0, 0).unwrap();
1205 let daily: Vec<_> = (0..303)
1206 .map(|i| (start + Duration::days(i), i as usize))
1207 .collect();
1208 let marks =
1209 TickMarkGenerator::new().generate_marks(daily[0].0, daily[302].0, 1100.0, &daily);
1210 let panned =
1211 TickMarkGenerator::new().generate_marks(daily[1].0, daily[302].0, 1100.0, &daily[1..]);
1212 assert_eq!(
1213 marks
1214 .iter()
1215 .skip(1)
1216 .map(|mark| mark.time)
1217 .collect::<Vec<_>>(),
1218 panned.iter().map(|mark| mark.time).collect::<Vec<_>>()
1219 );
1220 let yearly = TickMarkGenerator::new().generate_candidate_marks(
1221 start,
1222 start + Duration::days(365 * 3),
1223 Duration::days(365),
1224 TickMarkType::Year,
1225 );
1226 assert!(
1227 yearly.len() >= 2
1228 && yearly
1229 .iter()
1230 .all(|mark| mark.mark_type == TickMarkType::Year)
1231 );
1232 assert_eq!(
1233 marks
1234 .iter()
1235 .map(|mark| mark.label.as_str())
1236 .collect::<Vec<_>>(),
1237 [
1238 "Sep", "Oct", "Nov", "Dec", "2025", "Feb", "Mar", "Apr", "May", "Jun"
1239 ]
1240 );
1241 assert_eq!(marks[4].weight, TickMarkWeight::YEAR);
1242 }
1243
1244 #[test]
1245 fn hour_marks_follow_half_hour_session_opens() {
1246 let start = Utc.with_ymd_and_hms(2024, 3, 20, 9, 30, 0).unwrap();
1248 let session: Vec<_> = (0..7)
1249 .map(|i| (start + Duration::hours(i), i as usize))
1250 .collect();
1251 let marks =
1252 TickMarkGenerator::new().generate_marks(session[0].0, session[6].0, 300.0, &session);
1253 assert!(marks.len() >= 3, "half-hour session: {marks:?}");
1254 assert!(marks.iter().all(|mark| mark.time == session[mark.index].0));
1255 }
1256
1257 #[test]
1258 fn session_marks_skip_closed_hours_and_weekends() {
1259 let start = Utc.with_ymd_and_hms(2024, 3, 20, 13, 30, 0).unwrap();
1261 let bars: Vec<_> = (0..30)
1262 .flat_map(|day| {
1263 let time = start + Duration::days(day);
1264 (0..7).filter_map(move |hour| {
1265 (time.weekday().number_from_monday() <= 5)
1266 .then_some(time + Duration::hours(hour))
1267 })
1268 })
1269 .enumerate()
1270 .map(|(index, time)| (time, index))
1271 .collect();
1272 let marks = TickMarkGenerator::new().generate_marks(
1273 bars[0].0,
1274 bars.last().unwrap().0,
1275 1100.0,
1276 &bars,
1277 );
1278 assert!(
1279 marks
1280 .iter()
1281 .any(|mark| mark.mark_type == TickMarkType::Month && mark.label == "Apr")
1282 );
1283 assert!(marks.iter().all(|mark| bars[mark.index].0 == mark.time));
1284 let month_start = bars
1285 .windows(2)
1286 .position(|pair| pair[0].0.month() != pair[1].0.month())
1287 .unwrap()
1288 + 1;
1289 let around_month = &bars[month_start - 7..month_start + 7];
1290 let close_marks = TickMarkGenerator::new().generate_marks(
1291 around_month[0].0,
1292 around_month[13].0,
1293 800.0,
1294 around_month,
1295 );
1296 assert!(
1297 close_marks
1298 .iter()
1299 .any(|mark| mark.mark_type == TickMarkType::Month && mark.label == "Apr")
1300 );
1301 let min_bars = 50.0 * (bars.len() - 1) as f32 / 1100.0;
1302 assert!(
1303 marks
1304 .windows(2)
1305 .all(|pair| pair[0].index.abs_diff(pair[1].index) as f32 >= min_bars)
1306 );
1307 }
1308
1309 #[test]
1310 fn daily_marks_keep_calendar_anchors_at_every_width() {
1311 let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
1312 let bars: Vec<_> = (0..600)
1313 .map(|i| (start + Duration::days(i), i as usize))
1314 .collect();
1315 let generator = TickMarkGenerator::new();
1316 for width in [434.0, 634.0, 734.0, 1274.0] {
1317 let marks = generator.generate_marks(bars[300].0, bars[419].0, width, &bars[300..420]);
1318 assert!(marks.len() >= 2, "width {width}: {marks:?}");
1319 for &(time, _) in bars[300..420].iter().filter(|(time, _)| time.day() == 1) {
1320 assert!(
1321 marks.iter().any(|mark| mark.time == time),
1322 "lost month boundary at width {width}: {time}"
1323 );
1324 }
1325 if width <= 734.0 {
1326 assert!(marks.iter().all(|mark| mark.time.day() == 1));
1327 }
1328 }
1329 let weekdays: Vec<_> = (0..400)
1331 .map(|day| start + Duration::days(day))
1332 .filter(|time| time.weekday().number_from_monday() <= 5 && time.day() != 1)
1333 .enumerate()
1334 .map(|(index, time)| (time, index))
1335 .collect();
1336 use crate::scales::{HorizontalScale, TickMarkLayout, TimeFormatterBuilder};
1337 let formatter = TimeFormatterBuilder::new().build();
1338 for spacing in [8.0, 14.0, 24.0, 40.0] {
1339 let layout = TickMarkLayout {
1340 width: 1000.0,
1341 bar_spacing: spacing,
1342 min_spacing: 80.0,
1343 uniform_distribution: false,
1344 };
1345 let visible = &weekdays[weekdays.len() - (1000.0 / spacing) as usize..];
1346 let marks = formatter.tick_marks(visible, &layout);
1347 assert!(!marks.is_empty());
1348 assert!(
1349 marks.iter().all(|mark| weekdays[mark.index].0 == mark.time),
1350 "{marks:?}"
1351 );
1352 assert!(marks.windows(2).all(|pair| pair[0].index < pair[1].index));
1353 let february = Utc.with_ymd_and_hms(2025, 2, 3, 0, 0, 0).unwrap();
1354 assert!(
1355 marks
1356 .iter()
1357 .any(|mark| mark.time == february && mark.label == "Feb"),
1358 "{spacing}: {marks:?}"
1359 );
1360 }
1361 let sparse = [(start, 0), (start + Duration::days(35), 1)];
1364 let marks = generator.generate_marks(start, start + Duration::days(60), 1000.0, &sparse);
1365 assert_eq!(marks.len(), 2);
1366 assert_eq!(marks[1].time, sparse[1].0);
1367 assert_eq!(marks[1].mark_type, TickMarkType::Month);
1368
1369 for interval in [Duration::days(2), Duration::days(7)] {
1370 let before = generator.generate_candidate_marks(
1371 bars[300].0,
1372 bars[400].0,
1373 interval,
1374 TickMarkType::DayOfMonth,
1375 );
1376 let after = generator.generate_candidate_marks(
1377 bars[301].0,
1378 bars[400].0,
1379 interval,
1380 TickMarkType::DayOfMonth,
1381 );
1382 let remaining: Vec<_> = before
1383 .iter()
1384 .filter(|mark| mark.time >= bars[301].0)
1385 .map(|mark| mark.time)
1386 .collect();
1387 assert_eq!(
1388 remaining,
1389 after.iter().map(|mark| mark.time).collect::<Vec<_>>()
1390 );
1391 if interval == Duration::days(7) {
1392 assert!(
1393 after
1394 .iter()
1395 .filter(|mark| mark.weight < TickMarkWeight::MONTH)
1396 .all(|mark| mark.time.weekday() == chrono::Weekday::Mon)
1397 );
1398 }
1399 }
1400 }
1401
1402 #[test]
1403 fn intraday_day_buckets_mark_the_first_available_bar() {
1404 use crate::scales::{HorizontalScale, TickMarkLayout, TimeFormatterBuilder};
1405 let start = Utc.with_ymd_and_hms(2024, 1, 1, 14, 30, 0).unwrap();
1406 let bars: Vec<_> = (0..50)
1409 .filter(|day| {
1410 *day != 7
1411 && (start + Duration::days(*day))
1412 .weekday()
1413 .number_from_monday()
1414 <= 5
1415 })
1416 .flat_map(|day| {
1417 (0..7).map(move |hour| start + Duration::days(day) + Duration::hours(hour))
1418 })
1419 .enumerate()
1420 .map(|(index, time)| (time, index))
1421 .collect();
1422 let generator = TickMarkGenerator::new();
1423 for (days, expected) in [
1424 (
1425 2,
1426 vec![
1427 0, 1, 3, 8, 9, 11, 14, 15, 17, 21, 23, 25, 28, 29, 35, 37, 39, 42, 43, 45, 49,
1428 ],
1429 ),
1430 (7, vec![0, 8, 14, 21, 28, 35, 42, 49]),
1431 (14, vec![0, 8, 21, 35, 49]),
1432 ] {
1433 let marks =
1434 generator.generate_bar_marks(&bars, Duration::days(days), TickMarkType::DayOfMonth);
1435 let actual: Vec<_> = marks
1437 .iter()
1438 .filter(|mark| mark.weight < TickMarkWeight::MONTH)
1439 .map(|mark| (mark.time - start).num_days())
1440 .collect();
1441 assert_eq!(actual, expected, "{days}-day buckets");
1442 assert!(
1443 marks
1444 .iter()
1445 .all(|mark| mark.time.hour() == 14 && mark.time.minute() == 30)
1446 );
1447 }
1448 let formatter = TimeFormatterBuilder::new().build();
1449 let marks = formatter.tick_marks(
1450 &bars,
1451 &TickMarkLayout {
1452 width: 1000.0,
1453 bar_spacing: 1000.0 / bars.len() as f32,
1454 min_spacing: 80.0,
1455 uniform_distribution: false,
1456 },
1457 );
1458 assert!(marks.len() >= 4, "weekly marks: {marks:?}");
1459 assert!(marks.iter().all(|mark| mark.time == bars[mark.index].0));
1460 }
1461
1462 #[test]
1463 fn subsecond_axis_labels_distinguish_instants() {
1464 use crate::scales::{HorizontalScale, TickMarkLayout, TimeFormatterBuilder};
1465 let afternoon = Utc.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap();
1466 for (start, count, step_ms) in [
1467 (afternoon, 30, 100),
1468 (Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), 30, 100),
1469 (afternoon + Duration::milliseconds(50), 600, 100),
1470 (afternoon + Duration::nanoseconds(500), 390, 60_000),
1471 ] {
1472 let bars: Vec<_> = (0..count)
1473 .map(|i| (start + Duration::milliseconds(step_ms * i), i as usize))
1474 .collect();
1475 let formatter = TimeFormatterBuilder::new().build();
1476 let marks = formatter.tick_marks(
1477 &bars,
1478 &TickMarkLayout {
1479 width: 1000.0,
1480 bar_spacing: 1000.0 / count as f32,
1481 min_spacing: 80.0,
1482 uniform_distribution: false,
1483 },
1484 );
1485 assert!(marks.len() >= 5, "{start}, {step_ms} ms: {marks:?}");
1486 assert!(
1487 marks.windows(2).all(|pair| pair[0].label != pair[1].label),
1488 "{marks:?}"
1489 );
1490 for mark in marks
1491 .iter()
1492 .filter(|mark| mark.time.timestamp_subsec_millis() == 0 || count == 600)
1493 {
1494 let (kind, pattern) = if mark.time == start && start.month() == 1 {
1495 (TickMarkType::Year, "%Y")
1496 } else if mark.time.second() == 0 {
1497 (TickMarkType::Time, "%H:%M")
1498 } else {
1499 (TickMarkType::TimeWithSeconds, "%H:%M:%S")
1500 };
1501 assert_eq!(mark.mark_type, kind);
1502 assert_eq!(mark.label, mark.time.format(pattern).to_string());
1503 }
1504 for mark in marks
1505 .iter()
1506 .filter(|mark| mark.time.timestamp_subsec_millis() != 0 && count == 30)
1507 {
1508 assert_eq!(mark.label, mark.time.format("%H:%M:%S%.3f").to_string());
1509 assert_eq!(mark.weight, TickMarkWeight::SUBSECOND);
1510 }
1511 }
1512 }
1513
1514 #[test]
1515 fn test_time_boundary_classification() {
1516 let generator = TickMarkGenerator::new();
1517
1518 let time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
1520 let (mark_type, weight) = generator.classify_time_boundary(time, TickMarkType::Time);
1521 assert_eq!(mark_type, TickMarkType::Year);
1522 assert_eq!(weight, TickMarkWeight::YEAR);
1523
1524 let time = Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap();
1526 let (mark_type, weight) = generator.classify_time_boundary(time, TickMarkType::Time);
1527 assert_eq!(mark_type, TickMarkType::Month);
1528 assert_eq!(weight, TickMarkWeight::MONTH);
1529
1530 let time = Utc.with_ymd_and_hms(2024, 6, 15, 13, 0, 0).unwrap();
1532 let (mark_type, weight) = generator.classify_time_boundary(time, TickMarkType::Time);
1533 assert_eq!(mark_type, TickMarkType::Time);
1534 assert_eq!(weight, TickMarkWeight::HOUR);
1535 assert_eq!(
1536 generator.classify_time_boundary(
1537 time + Duration::milliseconds(123),
1538 TickMarkType::DayOfMonth
1539 ),
1540 (TickMarkType::DayOfMonth, TickMarkWeight::DAY)
1541 );
1542 }
1543
1544 #[test]
1545 fn test_round_to_boundary_zero_interval_does_not_panic() {
1546 let generator = TickMarkGenerator::new();
1549 let time = Utc.with_ymd_and_hms(2024, 6, 15, 14, 30, 45).unwrap();
1550 let rounded = generator.round_time_to_boundary(time, Duration::milliseconds(0));
1551 assert_eq!(rounded, time);
1552 }
1553}