use super::MAX_GRID_LINES;
use crate::{UserTimezone, config::timezone::TimeLabelKind};
use exchange::{Timeframe, UnixMs};
use chrono::DateTime;
const DAY_MS: u64 = 86_400_000;
const MAX_LOCAL_DAY_MS: u64 = 90_000_000;
const MONTH_MS: u64 = 30 * DAY_MS;
const YEAR_MS: u64 = 365 * DAY_MS;
const MS_TIME_STEPS: [u64; 12] = [
1000 * 120,
1000 * 60,
1000 * 30,
1000 * 20,
1000 * 15,
1000 * 10,
1000 * 5,
1000 * 2,
1000,
500,
200,
100,
];
const M1_TIME_STEPS: [u64; 13] = [
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, ];
const M3_TIME_STEPS: [u64; 11] = [
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, ];
const M5_TIME_STEPS: [u64; 10] = [
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, ];
const HOURLY_TIME_STEPS: [u64; 9] = [
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, ];
const INTRADAY_DIVISORS: [(u64, TickWeight); 8] = [
(12 * 60 * 60 * 1000, TickWeight::Hour12),
(6 * 60 * 60 * 1000, TickWeight::Hour6),
(3 * 60 * 60 * 1000, TickWeight::Hour3),
(60 * 60 * 1000, TickWeight::Hour1),
(30 * 60 * 1000, TickWeight::Minute30),
(5 * 60 * 1000, TickWeight::Minute5),
(60 * 1000, TickWeight::Minute1),
(1_000, TickWeight::Second),
];
fn intraday_stride(step_ms: u64, span_ms: u64, width: f32, label_char_w: f32) -> u64 {
if step_ms == 0 {
return 0;
}
let stride = if width > 0.0 && label_char_w > 0.0 {
let span = span_ms.max(1) as f64;
let px_per_step = (step_ms as f64 / span) * f64::from(width);
let k = ((f64::from(10.0 * label_char_w) / px_per_step).ceil() as u64).max(1);
step_ms.saturating_mul(k).max(1)
} else {
step_ms
};
if stride >= MAX_LOCAL_DAY_MS {
return stride;
}
if !DAY_MS.is_multiple_of(step_ms) {
return DAY_MS;
}
let mut s = stride;
for _ in 0..DAY_MS / step_ms {
if DAY_MS.is_multiple_of(s) {
return s.min(DAY_MS);
}
s += step_ms;
}
DAY_MS
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TimeSteps(&'static [u64]);
impl TimeSteps {
fn pick_closest(&self, span_ms: u64, budget: u32, width: f32, label_char_w: f32) -> u64 {
let ideal_spacing_px = f64::from(width) / f64::from(budget.max(1));
let span = span_ms.max(1) as f64;
let mut best: Option<(u64, f64)> = None;
for &step in self.0 {
if step == 0 {
continue;
}
let stride = intraday_stride(step, span_ms, width, label_char_w);
let spacing = stride as f64 / span * f64::from(width);
let dist = (spacing - ideal_spacing_px).abs();
if best.is_none_or(|(_, d)| dist < d) {
best = Some((step, dist));
}
}
best.map(|(s, _)| s)
.unwrap_or(*self.0.first().unwrap_or(&1))
}
}
impl From<Timeframe> for TimeSteps {
fn from(timeframe: Timeframe) -> Self {
let timeframe_in_min = timeframe.to_milliseconds() / 60_000;
match timeframe_in_min {
0_u64..1_u64 => Self(&MS_TIME_STEPS),
1..=30 => match timeframe_in_min {
1 => Self(&M1_TIME_STEPS),
3 => Self(&M3_TIME_STEPS),
5 => Self(&M5_TIME_STEPS),
15 => Self(&M5_TIME_STEPS[..8]),
30 => Self(&M5_TIME_STEPS[..7]),
_ => Self(&HOURLY_TIME_STEPS),
},
31.. => Self(&HOURLY_TIME_STEPS),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TickWeight {
Year,
Month,
Day,
Hour12,
Hour6,
Hour3,
Hour1,
Minute30,
Minute5,
Minute1,
Second,
}
impl TickWeight {
fn label_kind(self, timeframe: Timeframe) -> TimeLabelKind<'static> {
match self {
TickWeight::Year => TimeLabelKind::Custom("%Y"),
TickWeight::Month => TimeLabelKind::Custom("%b"),
TickWeight::Day => TimeLabelKind::Custom("%d"),
_ => TimeLabelKind::Axis { timeframe },
}
}
fn for_intraday_step(step_ms: u64) -> Self {
INTRADAY_DIVISORS
.iter()
.find(|(divisor, _)| step_ms >= *divisor)
.map(|&(_, weight)| weight)
.unwrap_or(Self::Second)
}
fn chars(self) -> u32 {
match self {
TickWeight::Year => 4,
TickWeight::Month => 3,
TickWeight::Day => 2,
_ => 5,
}
}
fn priority(self) -> u8 {
match self {
TickWeight::Year => 10,
TickWeight::Month => 9,
TickWeight::Day => 8,
TickWeight::Hour12 => 7,
TickWeight::Hour6 => 6,
TickWeight::Hour3 => 5,
TickWeight::Hour1 => 4,
TickWeight::Minute30 => 3,
TickWeight::Minute5 => 2,
TickWeight::Minute1 => 1,
TickWeight::Second => 0,
}
}
}
impl PartialOrd for TickWeight {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TickWeight {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.priority().cmp(&other.priority())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TickMark {
time_ms: u64,
weight: TickWeight,
}
impl TickMark {
fn format_tick(
&self,
timeframe: exchange::Timeframe,
timezone: crate::UserTimezone,
) -> Option<String> {
let kind = self.weight.label_kind(timeframe);
timezone.format_with_kind(self.time_ms as i64, kind)
}
}
#[derive(Debug, Clone, Default)]
struct TimeAxisGrid {
earliest: UnixMs,
latest: UnixMs,
marks: Vec<TickMark>,
}
impl TimeAxisGrid {
fn new(
earliest: UnixMs,
latest: UnixMs,
span_ms: u64,
labels_can_fit: i32,
timeframe: Timeframe,
timezone: UserTimezone,
width: f32,
label_char_w: f32,
) -> Self {
let step_ms = calc_step(span_ms, labels_can_fit, timeframe, width, label_char_w);
if step_ms == 0 {
return TimeAxisGrid {
earliest,
latest,
..Default::default()
};
}
let min_margin_ms =
(f64::from(10.0 * label_char_w) / f64::from(width) * span_ms as f64) as u64;
let margin_ms = step_ms.max(min_margin_ms);
let gen_earliest = earliest.saturating_sub(margin_ms);
let gen_latest = latest.saturating_add(margin_ms);
let (Some(start), Some(end)) =
(gen_earliest.as_datetime_utc(), gen_latest.as_datetime_utc())
else {
return TimeAxisGrid {
earliest,
latest,
marks: Self::step_marks(
gen_earliest,
gen_latest,
span_ms,
step_ms,
timezone,
width,
label_char_w,
),
};
};
let mut marks = Self::step_marks(
gen_earliest,
gen_latest,
span_ms,
step_ms,
timezone,
width,
label_char_w,
);
marks.extend(
Self::collect_daily(
gen_earliest,
gen_latest,
span_ms,
start,
end,
timezone,
width,
label_char_w,
)
.into_iter()
.map(|t| TickMark {
time_ms: t,
weight: TickWeight::Day,
}),
);
marks.extend(
Self::collect_monthly(
gen_earliest,
gen_latest,
span_ms,
start,
end,
timezone,
width,
label_char_w,
)
.into_iter()
.map(|t| TickMark {
time_ms: t,
weight: TickWeight::Month,
}),
);
marks.extend(
Self::collect_yearly(
gen_earliest,
gen_latest,
span_ms,
start,
end,
timezone,
width,
label_char_w,
)
.into_iter()
.map(|t| TickMark {
time_ms: t,
weight: TickWeight::Year,
}),
);
marks.sort_by_key(|m| m.time_ms);
marks.dedup_by(|removed, kept| {
if removed.time_ms == kept.time_ms {
if removed.weight > kept.weight {
kept.weight = removed.weight;
}
true
} else {
false
}
});
TimeAxisGrid {
earliest,
latest,
marks,
}
}
fn step_marks(
earliest: UnixMs,
latest: UnixMs,
span_ms: u64,
step_ms: u64,
timezone: UserTimezone,
width: f32,
label_char_w: f32,
) -> Vec<TickMark> {
let weight = TickWeight::for_intraday_step(step_ms);
Self::day_anchored_ticks(
earliest,
latest,
span_ms,
step_ms,
timezone,
width,
label_char_w,
)
.into_iter()
.map(|t| TickMark { time_ms: t, weight })
.collect()
}
fn select_marks(&self, width: f32, label_char_w: f32) -> Vec<TickMark> {
if self.marks.is_empty() || width <= 0.0 || label_char_w <= 0.0 {
return self.marks.clone();
}
let earliest = self.earliest.as_u64();
let latest = self.latest.as_u64();
if latest <= earliest {
return Vec::new();
}
let span = latest - earliest;
let x = |t: u64| -> f64 { (t as f64 - earliest as f64) / span as f64 * f64::from(width) };
let gap =
|a: TickWeight, b: TickWeight| f64::from((a.chars() + b.chars()) as f32 * label_char_w);
let mut kept: Vec<TickMark> = Vec::new();
for weight in [
TickWeight::Year,
TickWeight::Month,
TickWeight::Day,
TickWeight::Hour12,
TickWeight::Hour6,
TickWeight::Hour3,
TickWeight::Hour1,
TickWeight::Minute30,
TickWeight::Minute5,
TickWeight::Minute1,
TickWeight::Second,
] {
for mark in self.marks.iter().filter(|m| m.weight == weight) {
let idx = kept.partition_point(|k| k.time_ms < mark.time_ms);
let left_ok = idx == 0
|| x(mark.time_ms) - x(kept[idx - 1].time_ms)
>= gap(mark.weight, kept[idx - 1].weight);
let right_ok = idx >= kept.len()
|| x(kept[idx].time_ms) - x(mark.time_ms) >= gap(mark.weight, kept[idx].weight);
if left_ok && right_ok {
kept.insert(idx, *mark);
}
}
}
kept
}
fn x_position(&self, time_ms: u64, width: f32) -> f64 {
let earliest = self.earliest.as_u64();
let latest = self.latest.as_u64();
if latest > earliest {
let span = latest - earliest;
(time_ms as f64 - earliest as f64) / span as f64 * f64::from(width)
} else {
0.0
}
}
fn collect_daily(
earliest: UnixMs,
latest: UnixMs,
span_ms: u64,
start: DateTime<chrono::Utc>,
end: DateTime<chrono::Utc>,
timezone: UserTimezone,
width: f32,
label_char_w: f32,
) -> Vec<u64> {
let start_ms = timezone
.start_of_local_day_utc_ms(start.timestamp_millis() as u64)
.unwrap_or(0);
let end_ms = end.timestamp_millis() as u64;
let skip_ms = boundary_skip_ms(DAY_MS, span_ms, width, 6.0 * label_char_w);
let start_ms = timezone
.align_to_skip_grid(start_ms, skip_ms / DAY_MS)
.unwrap_or(start_ms);
collect_boundaries(start_ms, end_ms, earliest, latest, skip_ms, |ts| {
timezone.next_local_day_utc_ms(ts)
})
}
fn collect_monthly(
earliest: UnixMs,
latest: UnixMs,
span_ms: u64,
start: DateTime<chrono::Utc>,
end: DateTime<chrono::Utc>,
timezone: UserTimezone,
width: f32,
label_char_w: f32,
) -> Vec<u64> {
let start_ms = timezone
.start_of_local_month_utc_ms(start.timestamp_millis() as u64)
.unwrap_or(0);
let end_ms = end.timestamp_millis() as u64;
let skip_ms = boundary_skip_ms(MONTH_MS, span_ms, width, 6.0 * label_char_w);
let start_ms = timezone
.align_to_month_grid(start_ms, skip_ms / MONTH_MS)
.unwrap_or(start_ms);
collect_boundaries(start_ms, end_ms, earliest, latest, skip_ms, |ts| {
timezone.next_local_month_utc_ms(ts)
})
}
fn collect_yearly(
earliest: UnixMs,
latest: UnixMs,
span_ms: u64,
start: DateTime<chrono::Utc>,
end: DateTime<chrono::Utc>,
timezone: UserTimezone,
width: f32,
label_char_w: f32,
) -> Vec<u64> {
let start_ms = timezone
.start_of_local_year_utc_ms(start.timestamp_millis() as u64)
.unwrap_or(0);
let end_ms = end.timestamp_millis() as u64;
let skip_ms = boundary_skip_ms(YEAR_MS, span_ms, width, 8.0 * label_char_w);
let start_ms = timezone
.align_to_year_grid(start_ms, skip_ms / YEAR_MS)
.unwrap_or(start_ms);
collect_boundaries(start_ms, end_ms, earliest, latest, skip_ms, |ts| {
timezone.next_local_year_utc_ms(ts)
})
}
fn day_anchored_ticks(
earliest: UnixMs,
latest: UnixMs,
span_ms: u64,
step_ms: u64,
timezone: UserTimezone,
width: f32,
label_char_w: f32,
) -> Vec<u64> {
if step_ms == 0 {
return Vec::new();
}
let e = earliest.as_u64();
let l = latest.as_u64();
if l < e {
return Vec::new();
}
let stride_ms = intraday_stride(step_ms, span_ms, width, label_char_w);
if stride_ms >= MAX_LOCAL_DAY_MS {
let first = e.div_ceil(stride_ms).saturating_mul(stride_ms);
let mut out = Vec::new();
let mut t = first;
while t <= l {
out.push(t);
let Some(next) = t.checked_add(stride_ms) else {
break;
};
t = next;
}
return out;
}
let mut out = Vec::new();
let mut day_start = timezone.start_of_local_day_utc_ms(e).unwrap_or(e);
while day_start <= l {
let Some(next_day) = timezone.next_local_day_utc_ms(day_start) else {
break;
};
let first = day_start.max(e);
let k = first.saturating_sub(day_start).div_ceil(stride_ms);
let mut t = day_start.saturating_add(k.saturating_mul(stride_ms));
while t < next_day && t <= l {
out.push(t);
let Some(next) = t.checked_add(stride_ms) else {
break;
};
t = next;
}
day_start = next_day;
}
out
}
}
fn collect_boundaries(
mut current: u64,
end_ms: u64,
earliest: UnixMs,
latest: UnixMs,
skip_ms: u64,
next: impl Fn(u64) -> Option<u64>,
) -> Vec<u64> {
let mut out = Vec::with_capacity(MAX_GRID_LINES.min(64));
let mut guard = 0;
while current <= end_ms && guard < MAX_GRID_LINES {
if current >= earliest.as_u64() && current <= latest.as_u64() {
out.push(current);
}
guard += 1;
let Some(mut next_val) = next(current) else {
break;
};
if next_val <= current {
break;
}
let target = current.saturating_add(skip_ms);
while next_val < target {
let Some(after) = next(next_val) else {
break;
};
if after <= next_val {
break;
}
next_val = after;
}
current = next_val;
}
out
}
fn boundary_skip_ms(nominal_ms: u64, span_ms: u64, width: f32, spacing_px: f32) -> u64 {
if width <= 0.0 || spacing_px <= 0.0 {
return 0;
}
let span = span_ms.max(1) as f64;
let px_per_boundary = (nominal_ms as f64 / span) * f64::from(width);
let count = (f64::from(spacing_px) / px_per_boundary).ceil() as u64;
nominal_ms.saturating_mul(count.max(1))
}
fn calc_step(
span_ms: u64,
labels_can_fit: i32,
timeframe: Timeframe,
width: f32,
label_char_w: f32,
) -> u64 {
TimeSteps::from(timeframe).pick_closest(
span_ms,
labels_can_fit.max(1) as u32,
width,
label_char_w,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeTickTier {
Main,
Secondary,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TimeTickLabel {
pub x_pos: f32,
pub content: String,
pub tier: TimeTickTier,
}
impl TimeTickLabel {
pub fn for_range(
earliest: UnixMs,
latest: UnixMs,
span_ms: u64,
width: f32,
label_char_w: f32,
labels_can_fit: i32,
timeframe: Timeframe,
timezone: crate::UserTimezone,
) -> Vec<TimeTickLabel> {
let grid = TimeAxisGrid::new(
earliest,
latest,
span_ms,
labels_can_fit,
timeframe,
timezone,
width,
label_char_w,
);
if grid.marks.is_empty() {
return Vec::new();
}
let selected = grid.select_marks(width, label_char_w);
let max_weight = selected.iter().map(|m| m.weight).max();
let has_finer_tier = max_weight.is_some_and(|max| selected.iter().any(|m| m.weight < max));
let mut labels = Vec::with_capacity(selected.len());
for mark in selected {
let Some(content) = mark.format_tick(timeframe, timezone) else {
continue;
};
let tier = if has_finer_tier && Some(mark.weight) == max_weight {
TimeTickTier::Secondary
} else {
TimeTickTier::Main
};
labels.push(TimeTickLabel {
x_pos: grid.x_position(mark.time_ms, width) as f32,
content,
tier,
});
}
labels
}
}