use crate::chart::cursor_modes::CursorModeState;
use crate::chart::indicators::{
SelectionDotConfig, hit_test_indicator, hit_test_pane_indicator,
render_indicator_selection_dots,
};
use crate::chart::renderers::{self, ChartMapping, PriceScale, RenderContext, StyleColors};
use crate::chart::selection::{ChartElementId, SelectionState, SeriesId};
use crate::chart::series::{
SelectionHandleConfig, SeriesSettings, calculate_dot_interval, hit_test_candles,
hit_test_volume, render_candle_selection_dots,
};
use crate::config::{BackgroundStyle, ChartConfig, ChartOptions, WatermarkPos};
use crate::drawings::DrawingManager;
use crate::model::ChartState;
use crate::model::ChartType;
use crate::scales::TimeFormatterBuilder;
use crate::studies::IndicatorRegistry;
use crate::validation::DataValidator;
pub mod indicator_pane;
use crate::styles::{sizing, typography};
use crate::tokens::DESIGN_TOKENS;
use egui::{Pos2, Rect, Response, Sense, Ui, Vec2};
pub use indicator_pane::{
IndicatorCoordParams, IndicatorPane, IndicatorPaneConfig, PaneInteraction,
};
use crate::chart::{helpers, rendering, state};
pub mod builder;
pub use helpers::{apply_price_zoom, y_to_price};
pub use state::{BoxZoomMode, BoxZoomState, ElasticBounceState, KineticScrollState};
pub struct Chart {
pub state: ChartState,
pub config: ChartConfig,
pub chart_options: ChartOptions,
pub(crate) start_idx: usize,
pub(crate) desired_visible_bars: Option<usize>,
pub(crate) last_visible_bars: usize,
pub(crate) apply_visible_bars_once: bool,
pub(crate) kinetic_scroll: KineticScrollState,
pub(crate) scroll_start_pos: Option<Pos2>,
pub(crate) scroll_start_offset: Option<f32>,
pub(crate) prev_width: Option<f32>,
pub(crate) price_scale_drag_start: Option<Pos2>,
pub(crate) pending_start_idx: Option<usize>,
pub(crate) chart_type: ChartType,
pub(crate) renko_brick_size: f64,
pub(crate) kagi_reversal_amount: f64,
pub(crate) tracking_mode_active: bool,
pub(crate) mouse_in_chart: bool,
pub(crate) validator: Option<DataValidator>,
pub(crate) box_zoom: BoxZoomState,
pub(crate) zoom_mode_active: bool,
pub(crate) zoom_just_applied: bool,
pub(crate) symbol: String,
pub(crate) timeframe: String,
#[doc(hidden)]
pub cursor_modes: CursorModeState,
pub(crate) last_rendered_price_range: (f64, f64),
#[doc(hidden)]
pub last_rendered_price_rect: Rect,
pub(crate) last_rendered_volume_rect: Rect,
pub(crate) last_rendered_indicator_panes: Vec<RenderedIndicatorPane>,
pub(crate) synced_crosshair_bar_idx: Option<f64>,
pub(crate) last_hover_bar_idx: Option<f64>,
pub marks: Vec<crate::model::Marker>,
pub timescale_marks: Vec<crate::model::Marker>,
pub(crate) selection: SelectionState<ChartElementId>,
pub(crate) right_click: Option<RightClickTarget>,
pub(crate) indicator_remove: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum RightClickTarget {
Element {
id: ChartElementId,
bar_idx: usize,
pos: Pos2,
price: f64,
},
Drawing {
drawing_id: usize,
pos: Pos2,
},
Background {
pos: Pos2,
price: f64,
},
}
#[derive(Clone, Debug)]
pub struct RenderedIndicatorPane {
pub indicator_idx: usize,
pub panel_rect: Rect,
pub chart_rect: Rect,
pub y_min: f64,
pub y_max: f64,
pub coords: IndicatorCoordParams,
}
#[derive(Clone, Copy, Debug)]
pub struct ChartLayoutRects {
pub widget_rect: Rect,
pub price_rect: Rect,
pub volume_rect: Rect,
pub legend_rect: Rect,
}
impl Default for ChartLayoutRects {
fn default() -> Self {
Self {
widget_rect: Rect::NOTHING,
price_rect: Rect::NOTHING,
volume_rect: Rect::NOTHING,
legend_rect: Rect::NOTHING,
}
}
}
impl Chart {
pub fn calculate_layout_rects(&self, widget_rect: Rect) -> ChartLayoutRects {
let bottom_padding = if self.config.show_time_labels {
30.0
} else {
20.0
};
let top_padding = if self.config.show_ohlc_info {
40.0
} else {
20.0
};
let right_padding = self.config.padding * 2.0;
let legend_rect = if self.config.show_ohlc_info {
Rect::from_min_size(
widget_rect.min + Vec2::new(self.config.padding, 4.0),
Vec2::new(widget_rect.width() * 0.7, top_padding - 8.0),
)
} else {
Rect::NOTHING
};
let chart_rect = Rect::from_min_size(
widget_rect.min + Vec2::new(self.config.padding, top_padding),
Vec2::new(
widget_rect.width() - self.config.padding - right_padding,
widget_rect.height() - top_padding - bottom_padding,
),
);
let (price_rect, volume_rect) = if self.config.show_volume {
let split_y =
chart_rect.min.y + chart_rect.height() * (1.0 - self.config.volume_height_fraction);
(
Rect::from_min_max(chart_rect.min, Pos2::new(chart_rect.max.x, split_y)),
Rect::from_min_max(Pos2::new(chart_rect.min.x, split_y), chart_rect.max),
)
} else {
(chart_rect, Rect::NOTHING)
};
ChartLayoutRects {
widget_rect,
price_rect,
volume_rect,
legend_rect,
}
}
pub fn set_zoom_mode(&mut self, active: bool) {
self.zoom_mode_active = active;
}
pub fn zoom_was_applied(&self) -> bool {
self.zoom_just_applied
}
pub fn set_symbol(&mut self, symbol: &str) {
self.symbol = symbol.to_string();
}
pub fn set_timeframe_label(&mut self, timeframe: &str) {
self.timeframe = timeframe.to_string();
}
pub fn set_crosshair_style(&mut self, style: crate::config::CrosshairStyle) {
self.chart_options.crosshair.style = style;
}
pub fn apply_series_settings(&mut self, settings: &SeriesSettings) {
self.config.bullish_color = settings.bullish_color;
self.config.bearish_color = settings.bearish_color;
self.config.bullish_border_color = settings.bullish_border_color;
self.config.bearish_border_color = settings.bearish_border_color;
self.config.bullish_wick_color = settings.bullish_wick_color;
self.config.bearish_wick_color = settings.bearish_wick_color;
self.config.price_source = settings.price_source;
}
fn draw_background(&self, painter: &egui::Painter, rect: Rect) {
if self.config.skip_background {
return;
}
match self.config.background_style {
BackgroundStyle::Solid => {
painter.rect_filled(rect, 0.0, self.config.background_color);
}
BackgroundStyle::VerticalGradient {
top_color,
bottom_color,
} => {
let mesh = egui::Mesh {
indices: vec![0, 1, 2, 2, 3, 0],
vertices: vec![
egui::epaint::Vertex {
pos: rect.left_top(),
uv: egui::epaint::WHITE_UV,
color: top_color,
},
egui::epaint::Vertex {
pos: rect.right_top(),
uv: egui::epaint::WHITE_UV,
color: top_color,
},
egui::epaint::Vertex {
pos: rect.right_bottom(),
uv: egui::epaint::WHITE_UV,
color: bottom_color,
},
egui::epaint::Vertex {
pos: rect.left_bottom(),
uv: egui::epaint::WHITE_UV,
color: bottom_color,
},
],
texture_id: egui::TextureId::default(),
};
painter.add(egui::Shape::mesh(mesh));
}
BackgroundStyle::HorizontalGradient {
left_color,
right_color,
} => {
let mesh = egui::Mesh {
indices: vec![0, 1, 2, 2, 3, 0],
vertices: vec![
egui::epaint::Vertex {
pos: rect.left_top(),
uv: egui::epaint::WHITE_UV,
color: left_color,
},
egui::epaint::Vertex {
pos: rect.right_top(),
uv: egui::epaint::WHITE_UV,
color: right_color,
},
egui::epaint::Vertex {
pos: rect.right_bottom(),
uv: egui::epaint::WHITE_UV,
color: right_color,
},
egui::epaint::Vertex {
pos: rect.left_bottom(),
uv: egui::epaint::WHITE_UV,
color: left_color,
},
],
texture_id: egui::TextureId::default(),
};
painter.add(egui::Shape::mesh(mesh));
}
}
}
fn draw_watermark(&self, painter: &egui::Painter, rect: Rect) {
if !self.config.show_watermark {
return;
}
let text = self.config.watermark_text.as_deref().unwrap_or_else(|| {
if self.symbol.is_empty() {
"SYMBOL"
} else {
&self.symbol
}
});
let font_id = egui::FontId::proportional(self.config.watermark_font_size);
let pos = match self.config.watermark_pos {
WatermarkPos::Center => rect.center(),
WatermarkPos::TopLeft => Pos2::new(
rect.min.x + 20.0,
rect.min.y + self.config.watermark_font_size,
),
WatermarkPos::TopRight => Pos2::new(
rect.max.x - 20.0,
rect.min.y + self.config.watermark_font_size,
),
WatermarkPos::BottomLeft => Pos2::new(rect.min.x + 20.0, rect.max.y - 20.0),
WatermarkPos::BottomRight => Pos2::new(rect.max.x - 20.0, rect.max.y - 20.0),
};
let anchor = match self.config.watermark_pos {
WatermarkPos::Center => egui::Align2::CENTER_CENTER,
WatermarkPos::TopLeft => egui::Align2::LEFT_TOP,
WatermarkPos::TopRight => egui::Align2::RIGHT_TOP,
WatermarkPos::BottomLeft => egui::Align2::LEFT_BOTTOM,
WatermarkPos::BottomRight => egui::Align2::RIGHT_BOTTOM,
};
painter.text(pos, anchor, text, font_id, self.config.watermark_color);
}
pub fn show_with_drawings(
&mut self,
ui: &mut Ui,
drawing_manager: Option<&mut DrawingManager>,
) -> Response {
self.show_internal(ui, drawing_manager, None)
}
pub fn show_with_indicators(
&mut self,
ui: &mut Ui,
drawing_manager: Option<&mut DrawingManager>,
indicators: Option<&IndicatorRegistry>,
) -> Response {
self.last_rendered_indicator_panes.clear();
let indicator_pane_height = if let Some(indicators) = indicators {
let mut total_height = 0.0f32;
let mut pane_count = 0;
for indicator in indicators.indicators() {
if indicator.is_overlay() || !indicator.is_visible() {
continue;
}
pane_count += 1;
let height = match indicator.name() {
"RSI" => IndicatorPaneConfig::rsi().height,
"MACD" => IndicatorPaneConfig::macd().height,
"Stochastic" => IndicatorPaneConfig::stochastic().height,
_ => IndicatorPaneConfig::default().height,
};
total_height += height;
}
if pane_count > 0 {
total_height + 1.0 * pane_count as f32
} else {
0.0
}
} else {
0.0
};
let available = ui.available_size();
let main_chart_height = (available.y - indicator_pane_height).max(200.0);
let response = ui
.allocate_ui_with_layout(
egui::vec2(available.x, main_chart_height),
egui::Layout::top_down(egui::Align::LEFT),
|ui| self.show_internal(ui, drawing_manager, indicators),
)
.inner;
if let Some(indicators) = indicators {
let (start_idx, end_idx) = self.state.visible_range();
let visible_range = start_idx..end_idx;
let bars = &self.state.data().bars;
let time_scale = self.state.time_scale();
let coords = IndicatorCoordParams::new(
time_scale.bar_spacing(),
time_scale.right_offset(),
self.state.data().len().saturating_sub(1),
start_idx,
);
let mut has_pane_indicators = false;
for indicator in indicators.indicators() {
if indicator.is_overlay() || !indicator.is_visible() {
continue;
}
has_pane_indicators = true;
break;
}
if has_pane_indicators {
let mut pending_pane_selection: Option<Option<ChartElementId>> = None;
let mut pending_indicator_remove: Option<usize> = None;
let current_selection = self.selection.selected_id();
for (idx, indicator) in indicators.indicators().iter().enumerate() {
if indicator.is_overlay() || !indicator.is_visible() {
continue;
}
ui.add_space(DESIGN_TOKENS.spacing.hairline);
let config = match indicator.name() {
"RSI" => IndicatorPaneConfig::rsi(),
"MACD" => IndicatorPaneConfig::macd(),
"Stochastic" => IndicatorPaneConfig::stochastic(),
_ => IndicatorPaneConfig::default(),
};
let mut panel = IndicatorPane::with_config(config);
if let Some(interaction) = panel.show_aligned_interactive(
ui,
indicator.as_ref(),
bars,
visible_range.clone(),
coords,
) {
let PaneInteraction {
panel_rect,
chart_rect,
y_min,
y_max,
response: pane_response,
close_response,
} = interaction;
let close_clicked = close_response.is_some_and(|r| r.clicked());
if close_clicked {
pending_indicator_remove = Some(idx);
}
let pane_coords = coords.to_mapping(chart_rect, y_min, y_max);
if !close_clicked
&& pane_response.clicked()
&& let Some(click_pos) = pane_response.interact_pointer_pos()
{
let hit = hit_test_pane_indicator(
click_pos,
indicator.as_ref(),
idx,
visible_range.clone(),
chart_rect,
y_min,
y_max,
&pane_coords,
);
pending_pane_selection =
Some(hit.map(|h| ChartElementId::PaneIndicator(h.indicator_idx)));
}
if current_selection == Some(ChartElementId::PaneIndicator(idx)) {
let dot_config = SelectionDotConfig {
dot_interval: calculate_dot_interval(pane_coords.bar_spacing),
..Default::default()
};
for line_idx in 0..indicator.line_cnt() {
render_indicator_selection_dots(
ui.painter(),
indicator.as_ref(),
line_idx,
visible_range.clone(),
&pane_coords,
|value| pane_coords.price_to_y(value),
&dot_config,
);
}
}
self.last_rendered_indicator_panes
.push(RenderedIndicatorPane {
indicator_idx: idx,
panel_rect,
chart_rect,
y_min,
y_max,
coords,
});
}
}
if let Some(decision) = pending_pane_selection {
match decision {
Some(id) => self.selection.select(id, None),
None => self.selection.deselect(),
}
}
if pending_indicator_remove.is_some() {
self.indicator_remove = pending_indicator_remove;
}
}
}
response
}
pub fn show_with_indicators_plot(
&mut self,
ui: &mut Ui,
drawing_manager: Option<&mut DrawingManager>,
indicators: Option<&IndicatorRegistry>,
) -> Response {
let response = self.show_with_drawings(ui, drawing_manager);
if let Some(indicators) = indicators {
ui.separator();
let (start_idx, end_idx) = self.state.visible_range();
let visible_range = start_idx..end_idx;
let bars = &self.state.data().bars;
for indicator in indicators.indicators() {
if indicator.is_overlay() {
continue;
}
if !indicator.is_visible() {
continue;
}
let config = match indicator.name() {
"RSI" => IndicatorPaneConfig::rsi(),
"MACD" => IndicatorPaneConfig::macd(),
"Stochastic" => IndicatorPaneConfig::stochastic(),
_ => IndicatorPaneConfig::default(),
};
let mut panel = IndicatorPane::with_config(config);
panel.show(ui, indicator.as_ref(), bars, visible_range.clone());
}
}
response
}
pub fn show(&mut self, ui: &mut Ui) -> Response {
self.show_internal(ui, None, None)
}
fn resolve_session_timeframe(
&self,
visible_data: &[crate::model::Bar],
) -> crate::model::Timeframe {
use crate::model::Timeframe;
use std::str::FromStr;
if let Ok(tf) = Timeframe::from_str(self.timeframe.trim()) {
return tf;
}
if visible_data.len() < 2 {
return Timeframe::default();
}
let mut gaps_ms: Vec<i64> = visible_data
.windows(2)
.map(|w| (w[1].time - w[0].time).num_milliseconds())
.filter(|&g| g > 0)
.collect();
if gaps_ms.is_empty() {
return Timeframe::default();
}
gaps_ms.sort_unstable();
let median = gaps_ms[gaps_ms.len() / 2];
Timeframe::all()
.into_iter()
.min_by_key(|tf| (tf.duration_ms() - median).abs())
.unwrap_or_default()
}
pub(crate) fn show_internal(
&mut self,
ui: &mut Ui,
mut drawing_manager: Option<&mut DrawingManager>,
indicators: Option<&IndicatorRegistry>,
) -> Response {
let ctx = ui.ctx().clone();
let loaders_installed = egui::Id::new("egui_charts::image_loaders_installed");
if !ctx.data(|d| d.get_temp::<bool>(loaders_installed).unwrap_or(false)) {
egui_extras::install_image_loaders(&ctx);
ctx.data_mut(|d| d.insert_temp(loaders_installed, true));
}
self.zoom_just_applied = false;
self.right_click = None;
let available_size = ui.available_size();
let (mut response, painter) = ui.allocate_painter(available_size, Sense::click_and_drag());
let rect = response.rect;
let top_padding = if self.config.show_ohlc_info {
sizing::chart::TOP_PADDING_WITH_OHLC
} else {
sizing::chart::TOP_PADDING_NO_OHLC
};
let bottom_padding = if self.config.show_time_labels {
sizing::chart::BOTTOM_PADDING_WITH_TIME
} else {
sizing::chart::BOTTOM_PADDING_NO_TIME
};
let right_axis_width = sizing::chart::RIGHT_AXIS_WIDTH;
let left_margin = sizing::chart::PADDING;
let right_margin = sizing::chart::PADDING + right_axis_width;
let chart_rect = Rect::from_min_size(
rect.min + Vec2::new(left_margin, top_padding),
Vec2::new(
(rect.width() - left_margin - right_margin).max(sizing::chart::MIN_CHART_WIDTH),
(rect.height() - top_padding - bottom_padding).max(sizing::chart::MIN_CHART_HEIGHT),
),
);
let chart_width = chart_rect.width();
self.apply_timescale_config(chart_width);
self.handle_tracking_mode(ui, &response);
self.request_focus_if_needed(&mut response);
self.handle_keyboard_shortcuts(ui, &response, chart_width, chart_rect.min.x);
let logical_range = self.state.time_scale().visible_logical_range();
let visible_bars = logical_range.length().ceil() as usize;
self.last_visible_bars = visible_bars;
self.set_panning_cursor(ui, &response);
let price_axis_rect = Rect::from_min_max(
Pos2::new(chart_rect.max.x, chart_rect.min.y),
Pos2::new(rect.max.x, chart_rect.max.y),
);
let time_axis_rect = Rect::from_min_max(
Pos2::new(chart_rect.min.x, chart_rect.max.y),
Pos2::new(chart_rect.max.x, rect.max.y),
);
self.handle_double_click(&response, price_axis_rect, time_axis_rect);
let is_drawing_interaction = drawing_manager.as_ref().is_some_and(|dm| {
dm.active_tool.is_some() || dm.dragging_handle.is_some() || dm.curr_drawing.is_some()
});
let pending_price_zoom = self.handle_mouse_wheel(
ui,
&response,
chart_width,
chart_rect.min.x,
price_axis_rect,
);
self.handle_pinch_zoom(ui, &response, chart_width, chart_rect.min.x);
self.handle_drag_pan(
ui,
&response,
price_axis_rect,
time_axis_rect,
chart_rect.min.x,
is_drawing_interaction,
);
self.apply_kinetic_scroll(ui);
self.zoom_just_applied = self.handle_box_zoom(
ui,
&response,
chart_rect,
chart_width,
self.zoom_mode_active,
);
if self.zoom_just_applied {
log::info!("Zoom applied - chart will auto-deactivate zoom mode");
}
if self.zoom_mode_active && response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ZoomIn);
}
else if response.hovered() {
use crate::config::CrosshairStyle;
match self.chart_options.crosshair.style {
CrosshairStyle::Full => {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
CrosshairStyle::Dot => {
ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
}
CrosshairStyle::Arrow => {
ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
}
}
}
self.draw_background(&painter, rect);
self.draw_watermark(&painter, chart_rect);
if self.state.data().is_empty() {
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"No data available",
egui::FontId::proportional(typography::LG),
self.config.text_color,
);
return response;
}
let (price_rect, volume_rect) = if self.config.show_volume {
let split_y =
chart_rect.min.y + chart_rect.height() * (1.0 - self.config.volume_height_fraction);
(
Rect::from_min_max(chart_rect.min, Pos2::new(chart_rect.max.x, split_y)),
Rect::from_min_max(Pos2::new(chart_rect.min.x, split_y), chart_rect.max),
)
} else {
(chart_rect, Rect::ZERO)
};
let (start_idx, _end_idx) = self.state.visible_range();
self.start_idx = start_idx;
let near_live_edge = self.state.time_scale().right_offset() >= -1.5;
if !near_live_edge {
let btn_size = Vec2::new(
DESIGN_TOKENS.sizing.charts_ext.realtime_button_width,
DESIGN_TOKENS.sizing.button_md,
);
let btn_pos = Pos2::new(
price_rect.center().x - btn_size.x / 2.0,
price_rect.min.y + DESIGN_TOKENS.spacing.lg + DESIGN_TOKENS.spacing.xs,
);
let btn_rect = Rect::from_min_size(btn_pos, btn_size);
let btn_id = ui.id().with("jump_to_latest");
let btn_res = ui.interact(btn_rect, btn_id, egui::Sense::click());
if btn_res.clicked() {
self.state.time_scale_mut().scroll_to_realtime();
}
}
let (mut adjusted_min, mut adjusted_max) = self.state.price_range();
let (new_min, new_max) = self.apply_price_zoom(
pending_price_zoom,
&response,
chart_rect,
adjusted_min,
adjusted_max,
);
adjusted_min = new_min;
adjusted_max = new_max;
self.last_rendered_price_range = (adjusted_min, adjusted_max);
self.last_rendered_price_rect = price_rect;
self.last_rendered_volume_rect = volume_rect;
let visible_data = self.state.visible_data();
if visible_data.is_empty() {
return response;
}
let max_volume = if self.config.show_volume {
visible_data
.iter()
.map(|c| c.volume)
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(1.0)
} else {
1.0
};
if self.config.show_horizontal_grid {
rendering::render_grid(
&painter,
price_rect,
adjusted_min,
adjusted_max,
self.config.grid_color,
);
}
let bar_spacing = self.state.time_scale().bar_spacing();
let bar_width = bar_spacing * self.config.candle_width;
let price_ctx = RenderContext::new(&painter, price_rect);
let price_scale = PriceScale::new(adjusted_min, adjusted_max);
let coords = ChartMapping::new(
price_rect,
bar_spacing,
start_idx,
self.state.time_scale().base_idx(),
self.state.time_scale().right_offset(),
adjusted_min,
adjusted_max,
);
let colors = StyleColors {
bullish: self.config.bullish_color,
bearish: self.config.bearish_color,
text: self.config.text_color,
bullish_border: self.config.bullish_border_color,
bearish_border: self.config.bearish_border_color,
bullish_wick: self.config.bullish_wick_color,
bearish_wick: self.config.bearish_wick_color,
candle_border_width: self.config.candle_border_width,
};
let formatter = if self.config.show_time_labels || self.config.show_vertical_grid {
Some(
TimeFormatterBuilder::new()
.with_24_hour(true)
.with_seconds(true)
.with_timezone(self.chart_options.time_scale.timezone.clone())
.build(),
)
} else {
None
};
if self.config.show_vertical_grid {
rendering::render_vertical_grid(&painter, chart_rect, &coords, self.config.grid_color);
}
if self.config.show_session_breaks {
let session_tf = self.resolve_session_timeframe(visible_data);
let provider = renderers::provider_for_timeframe(session_tf);
let session_painter = painter.with_clip_rect(price_rect);
let session_ctx = RenderContext::new(&session_painter, price_rect);
let background =
renderers::SessionBackgroundRenderer::from_background(self.config.background_color);
background.render(
&session_ctx,
visible_data,
provider.as_ref(),
&coords,
start_idx,
);
let break_renderer =
renderers::SessionBreakRenderer::new(renderers::SessionBreakRenderConfig {
line_color: self.config.session_break_color,
line_width: 1.0,
style: self.config.session_break_style,
});
break_renderer.render(
&session_ctx,
visible_data,
provider.as_ref(),
&coords,
start_idx,
);
}
let chart_rect_width = chart_rect.width();
let idx_to_coord = |idx: usize, min_x: f32| -> f32 {
self.state
.time_scale()
.idx_to_coord(idx, min_x, chart_rect_width)
};
let clipped_painter = painter.with_clip_rect(chart_rect);
let clipped_price_ctx = RenderContext::new(&clipped_painter, price_rect);
let clipped_volume_ctx = RenderContext::new(&clipped_painter, volume_rect);
let render_ctx = rendering::CandleDataContext {
price_ctx: &clipped_price_ctx,
volume_ctx: &clipped_volume_ctx,
price_scale: &price_scale,
colors: &colors,
visible_data,
full_data: &self.state.data().bars,
start_idx,
};
let render_params = rendering::ChartTypeParams::new(
rendering::BarDimensions::new(bar_width, self.config.wick_width),
rendering::VolumeSettings::new(self.config.show_volume, max_volume),
rendering::JapaneseChartSettings::new(self.renko_brick_size, self.kagi_reversal_amount),
rendering::TradingColors::new(self.config.bullish_color, self.config.bearish_color),
rendering::CoordMapping::new(chart_rect.min.x),
self.config.price_source,
);
rendering::render_chart_type(self.chart_type, &render_ctx, &render_params, idx_to_coord);
if let Some(indicator_registry) = indicators {
renderers::IndicatorRenderer::render(
&price_ctx,
indicator_registry.indicators(),
visible_data,
&price_scale,
&coords,
);
}
let main_visible_range = start_idx..(start_idx + visible_data.len());
if response.clicked()
&& let Some(click_pos) = response.interact_pointer_pos()
{
let volume_coords = if self.config.show_volume {
Some(coords.with_rect(volume_rect))
} else {
None
};
let hit = hit_test_main_chart(
click_pos,
indicators,
&coords,
volume_coords.as_ref(),
&self.state.data().bars,
max_volume,
main_visible_range.clone(),
);
match hit {
Some((id, bar_idx)) => self.selection.select(id, Some(bar_idx)),
None => self.selection.deselect(),
}
}
if response.secondary_clicked()
&& let Some(click_pos) = response.interact_pointer_pos()
&& chart_rect.contains(click_pos)
{
let price = coords.y_to_price(click_pos.y);
let drawing_hit = drawing_manager
.as_deref()
.and_then(|dm| dm.hit_test(click_pos));
self.right_click = Some(if let Some(drawing_id) = drawing_hit {
self.selection.deselect();
if let Some(dm) = drawing_manager.as_deref_mut() {
dm.select(drawing_id);
}
RightClickTarget::Drawing {
drawing_id,
pos: click_pos,
}
} else {
let volume_coords = if self.config.show_volume {
Some(coords.with_rect(volume_rect))
} else {
None
};
let element_hit = hit_test_main_chart(
click_pos,
indicators,
&coords,
volume_coords.as_ref(),
&self.state.data().bars,
max_volume,
main_visible_range.clone(),
);
match element_hit {
Some((id, bar_idx)) => {
self.selection.select(id, Some(bar_idx));
RightClickTarget::Element {
id,
bar_idx,
pos: click_pos,
price,
}
}
None => {
self.selection.deselect();
RightClickTarget::Background {
pos: click_pos,
price,
}
}
}
});
}
self.render_main_chart_selection(
&painter,
indicators,
&coords,
visible_data,
main_visible_range,
);
if !self.marks.is_empty() {
renderers::render_markers(
&clipped_price_ctx,
&self.marks,
visible_data,
&price_scale,
&coords,
);
}
if self.config.show_right_axis {
rendering::render_price_labels(
&price_ctx,
&price_scale,
&colors,
crate::scales::PriceScaleMode::Normal,
);
}
if self.config.show_symbol_last_val
&& let Some(last) = visible_data.last()
{
rendering::render_last_price_line(
&painter,
price_rect,
last.close,
last.open,
adjusted_min,
adjusted_max,
self.config.bullish_color,
self.config.bearish_color,
self.config.show_right_axis,
);
}
if self.config.show_time_labels {
let chart_ctx = RenderContext::new(&painter, chart_rect);
rendering::render_time_labels(
&chart_ctx,
visible_data,
&coords,
&colors,
formatter.as_deref(),
);
}
if self.config.show_ohlc_info {
if !self.symbol.is_empty() {
let prev_close = if visible_data.len() >= 2 {
Some(visible_data[visible_data.len() - 2].close)
} else {
None
};
rendering::render_legend(
&painter,
rect,
&self.symbol,
&self.timeframe,
visible_data,
prev_close,
&colors,
sizing::chart::PADDING,
);
} else {
rendering::render_ohlc_info(
&painter,
rect,
visible_data,
sizing::chart::PADDING,
self.config.text_color,
);
}
}
if let Some(dm) = drawing_manager {
let timescale = self.state.time_scale().clone();
let last_close = visible_data.last().map(|b| b.close);
let mut cursor_modes = std::mem::take(&mut self.cursor_modes);
self.handle_drawings(
ui,
dm,
&mut cursor_modes,
&response,
price_rect,
adjusted_min,
adjusted_max,
&painter,
last_close,
×cale,
);
self.render_eraser_highlight(&painter, dm, &cursor_modes);
self.cursor_modes = cursor_modes;
}
if self.config.show_realtime_btn {
let btn_id = ui.id().with("jump_to_latest");
let btn_size = Vec2::new(
DESIGN_TOKENS.sizing.charts_ext.realtime_button_width,
DESIGN_TOKENS.sizing.button_md,
);
let btn_pos = Pos2::new(
price_rect.center().x - btn_size.x / 2.0,
price_rect.min.y + DESIGN_TOKENS.spacing.lg + DESIGN_TOKENS.spacing.xs,
);
let btn_rect = Rect::from_min_size(btn_pos, btn_size);
let btn_res = ui.interact(btn_rect, btn_id, egui::Sense::click());
rendering::render_realtime_btn(
&painter,
price_rect,
near_live_edge,
self.config.show_realtime_btn,
self.config.realtime_button_size,
self.config.realtime_button_pos,
self.config.realtime_button_color,
self.config.realtime_button_hover_color,
self.config.realtime_button_text_color,
self.config.realtime_button_text.as_deref(),
btn_res.hovered(),
);
}
if let Some(hover_pos) = response.hover_pos()
&& price_rect.contains(hover_pos)
{
self.last_hover_bar_idx = Some(coords.x_to_idx_f32(hover_pos.x) as f64);
rendering::render_crosshair_with_options(
&price_ctx,
hover_pos,
visible_data,
&price_scale,
&coords,
&self.chart_options.crosshair,
);
let tooltip_options = &self.chart_options.tooltip;
if tooltip_options.enabled
&& let Some(local_idx) = coords.local_idx_at_x(hover_pos.x, visible_data.len())
{
let candle = &visible_data[local_idx];
rendering::render_tooltip_with_options(
&price_ctx,
hover_pos,
candle,
tooltip_options,
&price_scale,
&coords,
visible_data,
);
}
} else {
self.last_hover_bar_idx = None;
{
if let Some(bar_idx) = self.synced_crosshair_bar_idx {
let x = coords.idx_to_x(bar_idx as usize);
if coords.is_x_visible(x) {
let center_y = price_rect.center().y;
let synced_pos = Pos2::new(x, center_y);
rendering::render_crosshair_with_options(
&price_ctx,
synced_pos,
visible_data,
&price_scale,
&coords,
&self.chart_options.crosshair,
);
}
}
}
}
rendering::render_box_zoom(&painter, &self.box_zoom);
crate::styles::focus::draw_focus_ring(ui, &response);
response
}
pub fn set_synced_crosshair_bar_idx(&mut self, bar_idx: Option<f64>) {
self.synced_crosshair_bar_idx = bar_idx;
}
pub fn get_hover_bar_idx(&self) -> Option<f64> {
self.last_hover_bar_idx
}
pub fn apply_synced_time_scale(&mut self, bar_spacing: f32, right_offset: f32) {
self.state.time_scale_mut().set_bar_spacing(bar_spacing);
self.state.time_scale_mut().set_right_offset(right_offset);
}
pub fn get_time_scale_state(&self) -> (f32, f32) {
(
self.state.time_scale().bar_spacing(),
self.state.time_scale().right_offset(),
)
}
pub fn get_chart_mapping(&self) -> ChartMapping {
ChartMapping::new(
self.last_rendered_price_rect,
self.state.time_scale().bar_spacing(),
self.start_idx,
self.state.time_scale().base_idx(),
self.state.time_scale().right_offset(),
self.last_rendered_price_range.0,
self.last_rendered_price_range.1,
)
}
pub fn selected_element(&self) -> Option<ChartElementId> {
self.selection.selected_id()
}
pub fn selected_bar(&self) -> Option<usize> {
self.selection.selected_bar()
}
pub fn clear_selection(&mut self) {
self.selection.deselect();
}
pub fn take_right_click(&mut self) -> Option<RightClickTarget> {
self.right_click.take()
}
pub fn take_indicator_remove(&mut self) -> Option<usize> {
self.indicator_remove.take()
}
pub fn config_mut(&mut self) -> &mut ChartConfig {
&mut self.config
}
fn render_main_chart_selection(
&self,
painter: &egui::Painter,
indicators: Option<&IndicatorRegistry>,
coords: &ChartMapping,
visible_data: &[crate::model::Bar],
visible_range: std::ops::Range<usize>,
) {
let Some(selected) = self.selection.selected_id() else {
return;
};
match selected {
ChartElementId::OverlayIndicator(idx) => {
let Some(registry) = indicators else { return };
let Some(indicator) = registry.indicators().get(idx) else {
return;
};
if !indicator.is_visible() || !indicator.is_overlay() {
return;
}
let config = SelectionDotConfig {
dot_interval: calculate_dot_interval(coords.bar_spacing),
..Default::default()
};
for line_idx in 0..indicator.line_cnt() {
render_indicator_selection_dots(
painter,
indicator.as_ref(),
line_idx,
visible_range.clone(),
coords,
|price| coords.price_to_y(price),
&config,
);
}
}
ChartElementId::Series(series_id) => {
let config = SelectionHandleConfig {
dot_interval: calculate_dot_interval(coords.bar_spacing),
..Default::default()
};
let closes: Vec<f64> = visible_data.iter().map(|b| b.close).collect();
match series_id {
SeriesId::VOLUME => {
let volume_coords = coords.with_rect(self.last_rendered_volume_rect);
let max_volume = visible_data
.iter()
.map(|b| b.volume)
.fold(0.0_f64, f64::max)
.max(1.0);
render_candle_selection_dots(
painter,
visible_range,
&volume_coords,
&closes,
|volume| {
let norm = (volume / max_volume) as f32;
volume_coords.rect.bottom() - norm * volume_coords.rect.height()
},
&config,
);
}
_ => {
render_candle_selection_dots(
painter,
visible_range,
coords,
&closes,
|price| coords.price_to_y(price),
&config,
);
}
}
}
ChartElementId::PaneIndicator(_) => {}
}
}
}
fn hit_test_main_chart(
click_pos: Pos2,
indicators: Option<&IndicatorRegistry>,
coords: &ChartMapping,
volume_coords: Option<&ChartMapping>,
bars: &[crate::model::Bar],
max_volume: f64,
visible_range: std::ops::Range<usize>,
) -> Option<(ChartElementId, usize)> {
if bars.is_empty() {
return None;
}
if let Some(registry) = indicators {
for (idx, indicator) in registry.indicators().iter().enumerate() {
if let Some(hit) = hit_test_indicator(
click_pos,
indicator.as_ref(),
idx,
visible_range.clone(),
coords,
|price| coords.price_to_y(price),
) {
return Some((
ChartElementId::OverlayIndicator(hit.indicator_idx),
hit.bar_idx,
));
}
}
}
if let Some(hit) = hit_test_candles(
click_pos,
bars,
visible_range.clone(),
coords,
|price| coords.price_to_y(price),
&crate::chart::series::HitTestConfig::default(),
) {
return Some((ChartElementId::Series(hit.series_id), hit.bar_idx));
}
if let Some(volume_coords) = volume_coords
&& let Some(hit) =
hit_test_volume(click_pos, bars, visible_range, volume_coords, max_volume)
{
return Some((ChartElementId::Series(hit.series_id), hit.bar_idx));
}
None
}
#[cfg(test)]
mod selection_tests {
use super::*;
use crate::model::Bar;
use crate::studies::{CustomIndicator, IndicatorValue};
use chrono::{TimeZone, Utc};
use egui::{Pos2, Rect, Vec2};
fn fixture() -> (ChartMapping, Vec<Bar>) {
let rect = Rect::from_min_size(Pos2::new(0.0, 0.0), Vec2::new(500.0, 400.0));
let bars: Vec<Bar> = (0..5)
.map(|i| {
let close = 10.0 + i as f64;
Bar::new(
Utc.timestamp_opt(i as i64, 0).unwrap(),
close - 0.4,
close + 0.5,
close - 0.5,
close + 0.4,
100.0,
)
})
.collect();
let mapping = ChartMapping::new(rect, 40.0, 0, bars.len() - 1, 0.0, 8.0, 16.0);
(mapping, bars)
}
fn overlay_at(values: Vec<IndicatorValue>) -> IndicatorRegistry {
let mut registry = IndicatorRegistry::new();
let captured = values.clone();
registry.add(Box::new(
CustomIndicator::new("TestLine", Box::new(move |_| captured.clone()))
.with_overlay(true),
));
registry.calculate_all(&[]);
registry
}
#[test]
fn click_on_empty_area_returns_none() {
let (mapping, bars) = fixture();
let pos = Pos2::new(mapping.idx_to_x(2), mapping.rect.min.y + 1.0);
let hit = hit_test_main_chart(pos, None, &mapping, None, &bars, 100.0, 0..bars.len());
assert!(hit.is_none());
}
#[test]
fn click_on_candle_selects_main_series() {
let (mapping, bars) = fixture();
let bar = &bars[2];
let mid_price = (bar.open + bar.close) / 2.0;
let pos = Pos2::new(mapping.idx_to_x(2), mapping.price_to_y(mid_price));
let hit = hit_test_main_chart(pos, None, &mapping, None, &bars, 100.0, 0..bars.len());
assert_eq!(hit, Some((ChartElementId::Series(SeriesId::MAIN), 2)));
}
#[test]
fn overlay_indicator_wins_over_series_at_same_point() {
let (mapping, bars) = fixture();
let mut values = vec![IndicatorValue::None; bars.len()];
values[2] = IndicatorValue::Single(bars[2].close);
values[3] = IndicatorValue::Single(bars[3].close);
let registry = overlay_at(values);
let pos = Pos2::new(mapping.idx_to_x(2), mapping.price_to_y(bars[2].close));
let hit = hit_test_main_chart(
pos,
Some(®istry),
&mapping,
None,
&bars,
100.0,
0..bars.len(),
);
assert_eq!(hit, Some((ChartElementId::OverlayIndicator(0), 2)));
}
#[test]
fn hidden_or_pane_indicator_does_not_steal_overlay_priority() {
let (mapping, bars) = fixture();
let mut values = vec![IndicatorValue::None; bars.len()];
values[2] = IndicatorValue::Single(bars[2].close);
let mut registry = IndicatorRegistry::new();
let captured = values.clone();
registry.add(Box::new(
CustomIndicator::new("Pane", Box::new(move |_| captured.clone())).with_overlay(false),
));
registry.calculate_all(&[]);
let bar = &bars[2];
let mid_price = (bar.open + bar.close) / 2.0;
let pos = Pos2::new(mapping.idx_to_x(2), mapping.price_to_y(mid_price));
let hit = hit_test_main_chart(
pos,
Some(®istry),
&mapping,
None,
&bars,
100.0,
0..bars.len(),
);
assert_eq!(hit, Some((ChartElementId::Series(SeriesId::MAIN), 2)));
}
#[test]
fn empty_data_returns_none() {
let (mapping, _) = fixture();
let pos = mapping.rect.center();
let hit = hit_test_main_chart(pos, None, &mapping, None, &[], 100.0, 0..0);
assert!(hit.is_none());
}
#[test]
fn take_indicator_remove_drains_once() {
use crate::model::BarData;
let mut chart = Chart::new(BarData::default());
assert_eq!(chart.take_indicator_remove(), None);
chart.indicator_remove = Some(2);
assert_eq!(chart.take_indicator_remove(), Some(2));
assert_eq!(chart.take_indicator_remove(), None);
}
}