use super::context::{ChartMapping, RenderContext};
use crate::config::SessionBreakStyle;
use crate::model::{Bar, SessionBreakType, SessionProvider, Timeframe, find_session_breaks};
use egui::{Color32, Pos2, Stroke};
pub fn provider_for_timeframe(timeframe: Timeframe) -> Box<dyn SessionProvider> {
use crate::model::{DailySessionProvider, MonthlySessionProvider, WeeklySessionProvider};
let day_ms = Timeframe::Day1.duration_ms();
let ms = timeframe.duration_ms();
if ms < day_ms {
Box::new(DailySessionProvider::continuous())
} else if ms == day_ms {
Box::new(WeeklySessionProvider::default())
} else {
Box::new(MonthlySessionProvider)
}
}
fn dash_pattern(style: SessionBreakStyle) -> Option<(f32, f32)> {
match style {
SessionBreakStyle::Solid => None,
SessionBreakStyle::Dashed => Some((6.0, 4.0)),
SessionBreakStyle::Dotted => Some((2.0, 3.0)),
}
}
#[derive(Debug, Clone)]
pub struct SessionBreakRenderConfig {
pub line_color: Color32,
pub line_width: f32,
pub style: SessionBreakStyle,
}
impl Default for SessionBreakRenderConfig {
fn default() -> Self {
Self {
line_color: Color32::from_gray(80),
line_width: 1.0,
style: SessionBreakStyle::Dashed,
}
}
}
pub struct SessionBreakRenderer {
config: SessionBreakRenderConfig,
}
impl SessionBreakRenderer {
pub fn new(config: SessionBreakRenderConfig) -> Self {
Self { config }
}
pub fn render(
&self,
ctx: &RenderContext,
visible_data: &[Bar],
provider: &dyn SessionProvider,
mapping: &ChartMapping,
start_idx: usize,
) {
if visible_data.len() < 2 {
return;
}
for (local_idx, session_break) in find_session_breaks(visible_data, provider) {
let x = mapping.idx_to_x(start_idx + local_idx);
if !mapping.is_x_visible(x) {
continue;
}
let (color, width) = self.style_for(session_break.break_type);
let from = Pos2::new(x, ctx.rect.min.y);
let to = Pos2::new(x, ctx.rect.max.y);
match dash_pattern(self.config.style) {
None => {
ctx.painter
.line_segment([from, to], Stroke::new(width, color));
}
Some((dash, gap)) => {
ctx.painter.add(egui::Shape::dashed_line(
&[from, to],
Stroke::new(width, color),
dash,
gap,
));
}
}
}
}
fn style_for(&self, break_type: SessionBreakType) -> (Color32, f32) {
let base = self.config.line_color;
match break_type {
SessionBreakType::Daily => (base.gamma_multiply(0.6), self.config.line_width),
SessionBreakType::Weekly => (base.gamma_multiply(0.8), self.config.line_width * 1.5),
SessionBreakType::Monthly => (base, self.config.line_width * 2.0),
SessionBreakType::Custom => (base.gamma_multiply(0.7), self.config.line_width * 1.2),
}
}
}
pub struct SessionBackgroundRenderer {
colors: (Color32, Color32),
}
impl SessionBackgroundRenderer {
pub fn from_background(background: Color32) -> Self {
Self {
colors: (Color32::TRANSPARENT, background.gamma_multiply(0.94)),
}
}
pub fn render(
&self,
ctx: &RenderContext,
visible_data: &[Bar],
provider: &dyn SessionProvider,
mapping: &ChartMapping,
start_idx: usize,
) {
let breaks = find_session_breaks(visible_data, provider);
let mut even = true;
let mut prev_x = ctx.rect.min.x;
for (local_idx, _) in breaks {
let x = mapping
.idx_to_x(start_idx + local_idx)
.clamp(ctx.rect.min.x, ctx.rect.max.x);
self.fill_span(ctx, prev_x, x, even);
even = !even;
prev_x = x;
}
self.fill_span(ctx, prev_x, ctx.rect.max.x, even);
}
fn fill_span(&self, ctx: &RenderContext, x0: f32, x1: f32, even: bool) {
if x1 <= x0 {
return;
}
let color = if even { self.colors.0 } else { self.colors.1 };
if color == Color32::TRANSPARENT {
return;
}
let rect =
egui::Rect::from_min_max(Pos2::new(x0, ctx.rect.min.y), Pos2::new(x1, ctx.rect.max.y));
ctx.painter.rect_filled(rect, 0.0, color);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::DailySessionProvider;
use chrono::{TimeZone, Utc};
fn bar_at(year: i32, month: u32, day: u32, hour: u32) -> Bar {
Bar {
time: Utc.with_ymd_and_hms(year, month, day, hour, 0, 0).unwrap(),
open: 100.0,
high: 101.0,
low: 99.0,
close: 100.5,
volume: 1000.0,
}
}
#[test]
fn provider_tier_is_one_step_coarser_than_bars() {
let p = provider_for_timeframe(Timeframe::Min1);
assert_eq!(p.name(), "Daily Sessions");
let p = provider_for_timeframe(Timeframe::Hour4);
assert_eq!(p.name(), "Daily Sessions");
let p = provider_for_timeframe(Timeframe::Day1);
assert_eq!(p.name(), "Weekly Sessions");
let p = provider_for_timeframe(Timeframe::Week1);
assert_eq!(p.name(), "Monthly Sessions");
let p = provider_for_timeframe(Timeframe::Month1);
assert_eq!(p.name(), "Monthly Sessions");
}
#[test]
fn two_days_yield_one_break_at_first_bar_of_day_two() {
let bars = vec![
bar_at(2024, 1, 1, 9),
bar_at(2024, 1, 1, 12),
bar_at(2024, 1, 1, 15),
bar_at(2024, 1, 2, 9),
bar_at(2024, 1, 2, 12),
bar_at(2024, 1, 2, 15),
];
let provider = DailySessionProvider::continuous();
let breaks = find_session_breaks(&bars, &provider);
assert_eq!(breaks.len(), 1, "exactly one day boundary");
assert_eq!(breaks[0].0, 3, "break at first bar of day two");
assert_eq!(breaks[0].1.break_type, SessionBreakType::Daily);
}
#[test]
fn single_session_in_view_has_no_breaks() {
let bars = vec![
bar_at(2024, 1, 1, 9),
bar_at(2024, 1, 1, 12),
bar_at(2024, 1, 1, 15),
];
let provider = DailySessionProvider::continuous();
assert!(find_session_breaks(&bars, &provider).is_empty());
}
#[test]
fn solid_style_has_no_dash_pattern() {
assert!(dash_pattern(SessionBreakStyle::Solid).is_none());
assert!(dash_pattern(SessionBreakStyle::Dashed).is_some());
assert!(dash_pattern(SessionBreakStyle::Dotted).is_some());
}
}