1use super::MAX_GRID_LINES;
2use crate::{UserTimezone, config::timezone::TimeLabelKind};
3use exchange::{Timeframe, UnixMs};
4
5use chrono::DateTime;
6
7const DAY_MS: u64 = 86_400_000;
9const MAX_LOCAL_DAY_MS: u64 = 90_000_000;
11const MONTH_MS: u64 = 30 * DAY_MS;
13const 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, 1000 * 60 * 480, 1000 * 60 * 360, 1000 * 60 * 240, 1000 * 60 * 180, 1000 * 60 * 120, 1000 * 60 * 60, 1000 * 60 * 30, 1000 * 60 * 15, 1000 * 60 * 10, 1000 * 60 * 5, 1000 * 60 * 2, 1000 * 60, ];
46
47const M3_TIME_STEPS: [u64; 11] = [
48 1000 * 60 * 1440, 1000 * 60 * 720, 1000 * 60 * 360, 1000 * 60 * 180, 1000 * 60 * 120, 1000 * 60 * 60, 1000 * 60 * 30, 1000 * 60 * 15, 1000 * 60 * 9, 1000 * 60 * 5, 1000 * 60 * 3, ];
60
61const M5_TIME_STEPS: [u64; 10] = [
62 1000 * 60 * 1440, 1000 * 60 * 720, 1000 * 60 * 480, 1000 * 60 * 360, 1000 * 60 * 240, 1000 * 60 * 120, 1000 * 60 * 60, 1000 * 60 * 30, 1000 * 60 * 15, 1000 * 60 * 5, ];
73
74const HOURLY_TIME_STEPS: [u64; 9] = [
75 1000 * 60 * 5760, 1000 * 60 * 2880, 1000 * 60 * 1440, 1000 * 60 * 720, 1000 * 60 * 480, 1000 * 60 * 360, 1000 * 60 * 240, 1000 * 60 * 120, 1000 * 60 * 60, ];
85
86const 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
99fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139struct TimeSteps(&'static [u64]);
140
141impl TimeSteps {
142 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#[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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273struct TickMark {
274 time_ms: u64,
276 weight: TickWeight,
278}
279
280impl TickMark {
281 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#[derive(Debug, Clone, Default)]
297struct TimeAxisGrid {
298 earliest: UnixMs,
300 latest: UnixMs,
302 marks: Vec<TickMark>,
307}
308
309impl TimeAxisGrid {
310 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 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 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 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 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 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 let x = |t: u64| -> f64 { (t as f64 - earliest as f64) / span as f64 * f64::from(width) };
492
493 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(); 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 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 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 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 let skip_ms = boundary_skip_ms(DAY_MS, span_ms, width, 6.0 * label_char_w);
571 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 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 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 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 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 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 let stride_ms = intraday_stride(step_ms, span_ms, width, label_char_w);
673
674 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 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
719fn 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 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
766fn 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
782fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805pub enum TimeTickTier {
806 Main,
808 Secondary,
810}
811
812#[derive(Debug, Clone, PartialEq)]
817pub struct TimeTickLabel {
818 pub x_pos: f32,
820 pub content: String,
822 pub tier: TimeTickTier,
824}
825
826impl TimeTickLabel {
827 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 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}