Skip to main content

egui_charts/scales/
timescale_marks.rs

1//! Smart time-axis tick mark generation.
2//!
3//! [`TickMarkGenerator`] places time labels at hierarchical boundaries
4//! (year > month > week > day > hour > minute > second) and assigns a
5//! [`TickMarkWeight`] to each.  When the chart is too narrow to show every
6//! mark, lower-weight marks are dropped first, ensuring that the most
7//! significant boundaries (year, month changes) always remain visible.
8
9use 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/// Type of tick mark on the time scale
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum TickMarkType {
17    /// Year mark (e.g., "2024")
18    Year,
19
20    /// Month mark (e.g., "Jan", "Feb")
21    Month,
22
23    /// Day of month mark (e.g., "15", "20")
24    DayOfMonth,
25
26    /// Time mark without seconds (e.g., "12:30")
27    Time,
28
29    /// Time mark with seconds (e.g., "12:30:45")
30    TimeWithSeconds,
31
32    /// Time mark with milliseconds (e.g., "12:30:45.123")
33    TimeWithMilliseconds,
34}
35
36/// Weight of a tick mark (0-100 scale)
37/// Higher weight = more important mark = displayed at lower zoom levels
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
39pub struct TickMarkWeight(u8);
40
41impl TickMarkWeight {
42    /// Create a new tick mark weight (0-100)
43    pub fn new(weight: u8) -> Self {
44        Self(weight.min(100))
45    }
46
47    /// Get the raw weight value
48    pub fn value(&self) -> u8 {
49        self.0
50    }
51
52    /// Weight for year boundaries
53    pub const YEAR: TickMarkWeight = TickMarkWeight(100);
54
55    /// Weight for month boundaries
56    pub const MONTH: TickMarkWeight = TickMarkWeight(80);
57
58    /// Weight for week boundaries
59    pub const WEEK: TickMarkWeight = TickMarkWeight(70);
60
61    /// Weight for day boundaries
62    pub const DAY: TickMarkWeight = TickMarkWeight(60);
63
64    /// Weight for 4-hour marks
65    pub const HOUR_4: TickMarkWeight = TickMarkWeight(50);
66
67    /// Weight for hour boundaries
68    pub const HOUR: TickMarkWeight = TickMarkWeight(40);
69
70    /// Weight for 30-minute marks
71    pub const MIN_30: TickMarkWeight = TickMarkWeight(35);
72
73    /// Weight for 15-minute marks
74    pub const MIN_15: TickMarkWeight = TickMarkWeight(30);
75
76    /// Weight for 5-minute marks
77    pub const MIN_5: TickMarkWeight = TickMarkWeight(25);
78
79    /// Weight for minute boundaries
80    pub const MINUTE: TickMarkWeight = TickMarkWeight(20);
81
82    /// Weight for 10-second marks
83    pub const SEC_10: TickMarkWeight = TickMarkWeight(15);
84
85    /// Weight for second boundaries
86    pub const SECOND: TickMarkWeight = TickMarkWeight(10);
87
88    /// Weight for sub-second marks
89    pub const SUBSECOND: TickMarkWeight = TickMarkWeight(5);
90}
91
92/// A single tick mark on the time axis
93#[derive(Debug, Clone)]
94pub struct TickMark {
95    /// Time of this tick mark
96    pub time: DateTime<Utc>,
97
98    /// Type of tick mark
99    pub mark_type: TickMarkType,
100
101    /// Weight of this mark (importance)
102    pub weight: TickMarkWeight,
103
104    /// Formatted label text
105    pub label: String,
106
107    /// Index in the data series
108    pub index: usize,
109
110    /// Whether the label is drawn bold where the time scale options allow bold labels
111    pub bold: bool,
112}
113
114/// Configuration for tick mark generation
115#[derive(Debug, Clone)]
116pub struct TickMarkGeneratorConfig {
117    /// Min spacing between marks in pixels
118    pub min_spacing: f32,
119
120    /// Max number of marks to generate
121    pub max_marks: usize,
122
123    /// Whether to show sub-second marks
124    pub show_subseconds: bool,
125
126    /// Whether to use 24-hour time format
127    pub use_24_hour: bool,
128
129    /// Target density (marks per 100 pixels)
130    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
145/// Smart time axis mark generator
146/// Implements intelligent mark distribution algorithm
147pub struct TickMarkGenerator {
148    config: TickMarkGeneratorConfig,
149    formatter: Box<dyn TimeFormatter>,
150    uniform_distribution: bool,
151}
152
153impl TickMarkGenerator {
154    /// Create a new tick mark generator with default config
155    pub fn new() -> Self {
156        Self {
157            config: TickMarkGeneratorConfig::default(),
158            formatter: Box::new(DefaultTimeFormatter::default()),
159            uniform_distribution: false,
160        }
161    }
162
163    /// Create a new tick mark generator with custom config
164    pub fn with_config(config: TickMarkGeneratorConfig) -> Self {
165        // Build formatter from config
166        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    /// Create a new tick mark generator with custom formatter
180    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    /// Keeps marks of one weight all or none (default `false`): when a mark of some weight
192    /// does not fit between the marks already placed, that weight and every lighter one are
193    /// left out, so labels of one kind stay evenly spaced.
194    pub fn with_uniform_distribution(mut self, uniform: bool) -> Self {
195        self.uniform_distribution = uniform;
196        self
197    }
198
199    /// Generate tick marks for a given time range and display width
200    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)], // (time, index) pairs
206    ) -> 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        // Closed sessions take no slots, so the clock interval follows typical bar spacing.
216        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        // Daily charts at a multi-month zoom show calendar months, not alternating weeks.
227        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        // Session gaps take no horizontal space; use the supplied bar boundaries.
233        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        // Filter by weight threshold
251        marks.retain(|mark| mark.weight >= weight_threshold);
252
253        // Map marks to bar indices
254        marks = self.map_marks_to_bars(marks, bars);
255
256        // Apply spacing constraints
257        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        // Limit to max marks
268        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    /// Calendar and clock boundaries on actual bars, in the formatter's timezone.
283    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            // Gregorian day 1 is Monday; a bucket change also catches missing anchor days.
313            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    /// Calculate optimal interval between marks
353    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        // Snap to nice intervals
358        let seconds = if seconds_per_mark < 1.0 {
359            // Sub-second intervals: 100ms, 250ms, 500ms
360            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            // Second intervals: 1s, 2s, 5s, 10s, 15s, 30s
369            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            // Minute intervals: 1m, 2m, 5m, 10m, 15m, 30m
384            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            // Hour intervals: 1h, 2h, 4h, 6h, 12h
400            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            // Day intervals: 1d, 2d, 7d
414            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            // Month intervals: 1M, 3M, 6M, 1Y
424            let days = seconds_per_mark / 86400.0;
425            if days < 90.0 {
426                2592000.0 // ~30 days
427            } else if days < 180.0 {
428                7776000.0 // ~90 days
429            } else if days < 365.0 {
430                15552000.0 // ~180 days
431            } else {
432                31536000.0 // ~365 days
433            }
434        };
435
436        // Beyond a budget of candidates, step by whole years to bound the work.
437        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    /// Determine mark type and min weight threshold based on interval
446    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    /// Generate candidate marks at appropriate boundaries
467    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        // Multi-year intervals step by calendar years so every mark is a year boundary.
476        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, // Will be set later
488                        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, // Will be set later
529                bold: false,
530            });
531
532            let Some(next) = current.checked_add_signed(interval) else {
533                break;
534            };
535            current = next;
536        }
537
538        // Calendar boundaries outrank a day stride even when the first is off that stride.
539        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    /// Round time to appropriate boundary
553    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            // Sub-second: round to milliseconds
558            let millis = interval.num_milliseconds();
559            // A zero-millisecond interval has no boundary to snap to; integer
560            // division by it would panic, so leave the time unrounded.
561            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            // Multiples of the interval since midnight keep their clock times as the view pans.
569            // Start at or after the first bar: a mark before it would be drawn at that bar.
570            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            // Monday 1970-01-05 anchors weeks; the Unix epoch anchors other day strides.
578            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    /// Classify a time boundary and assign weight
586    /// Respects primary_type hierarchy - won't downgrade day-level marks to time-level
587    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        // Check for year boundary
607        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        // Check for month boundary
617        if time.day() == 1 && time.hour() == 0 && time.minute() == 0 && time.second() == 0 {
618            return (TickMarkType::Month, TickMarkWeight::MONTH);
619        }
620
621        // Check for week boundary (Monday)
622        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        // Check for day boundary
631        if time.hour() == 0 && time.minute() == 0 && time.second() == 0 {
632            return (TickMarkType::DayOfMonth, TickMarkWeight::DAY);
633        }
634
635        // IMPORTANT: If primary_type is day-level or higher, don't downgrade to time-of-day marks
636        // This fixes the bug where daily data shows "14:30" instead of "Jun 15"
637        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            // For day+ intervals, always show as day of month with appropriate weight
644            // Use the day number for display (e.g., "15", "Jun 15")
645            return (TickMarkType::DayOfMonth, TickMarkWeight::DAY);
646        }
647
648        // Below here only applies to time-level primary types (intraday data)
649
650        // Check for 4-hour boundary
651        if time.hour().is_multiple_of(4) && time.minute() == 0 && time.second() == 0 {
652            return (TickMarkType::Time, TickMarkWeight::HOUR_4);
653        }
654
655        // Check for hour boundary
656        if time.minute() == 0 && time.second() == 0 {
657            return (TickMarkType::Time, TickMarkWeight::HOUR);
658        }
659
660        // Check for 30-minute boundary
661        if time.minute().is_multiple_of(30) && time.second() == 0 {
662            return (TickMarkType::Time, TickMarkWeight::MIN_30);
663        }
664
665        // Check for 15-minute boundary
666        if time.minute().is_multiple_of(15) && time.second() == 0 {
667            return (TickMarkType::Time, TickMarkWeight::MIN_15);
668        }
669
670        // Check for 5-minute boundary
671        if time.minute().is_multiple_of(5) && time.second() == 0 {
672            return (TickMarkType::Time, TickMarkWeight::MIN_5);
673        }
674
675        // Check for minute boundary
676        if time.second() == 0 {
677            return (TickMarkType::Time, TickMarkWeight::MINUTE);
678        }
679
680        // Check for 10-second boundary
681        if time.second().is_multiple_of(10) {
682            return (TickMarkType::TimeWithSeconds, TickMarkWeight::SEC_10);
683        }
684
685        // Default: use primary type with second weight
686        (primary_type, TickMarkWeight::SECOND)
687    }
688
689    /// Format time label based on mark type
690    fn format_time_label(&self, time: DateTime<Utc>, mark_type: TickMarkType) -> String {
691        self.formatter.format(time, mark_type)
692    }
693
694    /// Map marks to bar indices
695    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        // A gap can map several candidates to one bar; retain its strongest boundary.
710        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    /// Apply min spacing constraints between marks
717    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        // Calculate pixels per mark
727        let pixels_per_mark = width_pixels / marks.len() as f32;
728
729        // If spacing is too tight, filter by weight
730        if pixels_per_mark < self.config.min_spacing {
731            // Calculate min time difference BEFORE sorting
732            // Formula: min_time_diff = min_spacing * (time_span / width_pixels)
733            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            // Sort by weight descending to prioritize important marks
740            // Secondary sort by time ensures deterministic ordering when weights are equal
741            marks.sort_by(|a, b| b.weight.cmp(&a.weight).then_with(|| a.time.cmp(&b.time)));
742
743            // Keep marks with sufficient spacing
744            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            // Sort back by time
763            filtered.sort_by_key(|m| m.time);
764            marks = filtered;
765        }
766
767        marks
768    }
769
770    /// Reduce marks count to max
771    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        // Keep the highest weight marks
777        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
789/// The heaviest of `marks` that lie `min_bars` bars apart: lighter marks give way, and with a
790/// `uniform` distribution a weight that does not fit everywhere is left out with every lighter
791/// one.
792pub(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
798/// Keeps marks separated in the same units as their horizontal positions.
799pub(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        // 1000 px give 20 target marks and a budget of 256 candidates; beyond it the
838        // interval is the whole number of years per target mark, plus one.
839        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    /// Three days of 5-minute bars on 600 points: day marks lie 288 bars apart and 4-hour
870    /// marks 48 apart, under the 72-bar minimum of 50 points. Without uniform distribution
871    /// every other 4-hour mark is kept; with it, none are.
872    #[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        // 119 hours at 1100 px choose 4-hour marks, from the first one at or after the first bar.
1037        // Spacing keeps each midnight with its date and 08:00 and 16:00 between them, and a pan
1038        // of one bar keeps every mark.
1039        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        // Local midnight, including a month change, is a date mark in either zone.
1080        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        // Midnights on DST transition dates are 23 or 25 hours apart in each zone.
1114        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        // Monthly intraday and daily calendar candidates must use the displayed month.
1177        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        // Hour bars may start at half past the hour and still need clock marks.
1247        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        // Missing weekends and closed hours consume no slots; marks use actual session bars.
1260        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        // Weekends and a holiday month opening have no bar to label.
1330        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        // Several calendar candidates in one gap keep the strongest boundary; candidates
1362        // beyond the final supplied bar cannot produce a label.
1363        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        // Monday January 8 is closed as well as weekends. Day numbers below are
1407        // offsets from January 1, with Monday-anchored Gregorian day buckets.
1408        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            // The first February bar, day 31, is a stronger boundary whatever the stride.
1436            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        // Year boundary
1519        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        // Month boundary
1525        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        // Hour boundary (use hour 13, not 12, since 12 is a 4-hour boundary)
1531        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        // A zero-millisecond interval has no boundary to snap to; integer
1547        // division by it would panic, so the time must pass through unchanged.
1548        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}