Skip to main content

flowsurface_data/chart/ticks/
x.rs

1use super::MAX_GRID_LINES;
2use crate::{UserTimezone, config::timezone::TimeLabelKind};
3use exchange::{Timeframe, UnixMs};
4
5use chrono::DateTime;
6
7/// Nominal local day length in ms (DST days vary slightly around this).
8const DAY_MS: u64 = 86_400_000;
9/// Upper bound on a local day's length in ms (25h, covers DST fall-back).
10const MAX_LOCAL_DAY_MS: u64 = 90_000_000;
11/// Nominal month length in ms, used only to estimate per-month pixel widths.
12const MONTH_MS: u64 = 30 * DAY_MS;
13/// Nominal year length in ms, used only to estimate per-year pixel widths.
14const YEAR_MS: u64 = 365 * DAY_MS;
15
16const MS_TIME_STEPS: [u64; 12] = [
17    1000 * 120,
18    1000 * 60,
19    1000 * 30,
20    1000 * 20,
21    1000 * 15,
22    1000 * 10,
23    1000 * 5,
24    1000 * 2,
25    1000,
26    500,
27    200,
28    100,
29];
30
31const M1_TIME_STEPS: [u64; 13] = [
32    1000 * 60 * 720, // 12 hour
33    1000 * 60 * 480, // 8 hour
34    1000 * 60 * 360, // 6 hour
35    1000 * 60 * 240, // 4 hour
36    1000 * 60 * 180, // 3 hour
37    1000 * 60 * 120, // 2 hour
38    1000 * 60 * 60,  // 1 hour
39    1000 * 60 * 30,  // 30 min
40    1000 * 60 * 15,  // 15 min
41    1000 * 60 * 10,  // 10 min
42    1000 * 60 * 5,   // 5 min
43    1000 * 60 * 2,   // 2 min
44    1000 * 60,       // 1 min
45];
46
47const M3_TIME_STEPS: [u64; 11] = [
48    1000 * 60 * 1440, // 24 hour
49    1000 * 60 * 720,  // 12 hour
50    1000 * 60 * 360,  // 6 hour
51    1000 * 60 * 180,  // 3 hour
52    1000 * 60 * 120,  // 2 hour
53    1000 * 60 * 60,   // 1 hour
54    1000 * 60 * 30,   // 30 min
55    1000 * 60 * 15,   // 15 min
56    1000 * 60 * 9,    // 9 min
57    1000 * 60 * 5,    // 5 min
58    1000 * 60 * 3,    // 3 min
59];
60
61const M5_TIME_STEPS: [u64; 10] = [
62    1000 * 60 * 1440, // 24 hour
63    1000 * 60 * 720,  // 12 hour
64    1000 * 60 * 480,  // 8 hour
65    1000 * 60 * 360,  // 6 hour
66    1000 * 60 * 240,  // 4 hour
67    1000 * 60 * 120,  // 2 hour
68    1000 * 60 * 60,   // 1 hour
69    1000 * 60 * 30,   // 30 min
70    1000 * 60 * 15,   // 15 min
71    1000 * 60 * 5,    // 5 min
72];
73
74const HOURLY_TIME_STEPS: [u64; 9] = [
75    1000 * 60 * 5760, // 96 hour
76    1000 * 60 * 2880, // 48 hour
77    1000 * 60 * 1440, // 24 hour
78    1000 * 60 * 720,  // 12 hour
79    1000 * 60 * 480,  // 8 hour
80    1000 * 60 * 360,  // 6 hour
81    1000 * 60 * 240,  // 4 hour
82    1000 * 60 * 120,  // 2 hour
83    1000 * 60 * 60,   // 1 hour
84];
85
86/// Intraday weights, coarsest first (e.g. a 2h step → `Hour1`, a 4h step →
87/// `Hour3`).
88const INTRADAY_DIVISORS: [(u64, TickWeight); 8] = [
89    (12 * 60 * 60 * 1000, TickWeight::Hour12),
90    (6 * 60 * 60 * 1000, TickWeight::Hour6),
91    (3 * 60 * 60 * 1000, TickWeight::Hour3),
92    (60 * 60 * 1000, TickWeight::Hour1),
93    (30 * 60 * 1000, TickWeight::Minute30),
94    (5 * 60 * 1000, TickWeight::Minute5),
95    (60 * 1000, TickWeight::Minute1),
96    (1_000, TickWeight::Second),
97];
98
99/// Intraday thinning stride: the smallest multiple of `step_ms` keeping marks
100/// at least a label width apart, rounded up to divide the local day so the
101/// day-anchored walk stays uniform (a 16h stride would alternate 8h/16h gaps).
102/// Non-dividing steps fall back to a full-day stride.
103fn intraday_stride(step_ms: u64, span_ms: u64, width: f32, label_char_w: f32) -> u64 {
104    if step_ms == 0 {
105        return 0;
106    }
107
108    let stride = if width > 0.0 && label_char_w > 0.0 {
109        let span = span_ms.max(1) as f64;
110        let px_per_step = (step_ms as f64 / span) * f64::from(width);
111        let k = ((f64::from(10.0 * label_char_w) / px_per_step).ceil() as u64).max(1);
112        step_ms.saturating_mul(k).max(1)
113    } else {
114        step_ms
115    };
116
117    if stride >= MAX_LOCAL_DAY_MS {
118        return stride;
119    }
120
121    if !DAY_MS.is_multiple_of(step_ms) {
122        return DAY_MS;
123    }
124
125    let mut s = stride;
126    for _ in 0..DAY_MS / step_ms {
127        if DAY_MS.is_multiple_of(s) {
128            return s.min(DAY_MS);
129        }
130        s += step_ms;
131    }
132    DAY_MS
133}
134
135/// A typed table of candidate time steps used to pick the X-axis grid step
136/// for a visible span. Owns both the candidate list and the selection rules,
137/// so callers never touch raw step tables.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139struct TimeSteps(&'static [u64]);
140
141impl TimeSteps {
142    /// Pick the step whose *rendered* grid comes closest to `budget` labels:
143    /// rank each candidate by the pixel spacing of its thinned stride
144    /// ([`intraday_stride`]) against the ideal `width / budget`, since
145    /// thinning can push a step past a longer one (an 8h step thinned past
146    /// 12h renders sparser than 12h). Falls back to the first entry when
147    /// empty.
148    fn pick_closest(&self, span_ms: u64, budget: u32, width: f32, label_char_w: f32) -> u64 {
149        let ideal_spacing_px = f64::from(width) / f64::from(budget.max(1));
150        let span = span_ms.max(1) as f64;
151
152        let mut best: Option<(u64, f64)> = None;
153        for &step in self.0 {
154            if step == 0 {
155                continue;
156            }
157            let stride = intraday_stride(step, span_ms, width, label_char_w);
158            let spacing = stride as f64 / span * f64::from(width);
159            let dist = (spacing - ideal_spacing_px).abs();
160            if best.is_none_or(|(_, d)| dist < d) {
161                best = Some((step, dist));
162            }
163        }
164        best.map(|(s, _)| s)
165            .unwrap_or(*self.0.first().unwrap_or(&1))
166    }
167}
168
169impl From<Timeframe> for TimeSteps {
170    fn from(timeframe: Timeframe) -> Self {
171        let timeframe_in_min = timeframe.to_milliseconds() / 60_000;
172
173        match timeframe_in_min {
174            0_u64..1_u64 => Self(&MS_TIME_STEPS),
175            1..=30 => match timeframe_in_min {
176                1 => Self(&M1_TIME_STEPS),
177                3 => Self(&M3_TIME_STEPS),
178                5 => Self(&M5_TIME_STEPS),
179                15 => Self(&M5_TIME_STEPS[..8]),
180                30 => Self(&M5_TIME_STEPS[..7]),
181                _ => Self(&HOURLY_TIME_STEPS),
182            },
183            31.. => Self(&HOURLY_TIME_STEPS),
184        }
185    }
186}
187
188/// Calendar significance of a tick, coarsest first. Greater weights are placed
189/// before finer ones, so calendar marks are never crowded out by time marks.
190///
191/// Ordering: `Year` > `Month` > `Day` > … > `Second`.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193enum TickWeight {
194    Year,
195    Month,
196    Day,
197    Hour12,
198    Hour6,
199    Hour3,
200    Hour1,
201    Minute30,
202    Minute5,
203    Minute1,
204    Second,
205}
206
207impl TickWeight {
208    /// How this tick's label should be rendered.
209    fn label_kind(self, timeframe: Timeframe) -> TimeLabelKind<'static> {
210        match self {
211            TickWeight::Year => TimeLabelKind::Custom("%Y"),
212            TickWeight::Month => TimeLabelKind::Custom("%b"),
213            TickWeight::Day => TimeLabelKind::Custom("%d"),
214            _ => TimeLabelKind::Axis { timeframe },
215        }
216    }
217
218    /// Intraday weight of a regular `step_ms` grid: the coarsest divisor it covers.
219    fn for_intraday_step(step_ms: u64) -> Self {
220        INTRADAY_DIVISORS
221            .iter()
222            .find(|(divisor, _)| step_ms >= *divisor)
223            .map(|&(_, weight)| weight)
224            .unwrap_or(Self::Second)
225    }
226
227    /// Rendered character count of the widest label for this weight
228    /// (`HH:MM` / `MM:SS` = 5, `%Y` = 4, `%b` = 3, `%-d` = 2). Used to size
229    /// each label's rect so selection never keeps overlapping labels: if two
230    /// labels overlapped, the every-other drop pattern would re-anchor to the
231    /// moving window edge and flip the whole label set while panning.
232    fn chars(self) -> u32 {
233        match self {
234            TickWeight::Year => 4,
235            TickWeight::Month => 3,
236            TickWeight::Day => 2,
237            _ => 5,
238        }
239    }
240
241    /// Placement priority: coarser units come first.
242    fn priority(self) -> u8 {
243        match self {
244            TickWeight::Year => 10,
245            TickWeight::Month => 9,
246            TickWeight::Day => 8,
247            TickWeight::Hour12 => 7,
248            TickWeight::Hour6 => 6,
249            TickWeight::Hour3 => 5,
250            TickWeight::Hour1 => 4,
251            TickWeight::Minute30 => 3,
252            TickWeight::Minute5 => 2,
253            TickWeight::Minute1 => 1,
254            TickWeight::Second => 0,
255        }
256    }
257}
258
259impl PartialOrd for TickWeight {
260    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
261        Some(self.cmp(other))
262    }
263}
264
265impl Ord for TickWeight {
266    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
267        self.priority().cmp(&other.priority())
268    }
269}
270
271/// A candidate time-axis tick: a timestamp plus its calendar weight.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273struct TickMark {
274    /// Unix timestamp in milliseconds.
275    time_ms: u64,
276    /// Calendar significance of the tick (coarsest first).
277    weight: TickWeight,
278}
279
280impl TickMark {
281    /// Format a tick by its calendar weight, in the user's timezone.
282    fn format_tick(
283        &self,
284        timeframe: exchange::Timeframe,
285        timezone: crate::UserTimezone,
286    ) -> Option<String> {
287        let kind = self.weight.label_kind(timeframe);
288        timezone.format_with_kind(self.time_ms as i64, kind)
289    }
290}
291
292/// Candidate ticks for a visible range, plus the selection math that turns
293/// them into the drawn labels: coarser marks are placed first under a minimum
294/// pixel spacing, so month/year boundaries are never crowded out by finer
295/// labels.
296#[derive(Debug, Clone, Default)]
297struct TimeAxisGrid {
298    /// Visible range start (ms); used by [`Self::x_position`].
299    earliest: UnixMs,
300    /// Visible range end (ms); used by [`Self::x_position`].
301    latest: UnixMs,
302    /// Candidate ticks spanning the visible range plus a one-step margin on
303    /// each side, deduplicated (a timestamp keeps its coarsest weight) and
304    /// sorted by time. The margin keeps off-screen ticks alive so they slide
305    /// in/out smoothly while the view pans.
306    marks: Vec<TickMark>,
307}
308
309impl TimeAxisGrid {
310    /// Compute the X-axis time grid for a visible range. Pure and deterministic.
311    fn new(
312        earliest: UnixMs,
313        latest: UnixMs,
314        span_ms: u64,
315        labels_can_fit: i32,
316        timeframe: Timeframe,
317        timezone: UserTimezone,
318        width: f32,
319        label_char_w: f32,
320    ) -> Self {
321        let step_ms = calc_step(span_ms, labels_can_fit, timeframe, width, label_char_w);
322
323        if step_ms == 0 {
324            return TimeAxisGrid {
325                earliest,
326                latest,
327                ..Default::default()
328            };
329        }
330
331        // Generate over a window that extends at least one step past each
332        // visible edge, and enough pixels to hold the widest label's full
333        // width off-screen (the largest center-to-center gap between two
334        // labels): ticks then already exist just off-screen, so as the view
335        // pans the leading tick slides in (and the trailing one slides out)
336        // from off-screen instead of popping at the axis edge, and a mark
337        // entering the window is always off-screen so it can never flip which
338        // finer marks are kept on-screen. Positions are still mapped from the
339        // *visible* range, so off-screen marks get negative / beyond-width
340        // positions that callers cull.
341        let min_margin_ms =
342            (f64::from(10.0 * label_char_w) / f64::from(width) * span_ms as f64) as u64;
343        let margin_ms = step_ms.max(min_margin_ms);
344        let gen_earliest = earliest.saturating_sub(margin_ms);
345        let gen_latest = latest.saturating_add(margin_ms);
346
347        let (Some(start), Some(end)) =
348            (gen_earliest.as_datetime_utc(), gen_latest.as_datetime_utc())
349        else {
350            // Extreme timestamps: fall back to step multiples only.
351            return TimeAxisGrid {
352                earliest,
353                latest,
354                marks: Self::step_marks(
355                    gen_earliest,
356                    gen_latest,
357                    span_ms,
358                    step_ms,
359                    timezone,
360                    width,
361                    label_char_w,
362                ),
363            };
364        };
365
366        let mut marks = Self::step_marks(
367            gen_earliest,
368            gen_latest,
369            span_ms,
370            step_ms,
371            timezone,
372            width,
373            label_char_w,
374        );
375
376        // Calendar boundaries guarantee coarse anchors even when the intraday
377        // step is too coarse to land on every one of them.
378        marks.extend(
379            Self::collect_daily(
380                gen_earliest,
381                gen_latest,
382                span_ms,
383                start,
384                end,
385                timezone,
386                width,
387                label_char_w,
388            )
389            .into_iter()
390            .map(|t| TickMark {
391                time_ms: t,
392                weight: TickWeight::Day,
393            }),
394        );
395        marks.extend(
396            Self::collect_monthly(
397                gen_earliest,
398                gen_latest,
399                span_ms,
400                start,
401                end,
402                timezone,
403                width,
404                label_char_w,
405            )
406            .into_iter()
407            .map(|t| TickMark {
408                time_ms: t,
409                weight: TickWeight::Month,
410            }),
411        );
412        marks.extend(
413            Self::collect_yearly(
414                gen_earliest,
415                gen_latest,
416                span_ms,
417                start,
418                end,
419                timezone,
420                width,
421                label_char_w,
422            )
423            .into_iter()
424            .map(|t| TickMark {
425                time_ms: t,
426                weight: TickWeight::Year,
427            }),
428        );
429
430        marks.sort_by_key(|m| m.time_ms);
431        marks.dedup_by(|removed, kept| {
432            if removed.time_ms == kept.time_ms {
433                if removed.weight > kept.weight {
434                    kept.weight = removed.weight;
435                }
436                true
437            } else {
438                false
439            }
440        });
441
442        TimeAxisGrid {
443            earliest,
444            latest,
445            marks,
446        }
447    }
448
449    /// Intraday step ticks (every `step_ms` from each local midnight), weighted
450    /// by the divisor the step covers; calendar boundaries are layered on top.
451    fn step_marks(
452        earliest: UnixMs,
453        latest: UnixMs,
454        span_ms: u64,
455        step_ms: u64,
456        timezone: UserTimezone,
457        width: f32,
458        label_char_w: f32,
459    ) -> Vec<TickMark> {
460        let weight = TickWeight::for_intraday_step(step_ms);
461        Self::day_anchored_ticks(
462            earliest,
463            latest,
464            span_ms,
465            step_ms,
466            timezone,
467            width,
468            label_char_w,
469        )
470        .into_iter()
471        .map(|t| TickMark { time_ms: t, weight })
472        .collect()
473    }
474
475    /// Place coarsest marks first, keeping each only when both neighbors sit
476    /// far enough that their labels cannot overlap.
477    fn select_marks(&self, width: f32, label_char_w: f32) -> Vec<TickMark> {
478        if self.marks.is_empty() || width <= 0.0 || label_char_w <= 0.0 {
479            return self.marks.clone();
480        }
481
482        let earliest = self.earliest.as_u64();
483        let latest = self.latest.as_u64();
484        if latest <= earliest {
485            return Vec::new();
486        }
487        let span = latest - earliest;
488
489        // Signed mapping: marks inside the margin before `earliest` get
490        // negative positions so they slide in from off-screen while panning.
491        let x = |t: u64| -> f64 { (t as f64 - earliest as f64) / span as f64 * f64::from(width) };
492
493        // Required center-to-center gap so two labels never overlap: the sum
494        // of their half-widths `(chars(a) + chars(b)) * char_w`. Sizing each
495        // pair keeps short day/month marks dense while guaranteeing kept
496        // labels never overlap — overlap is what made the drop pattern
497        // re-anchor to the moving window edge and flip the whole set on pan.
498        let gap =
499            |a: TickWeight, b: TickWeight| f64::from((a.chars() + b.chars()) as f32 * label_char_w);
500
501        let mut kept: Vec<TickMark> = Vec::new(); // sorted by time
502
503        for weight in [
504            TickWeight::Year,
505            TickWeight::Month,
506            TickWeight::Day,
507            TickWeight::Hour12,
508            TickWeight::Hour6,
509            TickWeight::Hour3,
510            TickWeight::Hour1,
511            TickWeight::Minute30,
512            TickWeight::Minute5,
513            TickWeight::Minute1,
514            TickWeight::Second,
515        ] {
516            // `marks` is time-sorted, so filtering preserves order: no sort needed.
517            for mark in self.marks.iter().filter(|m| m.weight == weight) {
518                let idx = kept.partition_point(|k| k.time_ms < mark.time_ms);
519                let left_ok = idx == 0
520                    || x(mark.time_ms) - x(kept[idx - 1].time_ms)
521                        >= gap(mark.weight, kept[idx - 1].weight);
522                let right_ok = idx >= kept.len()
523                    || x(kept[idx].time_ms) - x(mark.time_ms) >= gap(mark.weight, kept[idx].weight);
524                if left_ok && right_ok {
525                    kept.insert(idx, *mark);
526                }
527            }
528        }
529
530        kept
531    }
532
533    /// Map a timestamp to a horizontal position within this grid's visible
534    /// range (`[earliest, latest]`) scaled to `width`. Marks inside the
535    /// generation margin get positions slightly outside `[0, width]` (negative
536    /// before `earliest`, beyond `width` after `latest`) so they can slide in
537    /// from off-screen while panning; callers cull non-drawable positions.
538    /// Returns `0.0` when the range is empty.
539    fn x_position(&self, time_ms: u64, width: f32) -> f64 {
540        let earliest = self.earliest.as_u64();
541        let latest = self.latest.as_u64();
542        if latest > earliest {
543            let span = latest - earliest;
544            (time_ms as f64 - earliest as f64) / span as f64 * f64::from(width)
545        } else {
546            0.0
547        }
548    }
549
550    /// Start-of-local-day (00:00 in the user's timezone) boundaries within
551    /// `[earliest, latest]`, thinned to the pixel budget so multi-year ranges
552    /// stay bounded (`select_marks` re-checks the real spacing).
553    fn collect_daily(
554        earliest: UnixMs,
555        latest: UnixMs,
556        span_ms: u64,
557        start: DateTime<chrono::Utc>,
558        end: DateTime<chrono::Utc>,
559        timezone: UserTimezone,
560        width: f32,
561        label_char_w: f32,
562    ) -> Vec<u64> {
563        let start_ms = timezone
564            .start_of_local_day_utc_ms(start.timestamp_millis() as u64)
565            .unwrap_or(0);
566        let end_ms = end.timestamp_millis() as u64;
567        // Day labels are `%-d` (2 chars, full width `4 * char_w`); the
568        // thinning keeps the established ~`6 * char_w` density so day marks
569        // stay comfortably apart on zoomed-out views.
570        let skip_ms = boundary_skip_ms(DAY_MS, span_ms, width, 6.0 * label_char_w);
571        // Phase-align the walk start to an absolute skip grid so the thinned
572        // day set is anchored to fixed dates — otherwise it re-anchors to the
573        // sliding visible edge and the whole day-label set shifts by one day
574        // whenever the edge crosses a midnight while panning.
575        let start_ms = timezone
576            .align_to_skip_grid(start_ms, skip_ms / DAY_MS)
577            .unwrap_or(start_ms);
578        collect_boundaries(start_ms, end_ms, earliest, latest, skip_ms, |ts| {
579            timezone.next_local_day_utc_ms(ts)
580        })
581    }
582
583    /// Start-of-local-month boundaries within `[earliest, latest]`, thinned to
584    /// the pixel budget so they never overlap one another at extreme zoom-out
585    /// (`select_marks` re-checks the real spacing).
586    fn collect_monthly(
587        earliest: UnixMs,
588        latest: UnixMs,
589        span_ms: u64,
590        start: DateTime<chrono::Utc>,
591        end: DateTime<chrono::Utc>,
592        timezone: UserTimezone,
593        width: f32,
594        label_char_w: f32,
595    ) -> Vec<u64> {
596        let start_ms = timezone
597            .start_of_local_month_utc_ms(start.timestamp_millis() as u64)
598            .unwrap_or(0);
599        let end_ms = end.timestamp_millis() as u64;
600        // Month labels are `%b` (3 chars → full width `6 * char_w`); thinning
601        // only engages where month marks would collide (extreme zoom-out) and
602        // is phase-aligned to a fixed month grid so the set stays stable
603        // while panning.
604        let skip_ms = boundary_skip_ms(MONTH_MS, span_ms, width, 6.0 * label_char_w);
605        let start_ms = timezone
606            .align_to_month_grid(start_ms, skip_ms / MONTH_MS)
607            .unwrap_or(start_ms);
608        collect_boundaries(start_ms, end_ms, earliest, latest, skip_ms, |ts| {
609            timezone.next_local_month_utc_ms(ts)
610        })
611    }
612
613    /// Start-of-local-year boundaries within `[earliest, latest]`, thinned to
614    /// the pixel budget so they never overlap one another at extreme zoom-out
615    /// (`select_marks` re-checks the real spacing).
616    fn collect_yearly(
617        earliest: UnixMs,
618        latest: UnixMs,
619        span_ms: u64,
620        start: DateTime<chrono::Utc>,
621        end: DateTime<chrono::Utc>,
622        timezone: UserTimezone,
623        width: f32,
624        label_char_w: f32,
625    ) -> Vec<u64> {
626        let start_ms = timezone
627            .start_of_local_year_utc_ms(start.timestamp_millis() as u64)
628            .unwrap_or(0);
629        let end_ms = end.timestamp_millis() as u64;
630        // Year labels are `%Y` (4 chars → full width `8 * char_w`); thinning
631        // is phase-aligned to a fixed year grid for the same stability reason
632        // as days/months.
633        let skip_ms = boundary_skip_ms(YEAR_MS, span_ms, width, 8.0 * label_char_w);
634        let start_ms = timezone
635            .align_to_year_grid(start_ms, skip_ms / YEAR_MS)
636            .unwrap_or(start_ms);
637        collect_boundaries(start_ms, end_ms, earliest, latest, skip_ms, |ts| {
638            timezone.next_local_year_utc_ms(ts)
639        })
640    }
641
642    /// Multiples of `step_ms` anchored to the user's local midnight,
643    /// ascending: each day starts a fresh grid at its midnight, so the walk
644    /// is uniform in the user's timezone and matches the calendar layer.
645    ///
646    /// Pre-thinned via [`intraday_stride`]; once the stride spans a full
647    /// local day it degrades to plain multiples (the day layer already
648    /// anchors every midnight), keeping long ranges bounded. Guaranteed to
649    /// terminate. Returns an empty vec for a zero step or an
650    /// empty/inverted range.
651    fn day_anchored_ticks(
652        earliest: UnixMs,
653        latest: UnixMs,
654        span_ms: u64,
655        step_ms: u64,
656        timezone: UserTimezone,
657        width: f32,
658        label_char_w: f32,
659    ) -> Vec<u64> {
660        if step_ms == 0 {
661            return Vec::new();
662        }
663
664        let e = earliest.as_u64();
665        let l = latest.as_u64();
666        if l < e {
667            return Vec::new();
668        }
669
670        // Pre-sized to the label width so selection keeps every mark:
671        // overlapped marks would flip the whole label set while panning.
672        let stride_ms = intraday_stride(step_ms, span_ms, width, label_char_w);
673
674        // Once the stride spans a full local day, day-anchoring would only
675        // repeat the midnight anchor the day layer already provides, so fall
676        // back to plain stride multiples: uniformly spaced and bounded by the
677        // pixel budget.
678        if stride_ms >= MAX_LOCAL_DAY_MS {
679            let first = e.div_ceil(stride_ms).saturating_mul(stride_ms);
680            let mut out = Vec::new();
681            let mut t = first;
682            while t <= l {
683                out.push(t);
684                let Some(next) = t.checked_add(stride_ms) else {
685                    break;
686                };
687                t = next;
688            }
689            return out;
690        }
691
692        let mut out = Vec::new();
693        let mut day_start = timezone.start_of_local_day_utc_ms(e).unwrap_or(e);
694        while day_start <= l {
695            let Some(next_day) = timezone.next_local_day_utc_ms(day_start) else {
696                break;
697            };
698
699            // First mark of this day at or after `earliest`, aligned to the
700            // day's midnight grid (a multiple of `step` from midnight).
701            let first = day_start.max(e);
702            let k = first.saturating_sub(day_start).div_ceil(stride_ms);
703            let mut t = day_start.saturating_add(k.saturating_mul(stride_ms));
704
705            while t < next_day && t <= l {
706                out.push(t);
707                let Some(next) = t.checked_add(stride_ms) else {
708                    break;
709                };
710                t = next;
711            }
712
713            day_start = next_day;
714        }
715        out
716    }
717}
718
719/// Advance `current` by `next` while it stays within `[0, end_ms]`,
720/// collecting every value that lands inside `[earliest, latest]`.
721/// `skip_ms` thins the series: boundaries closer than `skip_ms` to the
722/// last collected one are skipped, so dense series (e.g. daily on a
723/// multi-year range) stay bounded. Terminates even when `next` does not
724/// advance.
725fn collect_boundaries(
726    mut current: u64,
727    end_ms: u64,
728    earliest: UnixMs,
729    latest: UnixMs,
730    skip_ms: u64,
731    next: impl Fn(u64) -> Option<u64>,
732) -> Vec<u64> {
733    let mut out = Vec::with_capacity(MAX_GRID_LINES.min(64));
734    let mut guard = 0;
735    while current <= end_ms && guard < MAX_GRID_LINES {
736        if current >= earliest.as_u64() && current <= latest.as_u64() {
737            out.push(current);
738        }
739        guard += 1;
740
741        let Some(mut next_val) = next(current) else {
742            break;
743        };
744        if next_val <= current {
745            break;
746        }
747
748        // Skip boundaries that would sit closer than `skip_ms` to the one
749        // just collected (pixel-based thinning for long ranges;
750        // `select_marks` re-checks the real spacing afterwards).
751        let target = current.saturating_add(skip_ms);
752        while next_val < target {
753            let Some(after) = next(next_val) else {
754                break;
755            };
756            if after <= next_val {
757                break;
758            }
759            next_val = after;
760        }
761        current = next_val;
762    }
763    out
764}
765
766/// Pixel-based thinning for a boundary series whose nominal spacing is
767/// `nominal_ms` (e.g. one day): the smallest skip, in ms, such that
768/// consecutive collected boundaries are at least `spacing_px` apart.
769/// `spacing_px` is the full width of the labels in this layer (e.g. day
770/// marks are 2 chars → `4 * char_w`), so short marks stay dense while
771/// kept labels never overlap.
772fn boundary_skip_ms(nominal_ms: u64, span_ms: u64, width: f32, spacing_px: f32) -> u64 {
773    if width <= 0.0 || spacing_px <= 0.0 {
774        return 0;
775    }
776    let span = span_ms.max(1) as f64;
777    let px_per_boundary = (nominal_ms as f64 / span) * f64::from(width);
778    let count = (f64::from(spacing_px) / px_per_boundary).ceil() as u64;
779    nominal_ms.saturating_mul(count.max(1))
780}
781
782/// Select the grid step (in milliseconds) for a visible range.
783///
784/// `step_ms` is always one of the timeframe's table values and non-zero.
785fn calc_step(
786    span_ms: u64,
787    labels_can_fit: i32,
788    timeframe: Timeframe,
789    width: f32,
790    label_char_w: f32,
791) -> u64 {
792    TimeSteps::from(timeframe).pick_closest(
793        span_ms,
794        labels_can_fit.max(1) as u32,
795        width,
796        label_char_w,
797    )
798}
799
800/// Visual emphasis tier for a time-axis tick.
801///
802/// [`TimeTickTier::Secondary`] marks the coarse calendar boundaries that stand
803/// out from the fine-grained [`TimeTickTier::Main`] labels.
804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805pub enum TimeTickTier {
806    /// The primary, fine-grained ticks (the majority of labels).
807    Main,
808    /// Coarser boundary ticks that should stand out from the majority.
809    Secondary,
810}
811
812/// A fully positioned, formatted X-axis tick label, ready to be drawn.
813///
814/// UI-agnostic: carries the horizontal position, text and emphasis tier, so
815/// callers can map it onto their own rendering primitives.
816#[derive(Debug, Clone, PartialEq)]
817pub struct TimeTickLabel {
818    /// Horizontal center position in pixels within the axis width.
819    pub x_pos: f32,
820    /// Rendered label text.
821    pub content: String,
822    /// Visual emphasis tier.
823    pub tier: TimeTickTier,
824}
825
826impl TimeTickLabel {
827    /// Build the grid, select marks under a per-label-width spacing, then
828    /// format and position each one within `width` pixels.
829    ///
830    /// `span_ms` is the exact visible span from the axis geometry: bit-stable
831    /// while panning, unlike `latest - earliest` which the edge conversion
832    /// quantizes and could flip step selection mid-pan. `label_char_w` is the
833    /// per-character label width each label's rect is sized from.
834    pub fn for_range(
835        earliest: UnixMs,
836        latest: UnixMs,
837        span_ms: u64,
838        width: f32,
839        label_char_w: f32,
840        labels_can_fit: i32,
841        timeframe: Timeframe,
842        timezone: crate::UserTimezone,
843    ) -> Vec<TimeTickLabel> {
844        let grid = TimeAxisGrid::new(
845            earliest,
846            latest,
847            span_ms,
848            labels_can_fit,
849            timeframe,
850            timezone,
851            width,
852            label_char_w,
853        );
854
855        if grid.marks.is_empty() {
856            return Vec::new();
857        }
858
859        let selected = grid.select_marks(width, label_char_w);
860        let max_weight = selected.iter().map(|m| m.weight).max();
861        let has_finer_tier = max_weight.is_some_and(|max| selected.iter().any(|m| m.weight < max));
862
863        let mut labels = Vec::with_capacity(selected.len());
864        for mark in selected {
865            let Some(content) = mark.format_tick(timeframe, timezone) else {
866                continue;
867            };
868
869            // The coarsest weight present stands out.
870            let tier = if has_finer_tier && Some(mark.weight) == max_weight {
871                TimeTickTier::Secondary
872            } else {
873                TimeTickTier::Main
874            };
875
876            labels.push(TimeTickLabel {
877                x_pos: grid.x_position(mark.time_ms, width) as f32,
878                content,
879                tier,
880            });
881        }
882
883        labels
884    }
885}