use crate::ext::HasDesignTokens;
use crate::model::Bar;
use crate::studies::{Indicator, IndicatorValue};
use crate::styles::typography;
use crate::tokens::DESIGN_TOKENS;
use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui};
use std::ops::Range;
mod layout {
pub const LEFT_PADDING: f32 = 40.0;
pub const RIGHT_PADDING: f32 = 80.0;
pub const MIN_GRID_SPACING: f32 = 80.0;
}
mod indicator_sizing {
pub const OSCILLATOR_HEIGHT: f32 = 120.0;
pub const MULTI_LINE_HEIGHT: f32 = 150.0;
}
#[derive(Debug, Clone)]
pub struct IndicatorPaneConfig {
pub height: f32,
pub y_range: Option<(f64, f64)>,
pub show_zones: bool,
pub zones: Vec<(f64, Color32, &'static str)>,
pub show_legend: bool,
pub show_grid: bool,
}
impl Default for IndicatorPaneConfig {
fn default() -> Self {
Self {
height: DESIGN_TOKENS.sizing.panel.bottom_default_height,
y_range: None,
show_zones: false,
zones: Vec::new(),
show_legend: true,
show_grid: true,
}
}
}
impl IndicatorPaneConfig {
pub fn rsi() -> Self {
Self {
height: indicator_sizing::OSCILLATOR_HEIGHT,
y_range: Some((0.0, 100.0)),
show_zones: true,
zones: vec![
(70.0, DESIGN_TOKENS.semantic.extended.bearish, "Overbought"),
(50.0, DESIGN_TOKENS.semantic.extended.chart_text, "Neutral"),
(30.0, DESIGN_TOKENS.semantic.extended.bullish, "Oversold"),
],
show_legend: true,
show_grid: true,
}
}
pub fn macd() -> Self {
Self {
height: indicator_sizing::MULTI_LINE_HEIGHT,
y_range: None, show_zones: true,
zones: vec![(0.0, DESIGN_TOKENS.semantic.extended.chart_text, "Zero Line")],
show_legend: true,
show_grid: true,
}
}
pub fn stochastic() -> Self {
Self {
height: indicator_sizing::OSCILLATOR_HEIGHT,
y_range: Some((0.0, 100.0)),
show_zones: true,
zones: vec![
(80.0, DESIGN_TOKENS.semantic.extended.bearish, "Overbought"),
(20.0, DESIGN_TOKENS.semantic.extended.bullish, "Oversold"),
],
show_legend: true,
show_grid: true,
}
}
}
pub use crate::chart::coords::ChartMapping;
#[derive(Clone, Copy, Debug)]
pub struct IndicatorCoordParams {
pub bar_spacing: f32,
pub right_offset: f32,
pub base_idx: usize,
pub start_idx: usize,
}
impl IndicatorCoordParams {
pub fn new(bar_spacing: f32, right_offset: f32, base_idx: usize, start_idx: usize) -> Self {
Self {
bar_spacing,
right_offset,
base_idx,
start_idx,
}
}
pub fn to_mapping(&self, rect: Rect, y_min: f64, y_max: f64) -> ChartMapping {
ChartMapping::new(
rect,
self.bar_spacing,
self.start_idx,
self.base_idx,
self.right_offset,
y_min,
y_max,
)
}
}
pub struct PaneInteraction {
pub panel_rect: Rect,
pub chart_rect: Rect,
pub y_min: f64,
pub y_max: f64,
pub response: Response,
pub close_response: Option<Response>,
}
pub struct IndicatorPane {
config: IndicatorPaneConfig,
}
impl IndicatorPane {
pub fn with_config(config: IndicatorPaneConfig) -> Self {
Self { config }
}
pub fn show(
&mut self,
ui: &mut Ui,
indicator: &dyn Indicator,
bars: &[Bar],
visible_range: Range<usize>,
) {
if !indicator.is_visible() {
return;
}
let chart_bg = ui.chart_bg();
let text_color = ui.style().visuals.text_color();
let grid_color = ui.chart_grid();
let bullish = ui.bullish_color();
let bearish = ui.bearish_color();
let available_width = ui.available_width();
let panel_size = egui::vec2(available_width, self.config.height);
let (rect, _response) = ui.allocate_exact_size(panel_size, Sense::hover());
if !ui.is_rect_visible(rect) {
return;
}
let painter = ui.painter();
painter.rect_filled(rect, 0.0, chart_bg);
let left_padding = layout::LEFT_PADDING;
let right_padding = layout::RIGHT_PADDING;
let chart_rect = Rect::from_min_max(
Pos2::new(rect.min.x + left_padding, rect.min.y),
Pos2::new(rect.max.x - right_padding, rect.max.y),
);
let (y_min, y_max) = self.calculate_y_range(indicator, &visible_range);
if self.config.show_zones && self.config.zones.len() >= 2 {
self.draw_zone_fills(painter, chart_rect, y_min, y_max, bullish, bearish);
}
self.draw_horizontal_lines(
painter, chart_rect, y_min, y_max, grid_color, bullish, bearish,
);
self.draw_indicator_lines(
painter,
chart_rect,
indicator,
bars,
&visible_range,
y_min,
y_max,
);
self.draw_y_axis_labels(painter, rect, chart_rect, y_min, y_max, text_color);
self.draw_legend(painter, chart_rect, indicator, text_color, None, false);
}
pub fn show_aligned(
&mut self,
ui: &mut Ui,
indicator: &dyn Indicator,
_bars: &[Bar],
visible_range: Range<usize>,
coords: IndicatorCoordParams,
) {
if !indicator.is_visible() {
return;
}
let chart_bg = ui.chart_bg();
let text_color = ui.style().visuals.text_color();
let grid_color = ui.chart_grid();
let bullish = ui.bullish_color();
let bearish = ui.bearish_color();
let available_width = ui.available_width();
let panel_size = egui::vec2(available_width, self.config.height);
let (rect, _response) = ui.allocate_exact_size(panel_size, Sense::hover());
if !ui.is_rect_visible(rect) {
return;
}
let painter = ui.painter();
painter.rect_filled(rect, 0.0, chart_bg);
let left_padding = layout::LEFT_PADDING;
let right_padding = layout::RIGHT_PADDING;
let chart_rect = Rect::from_min_max(
Pos2::new(rect.min.x + left_padding, rect.min.y),
Pos2::new(rect.max.x - right_padding, rect.max.y),
);
let (y_min, y_max) = self.calculate_y_range(indicator, &visible_range);
if self.config.show_zones && self.config.zones.len() >= 2 {
self.draw_zone_fills(painter, chart_rect, y_min, y_max, bullish, bearish);
}
self.draw_horizontal_lines(
painter, chart_rect, y_min, y_max, grid_color, bullish, bearish,
);
if self.config.show_grid {
self.draw_vertical_grid_aligned(painter, chart_rect, &coords, grid_color);
}
self.draw_indicator_lines_aligned(
painter,
chart_rect,
indicator,
&visible_range,
y_min,
y_max,
&coords,
);
self.draw_y_axis_labels(painter, rect, chart_rect, y_min, y_max, text_color);
self.draw_legend(painter, chart_rect, indicator, text_color, None, false);
}
pub fn show_aligned_interactive(
&mut self,
ui: &mut Ui,
indicator: &dyn Indicator,
_bars: &[Bar],
visible_range: Range<usize>,
coords: IndicatorCoordParams,
) -> Option<PaneInteraction> {
if !indicator.is_visible() {
return None;
}
let chart_bg = ui.chart_bg();
let text_color = ui.style().visuals.text_color();
let grid_color = ui.chart_grid();
let bullish = ui.bullish_color();
let bearish = ui.bearish_color();
let available_width = ui.available_width();
let panel_size = egui::vec2(available_width, self.config.height);
let (rect, response) = ui.allocate_exact_size(panel_size, Sense::click());
if !ui.is_rect_visible(rect) {
return None;
}
let painter = ui.painter();
painter.rect_filled(rect, 0.0, chart_bg);
let left_padding = layout::LEFT_PADDING;
let right_padding = layout::RIGHT_PADDING;
let chart_rect = Rect::from_min_max(
Pos2::new(rect.min.x + left_padding, rect.min.y),
Pos2::new(rect.max.x - right_padding, rect.max.y),
);
let (y_min, y_max) = self.calculate_y_range(indicator, &visible_range);
if self.config.show_zones && self.config.zones.len() >= 2 {
self.draw_zone_fills(painter, chart_rect, y_min, y_max, bullish, bearish);
}
self.draw_horizontal_lines(
painter, chart_rect, y_min, y_max, grid_color, bullish, bearish,
);
if self.config.show_grid {
self.draw_vertical_grid_aligned(painter, chart_rect, &coords, grid_color);
}
self.draw_indicator_lines_aligned(
painter,
chart_rect,
indicator,
&visible_range,
y_min,
y_max,
&coords,
);
self.draw_y_axis_labels(painter, rect, chart_rect, y_min, y_max, text_color);
let close_rect = self.legend_close_rect(ui.painter(), chart_rect, indicator);
let close_response = close_rect
.map(|cr| ui.interact(cr, response.id.with("indicator_pane_close"), Sense::click()));
let close_hovered = close_response.as_ref().is_some_and(|r| r.hovered());
if close_hovered {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
self.draw_legend(
ui.painter(),
chart_rect,
indicator,
text_color,
close_rect,
close_hovered,
);
Some(PaneInteraction {
panel_rect: rect,
chart_rect,
y_min,
y_max,
response,
close_response,
})
}
fn calculate_y_range(
&self,
indicator: &dyn Indicator,
visible_range: &Range<usize>,
) -> (f64, f64) {
if let Some((min, max)) = self.config.y_range {
return (min, max);
}
let values = indicator.values();
let mut min_val = f64::MAX;
let mut max_val = f64::MIN;
for i in visible_range.clone() {
if let Some(value) = values.get(i) {
match value {
IndicatorValue::Single(v) => {
min_val = min_val.min(*v);
max_val = max_val.max(*v);
}
IndicatorValue::Multiple(vals) => {
for v in vals {
min_val = min_val.min(*v);
max_val = max_val.max(*v);
}
}
IndicatorValue::None => {}
}
}
}
if min_val == f64::MAX {
(0.0, 100.0) } else {
let range = max_val - min_val;
let padding = range * 0.1;
(min_val - padding, max_val + padding)
}
}
fn draw_zone_fills(
&self,
painter: &egui::Painter,
rect: Rect,
y_min: f64,
y_max: f64,
bullish: Color32,
bearish: Color32,
) {
for (level, color, label) in &self.config.zones {
let live_color = if *color == DESIGN_TOKENS.semantic.extended.bullish {
bullish
} else if *color == DESIGN_TOKENS.semantic.extended.bearish {
bearish
} else {
continue; };
let y = self.value_to_y(*level, rect, y_min, y_max);
let fill_color = Color32::from_rgba_unmultiplied(
live_color.r(),
live_color.g(),
live_color.b(),
25, );
if label.contains("Overbought") || label.contains("above") {
let fill_rect =
Rect::from_min_max(Pos2::new(rect.min.x, rect.min.y), Pos2::new(rect.max.x, y));
painter.rect_filled(fill_rect, 0.0, fill_color);
} else if label.contains("Oversold") || label.contains("below") {
let fill_rect =
Rect::from_min_max(Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, rect.max.y));
painter.rect_filled(fill_rect, 0.0, fill_color);
}
}
}
fn draw_horizontal_lines(
&self,
painter: &egui::Painter,
rect: Rect,
y_min: f64,
y_max: f64,
grid_color: Color32,
bullish: Color32,
bearish: Color32,
) {
if self.config.show_zones {
for (level, color, _label) in &self.config.zones {
let live_color = if *color == DESIGN_TOKENS.semantic.extended.bullish {
bullish
} else if *color == DESIGN_TOKENS.semantic.extended.bearish {
bearish
} else {
*color
};
let y = self.value_to_y(*level, rect, y_min, y_max);
if y >= rect.min.y && y <= rect.max.y {
self.draw_dashed_line(
painter,
Pos2::new(rect.min.x, y),
Pos2::new(rect.max.x, y),
live_color,
1.0,
5.0,
3.0,
);
}
}
}
if self.config.show_grid {
let range = y_max - y_min;
let step = self.calculate_nice_step(range, 5);
let mut y_val = (y_min / step).ceil() * step;
while y_val <= y_max {
let y = self.value_to_y(y_val, rect, y_min, y_max);
if y >= rect.min.y && y <= rect.max.y {
painter.line_segment(
[Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)],
Stroke::new(DESIGN_TOKENS.stroke.extra_thin, grid_color),
);
}
y_val += step;
}
}
}
fn draw_vertical_grid_aligned(
&self,
painter: &egui::Painter,
rect: Rect,
coords: &IndicatorCoordParams,
grid_color: Color32,
) {
let mapping = coords.to_mapping(rect, 0.0, 1.0);
let min_pixel_spacing = layout::MIN_GRID_SPACING;
let bars_per_grid = (min_pixel_spacing / coords.bar_spacing).ceil().max(1.0) as usize;
let bars_per_grid = Self::nice_interval(bars_per_grid);
let chart_width = rect.width();
let visible_bars = (chart_width / coords.bar_spacing).ceil() as usize + 2;
let start_idx = coords.start_idx;
let first_visible_idx = if start_idx > 0 {
(start_idx / bars_per_grid) * bars_per_grid
} else {
0
};
for i in 0..=(visible_bars / bars_per_grid + 2) {
let grid_idx = first_visible_idx + i * bars_per_grid;
let x = mapping.idx_to_x(grid_idx);
if x < rect.min.x - 1.0 || x > rect.max.x + 1.0 {
continue;
}
painter.line_segment(
[Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)],
Stroke::new(DESIGN_TOKENS.stroke.extra_thin, grid_color),
);
}
}
fn nice_interval(raw: usize) -> usize {
if raw <= 1 {
return 1;
}
if raw <= 2 {
return 2;
}
if raw <= 5 {
return 5;
}
if raw <= 10 {
return 10;
}
if raw <= 20 {
return 20;
}
if raw <= 50 {
return 50;
}
if raw <= 100 {
return 100;
}
if raw <= 200 {
return 200;
}
if raw <= 500 {
return 500;
}
1000
}
fn draw_indicator_lines(
&self,
painter: &egui::Painter,
rect: Rect,
indicator: &dyn Indicator,
bars: &[Bar],
visible_range: &Range<usize>,
y_min: f64,
y_max: f64,
) {
let values = indicator.values();
let colors = indicator.colors();
let line_cnt = indicator.line_cnt();
let visible_bars = visible_range.end.saturating_sub(visible_range.start);
if visible_bars == 0 {
return;
}
let bar_spacing = rect.width() / visible_bars as f32;
for line_idx in 0..line_cnt {
let color = colors.get(line_idx).copied().unwrap_or(Color32::WHITE);
let mut points = Vec::new();
for i in visible_range.clone() {
if i >= bars.len() || i >= values.len() {
break;
}
let val = match &values[i] {
IndicatorValue::Single(v) => Some(*v),
IndicatorValue::Multiple(vals) => vals.get(line_idx).copied(),
IndicatorValue::None => None,
};
if let Some(v) = val {
let local_idx = i - visible_range.start;
let x = rect.min.x + (local_idx as f32 + 0.5) * bar_spacing;
let y = self.value_to_y(v, rect, y_min, y_max);
if x >= rect.min.x && x <= rect.max.x {
points.push(Pos2::new(x, y));
}
}
}
if points.len() > 1 {
for i in 0..points.len() - 1 {
painter.line_segment(
[points[i], points[i + 1]],
Stroke::new(DESIGN_TOKENS.stroke.medium, color),
);
}
}
}
}
fn draw_indicator_lines_aligned(
&self,
painter: &egui::Painter,
rect: Rect,
indicator: &dyn Indicator,
visible_range: &Range<usize>,
y_min: f64,
y_max: f64,
coords: &IndicatorCoordParams,
) {
let values = indicator.values();
let colors = indicator.colors();
let line_cnt = indicator.line_cnt();
let mapping = coords.to_mapping(rect, y_min, y_max);
for line_idx in 0..line_cnt {
let color = colors.get(line_idx).copied().unwrap_or(Color32::WHITE);
let mut points = Vec::new();
for i in visible_range.clone() {
if i >= values.len() {
break;
}
let val = match &values[i] {
IndicatorValue::Single(v) => Some(*v),
IndicatorValue::Multiple(vals) => vals.get(line_idx).copied(),
IndicatorValue::None => None,
};
if let Some(v) = val {
let x = mapping.idx_to_x(i);
let y = self.value_to_y(v, rect, y_min, y_max);
if x >= rect.min.x && x <= rect.max.x {
points.push(Pos2::new(x, y));
}
}
}
if points.len() > 1 {
for i in 0..points.len() - 1 {
painter.line_segment(
[points[i], points[i + 1]],
Stroke::new(DESIGN_TOKENS.stroke.medium, color),
);
}
}
}
}
fn draw_y_axis_labels(
&self,
painter: &egui::Painter,
full_rect: Rect,
chart_rect: Rect,
y_min: f64,
y_max: f64,
text_color: Color32,
) {
let range = y_max - y_min;
let step = self.calculate_nice_step(range, 5);
let label_x = chart_rect.max.x + DESIGN_TOKENS.spacing.sm;
let mut y_val = (y_min / step).ceil() * step;
while y_val <= y_max {
let y = self.value_to_y(y_val, chart_rect, y_min, y_max);
if y >= full_rect.min.y + 10.0 && y <= full_rect.max.y - 10.0 {
let label = format!("{y_val:.2}");
painter.text(
Pos2::new(label_x, y),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(typography::XS),
text_color,
);
}
y_val += step;
}
}
fn legend_text(&self, indicator: &dyn Indicator) -> String {
let values = indicator.values();
let mut legend_parts = vec![indicator.name().to_string()];
if let Some(last_value) = values.last() {
match last_value {
IndicatorValue::Single(v) => {
legend_parts.push(format!("{v:.2}"));
}
IndicatorValue::Multiple(vals) => {
for v in vals {
legend_parts.push(format!("{v:.2}"));
}
}
IndicatorValue::None => {}
}
}
legend_parts.join(" ")
}
fn legend_close_rect(
&self,
painter: &egui::Painter,
rect: Rect,
indicator: &dyn Indicator,
) -> Option<Rect> {
if !self.config.show_legend {
return None;
}
let galley = painter.layout_no_wrap(
self.legend_text(indicator),
egui::FontId::proportional(typography::SM),
Color32::WHITE,
);
let text_left = rect.min.x + DESIGN_TOKENS.spacing.sm;
let center_y = rect.min.y + DESIGN_TOKENS.spacing.xl;
let glyph_size = typography::SM;
let center = Pos2::new(
text_left + galley.size().x + DESIGN_TOKENS.spacing.md,
center_y,
);
let pad = DESIGN_TOKENS.spacing.sm;
Some(Rect::from_center_size(
center,
egui::vec2(glyph_size + pad, glyph_size + pad),
))
}
fn draw_legend(
&self,
painter: &egui::Painter,
rect: Rect,
indicator: &dyn Indicator,
text_color: Color32,
close_rect: Option<Rect>,
close_hovered: bool,
) {
if !self.config.show_legend {
return;
}
painter.text(
Pos2::new(
rect.min.x + DESIGN_TOKENS.spacing.sm,
rect.min.y + DESIGN_TOKENS.spacing.xl,
),
egui::Align2::LEFT_CENTER,
self.legend_text(indicator),
egui::FontId::proportional(typography::SM),
text_color,
);
if let Some(close_rect) = close_rect {
let glyph_color = if close_hovered {
text_color
} else {
Color32::from_rgba_unmultiplied(text_color.r(), text_color.g(), text_color.b(), 140)
};
painter.text(
close_rect.center(),
egui::Align2::CENTER_CENTER,
"\u{00d7}",
egui::FontId::proportional(typography::SM),
glyph_color,
);
}
}
fn value_to_y(&self, value: f64, rect: Rect, y_min: f64, y_max: f64) -> f32 {
let norm = (value - y_min) / (y_max - y_min);
rect.max.y - (norm as f32 * rect.height())
}
fn calculate_nice_step(&self, range: f64, target_lines: usize) -> f64 {
let raw_step = range / target_lines as f64;
let magnitude = 10f64.powf(raw_step.log10().floor());
let normalized = raw_step / magnitude;
let nice = if normalized <= 1.0 {
1.0
} else if normalized <= 2.0 {
2.0
} else if normalized <= 5.0 {
5.0
} else {
10.0
};
nice * magnitude
}
fn draw_dashed_line(
&self,
painter: &egui::Painter,
from: Pos2,
to: Pos2,
color: Color32,
width: f32,
dash_length: f32,
gap_length: f32,
) {
let dir = to - from;
let len = dir.length();
if len == 0.0 {
return;
}
let dir = dir / len;
let mut pos = 0.0;
let stroke = Stroke::new(width, color);
while pos < len {
let start = from + dir * pos;
let end_pos = (pos + dash_length).min(len);
let end = from + dir * end_pos;
painter.line_segment([start, end], stroke);
pos += dash_length + gap_length;
}
}
}