use super::context::{ChartMapping, LinearPriceMap, RenderContext, StyleColors};
use crate::model::Bar;
use crate::scales::{
PriceMarkGenerator, PriceScaleId, PriceScaleMode, TickMark, TickMarkGenerator,
TickMarkGeneratorConfig, TickMarkWeight, TimeFormatter,
};
use crate::styles::typography;
use crate::tokens::DESIGN_TOKENS;
use chrono::Duration;
use egui::{Color32, FontId, Pos2, Rect};
pub fn render_price_labels(
context: &RenderContext,
price_scale: &LinearPriceMap,
colors: &StyleColors,
scale_mode: PriceScaleMode,
) {
render_price_labels_at_pos(
context,
price_scale,
colors,
scale_mode,
PriceScaleId::Right,
);
}
pub fn render_price_labels_at_pos(
context: &RenderContext,
price_scale: &LinearPriceMap,
colors: &StyleColors,
scale_mode: PriceScaleMode,
position: PriceScaleId,
) {
let generator = PriceMarkGenerator::new();
let marks = generator.generate_marks(
price_scale.min_price,
price_scale.max_price,
context.rect.height(),
scale_mode,
context.rect.min.y,
context.rect.max.y,
);
let (x_offset, alignment) = match position {
PriceScaleId::Right => (context.rect.max.x + 8.0, egui::Align2::LEFT_CENTER),
PriceScaleId::Left => (context.rect.min.x - 8.0, egui::Align2::RIGHT_CENTER),
};
for mark in marks {
let font_size = if mark.weight >= 80 {
11.5 } else if mark.weight >= 60 {
11.0 } else {
10.5 };
context.painter.text(
Pos2::new(x_offset, mark.y_coord),
alignment,
&mark.label,
FontId::proportional(font_size),
colors.text,
);
}
}
fn generate_time_marks(
context: &RenderContext,
visible_data: &[Bar],
coords: &ChartMapping,
formatter: Option<&dyn TimeFormatter>,
) -> (Vec<TickMark>, Vec<(chrono::DateTime<chrono::Utc>, usize)>) {
if visible_data.is_empty() {
return (Vec::new(), Vec::new());
}
let mut deltas_ms: Vec<i64> = visible_data
.windows(2)
.filter_map(|pair| {
let delta = (pair[1].time - pair[0].time).num_milliseconds().abs();
if delta > 0 { Some(delta) } else { None }
})
.collect();
let bar_duration_ms = if deltas_ms.is_empty() {
60_000
} else {
deltas_ms.sort_unstable();
deltas_ms[deltas_ms.len() / 2]
};
let zoom_factor = (coords.bar_spacing / 8.0).clamp(0.5, 2.5);
let min_spacing = (100.0 / zoom_factor).clamp(60.0, 140.0);
let max_marks = ((context.rect.width() / min_spacing).ceil() as usize + 2).clamp(6, 24);
let config = TickMarkGeneratorConfig {
min_spacing,
max_marks,
show_subseconds: bar_duration_ms < 1000,
use_24_hour: true,
target_density: (1.0 * zoom_factor).clamp(0.6, 2.5),
};
let generator = if let Some(fmt) = formatter {
TickMarkGenerator::with_formatter(config, fmt.clone_box())
} else {
TickMarkGenerator::with_config(config)
};
let bar_duration_ms = bar_duration_ms.max(1);
let (Some(first), Some(last)) = (visible_data.first(), visible_data.last()) else {
return (Vec::new(), Vec::new());
};
let first_visible_time = first.time;
let last_visible_time = last.time;
let first_visible_idx = coords.start_idx;
let last_visible_idx = coords.start_idx + visible_data.len().saturating_sub(1);
let extra_left = (coords.start_idx as i64).min(10); let extra_right = coords.right_offset.ceil().max(0.0) as i64 + 2;
let start_time =
first_visible_time - Duration::milliseconds(bar_duration_ms.saturating_mul(extra_left));
let end_time =
last_visible_time + Duration::milliseconds(bar_duration_ms.saturating_mul(extra_right));
let mut bars =
Vec::with_capacity(extra_left as usize + visible_data.len() + extra_right as usize);
for i in (1..=extra_left).rev() {
let time = first_visible_time - Duration::milliseconds(bar_duration_ms.saturating_mul(i));
let index = first_visible_idx.saturating_sub(i as usize);
bars.push((time, index));
}
for (i, bar) in visible_data.iter().enumerate() {
bars.push((bar.time, coords.start_idx + i));
}
for i in 1..=extra_right {
let time = last_visible_time + Duration::milliseconds(bar_duration_ms.saturating_mul(i));
let index = last_visible_idx.saturating_add(i as usize);
bars.push((time, index));
}
let marks = generator.generate_marks(start_time, end_time, context.rect.width(), &bars);
(marks, bars)
}
fn time_to_idx(
time: chrono::DateTime<chrono::Utc>,
bars: &[(chrono::DateTime<chrono::Utc>, usize)],
) -> f32 {
if bars.is_empty() {
return 0.0;
}
if time <= bars[0].0 {
return bars[0].1 as f32;
}
let last = bars.len() - 1;
if time >= bars[last].0 {
return bars[last].1 as f32;
}
let mut lo = 0usize;
let mut hi = last;
while lo + 1 < hi {
let mid = (lo + hi) / 2;
if bars[mid].0 <= time {
lo = mid;
} else {
hi = mid;
}
}
let (t0, i0) = bars[lo];
let (t1, i1) = bars[hi];
let span_ms = (t1 - t0).num_milliseconds() as f32;
if span_ms.abs() < f32::EPSILON {
return i0 as f32;
}
let offset_ms = (time - t0).num_milliseconds() as f32;
let frac = (offset_ms / span_ms).clamp(0.0, 1.0);
i0 as f32 + frac * (i1 as f32 - i0 as f32)
}
pub fn render_time_labels(
context: &RenderContext,
visible_data: &[Bar],
coords: &ChartMapping,
colors: &StyleColors,
formatter: Option<&dyn TimeFormatter>,
) {
let (marks, bars) = generate_time_marks(context, visible_data, coords, formatter);
let min_label_spacing = 80.0; let mut last_label_x: Option<f32> = None;
for mark in marks {
let index = time_to_idx(mark.time, &bars);
let delta_from_right = coords.base_idx as f32 + coords.right_offset - index;
let x = context.rect.min.x + context.rect.width()
- (delta_from_right + 0.5) * coords.bar_spacing
- 1.0;
if x < context.rect.min.x || x > context.rect.max.x {
continue;
}
if let Some(last_x) = last_label_x
&& (x - last_x).abs() < min_label_spacing
{
continue;
}
let y = context.rect.max.y + 5.0;
context.painter.text(
Pos2::new(x, y),
egui::Align2::CENTER_TOP,
&mark.label,
FontId::proportional(if mark.weight >= TickMarkWeight::MONTH {
11.0
} else if mark.weight >= TickMarkWeight::DAY {
10.5
} else {
10.0
}),
colors.text,
);
last_label_x = Some(x);
}
}
pub fn render_ohlc_info(
painter: &egui::Painter,
rect: Rect,
visible_data: &[Bar],
padding: f32,
text_color: Color32,
) {
if visible_data.is_empty() {
return;
}
let candle = &visible_data[visible_data.len() - 1];
let info = format!(
"open: {:.8} close: {:.8} high: {:.8} low: {:.8} 24h volume: {:.2}",
candle.open, candle.close, candle.high, candle.low, candle.volume
);
painter.text(
Pos2::new(rect.min.x + padding, rect.min.y + 10.0),
egui::Align2::LEFT_TOP,
info,
FontId::proportional(typography::SM),
text_color,
);
}
pub fn render_legend(
painter: &egui::Painter,
rect: Rect,
symbol: &str,
timeframe: &str,
visible_data: &[Bar],
prev_close: Option<f64>,
colors: &StyleColors,
padding: f32,
) {
if visible_data.is_empty() {
return;
}
let candle = &visible_data[visible_data.len() - 1];
let is_bullish = candle.close >= candle.open;
let val_color = if is_bullish {
colors.bullish
} else {
colors.bearish
};
let mut x = rect.min.x + padding;
let y = rect.min.y + 12.0;
let symbol_font = FontId::proportional(typography::MD);
let symbol_galley =
painter.layout_no_wrap(symbol.to_string(), symbol_font.clone(), colors.text);
painter.galley(Pos2::new(x, y - 2.0), symbol_galley.clone(), colors.text);
x += symbol_galley.rect.width() + 8.0;
let sep_font = FontId::proportional(typography::SM);
let sep = painter.layout_no_wrap("•".to_string(), sep_font.clone(), Color32::GRAY);
painter.galley(Pos2::new(x, y), sep.clone(), Color32::GRAY);
x += sep.rect.width() + 8.0;
let tf_galley = painter.layout_no_wrap(timeframe.to_string(), sep_font.clone(), colors.text);
painter.galley(Pos2::new(x, y), tf_galley.clone(), colors.text);
x += tf_galley.rect.width() + 20.0;
let format_price = |price: f64| -> String {
if price >= 100.0 {
format!("{price:.2}")
} else if price >= 1.0 {
format!("{price:.4}")
} else {
format!("{price:.6}")
}
};
let label_font = FontId::proportional(typography::SM);
let val_font = FontId::proportional(typography::SM);
let label_color = DESIGN_TOKENS.semantic.extended.disabled;
let o_label = painter.layout_no_wrap("O ".to_string(), label_font.clone(), label_color);
painter.galley(Pos2::new(x, y), o_label.clone(), label_color);
x += o_label.rect.width();
let o_val = painter.layout_no_wrap(format_price(candle.open), val_font.clone(), val_color);
painter.galley(Pos2::new(x, y), o_val.clone(), val_color);
x += o_val.rect.width() + 12.0;
let h_label = painter.layout_no_wrap("H ".to_string(), label_font.clone(), label_color);
painter.galley(Pos2::new(x, y), h_label.clone(), label_color);
x += h_label.rect.width();
let h_val = painter.layout_no_wrap(format_price(candle.high), val_font.clone(), val_color);
painter.galley(Pos2::new(x, y), h_val.clone(), val_color);
x += h_val.rect.width() + 12.0;
let l_label = painter.layout_no_wrap("L ".to_string(), label_font.clone(), label_color);
painter.galley(Pos2::new(x, y), l_label.clone(), label_color);
x += l_label.rect.width();
let l_val = painter.layout_no_wrap(format_price(candle.low), val_font.clone(), val_color);
painter.galley(Pos2::new(x, y), l_val.clone(), val_color);
x += l_val.rect.width() + 12.0;
let c_label = painter.layout_no_wrap("C ".to_string(), label_font.clone(), label_color);
painter.galley(Pos2::new(x, y), c_label.clone(), label_color);
x += c_label.rect.width();
let c_val = painter.layout_no_wrap(format_price(candle.close), val_font.clone(), val_color);
painter.galley(Pos2::new(x, y), c_val.clone(), val_color);
x += c_val.rect.width() + 16.0;
let reference_price = prev_close.unwrap_or(candle.open);
let change = candle.close - reference_price;
let change_pct = if reference_price != 0.0 {
(change / reference_price) * 100.0
} else {
0.0
};
let change_color = if change >= 0.0 {
colors.bullish
} else {
colors.bearish
};
let sign = if change >= 0.0 { "+" } else { "" };
let change_text = format!("{sign}{change:.2} ({sign}{change_pct:.2}%)");
let change_galley = painter.layout_no_wrap(change_text, val_font, change_color);
painter.galley(Pos2::new(x, y), change_galley, change_color);
}