Преглед на файлове

Custom rendering test (#3)

* initial commit for custom canvas graph

* increase the number of klines to fetch

* proper zoom and panning

* add volume bars, +responsivity for candlestick drawings

* fix volume bars' scaling calculation, +styling adjustments

* mesh grid implementation

* by lots of workarounds and cheating; add axes labels

* styling adj. for axes labels

* fix "laggy" axes labels

* add as new pane to main.rs

* add custom graph as new pane
Berke преди 1 година
родител
ревизия
29917f4e13
променени са 4 файла, в които са добавени 773 реда и са изтрити 8 реда
  1. 2 1
      src/charts.rs
  2. 694 0
      src/charts/custom_line.rs
  3. 1 1
      src/data_providers/binance/market_data.rs
  4. 76 6
      src/main.rs

+ 2 - 1
src/charts.rs

@@ -1,2 +1,3 @@
 pub mod candlesticks;
-pub mod heatmap;
+pub mod heatmap;
+pub mod custom_line;

+ 694 - 0
src/charts/custom_line.rs

@@ -0,0 +1,694 @@
+use std::collections::BTreeMap;
+use chrono::{DateTime, Utc, TimeZone, LocalResult, Duration, NaiveDateTime, Timelike};
+use iced::{
+    mouse, 
+    widget::canvas::{self, event::{self, Event}, 
+    stroke::Stroke, Cache, Geometry, Path, Canvas}, 
+    window, Color, Point, Rectangle, Renderer, Size, Theme, Vector, Element, Length
+};
+use iced::widget::{Column, Row, Container, Space};
+use crate::market_data::Kline;
+
+#[derive(Debug, Clone)]
+pub enum Message {
+    Translated(Vector),
+    Scaled(f32, Option<Vector>),
+    ChartBounds(f32, f32),
+}
+
+#[derive(Debug)]
+pub struct CustomLine {
+    mesh_cache: Cache,
+    candles_cache: Cache,
+    x_labels_cache: Cache,
+    y_labels_cache: Cache,
+    translation: Vector,
+    scaling: f32,
+    klines_raw: BTreeMap<DateTime<Utc>, (f32, f32, f32, f32, f32, f32)>,
+    autoscale: bool,
+    x_min_time: i64,
+    x_max_time: i64,
+    y_min_price: f32,
+    y_max_price: f32,
+    chart_width: f32,
+    chart_height: f32,
+}
+impl CustomLine {
+    const MIN_SCALING: f32 = 0.1;
+    const MAX_SCALING: f32 = 2.0;
+
+    pub fn new(klines: Vec<Kline>, _timeframe_in_minutes: i16) -> CustomLine {
+        let _size = window::Settings::default().size;
+        let mut klines_raw = BTreeMap::new();
+
+        for kline in klines {
+            let time = match Utc.timestamp_opt(kline.time as i64 / 1000, 0) {
+                LocalResult::Single(dt) => dt,
+                _ => continue, 
+            };
+            let buy_volume = kline.taker_buy_base_asset_volume;
+            let sell_volume = kline.volume - buy_volume;
+            klines_raw.insert(time, (kline.open, kline.high, kline.low, kline.close, buy_volume, sell_volume));
+        }
+    
+        CustomLine {
+            mesh_cache: canvas::Cache::default(),
+            candles_cache: canvas::Cache::default(),
+            x_labels_cache: canvas::Cache::default(),
+            y_labels_cache: canvas::Cache::default(),
+            klines_raw,
+            translation: Vector::default(),
+            scaling: 1.0,
+            autoscale: true,
+            x_min_time: 0,
+            x_max_time: 0,
+            y_min_price: 0.0,
+            y_max_price: 0.0,
+            chart_width: 0.0,
+            chart_height: 0.0,
+        }
+    }
+
+    pub fn insert_datapoint(&mut self, kline: Kline) {
+        let time = match Utc.timestamp_opt(kline.time as i64 / 1000, 0) {
+            LocalResult::Single(dt) => dt,
+            _ => return, 
+        };
+        let buy_volume = kline.taker_buy_base_asset_volume;
+        let sell_volume = kline.volume - buy_volume;
+        self.klines_raw.insert(time, (kline.open, kline.high, kline.low, kline.close, buy_volume, sell_volume));
+
+        self.render_start();
+    }
+    
+    pub fn render_start(&mut self) {
+        self.candles_cache.clear();
+
+        let latest: i64 = self.klines_raw.keys().last().map_or(0, |time| time.timestamp() - (self.translation.x*10.0) as i64);
+        let earliest: i64 = latest - (6400.0 / (self.scaling / (self.chart_width/800.0))) as i64;
+
+        let (visible_klines, highest, lowest, avg_body_height, _) = self.klines_raw.iter()
+            .filter(|(time, _)| {
+                let timestamp = time.timestamp();
+                timestamp >= earliest && timestamp <= latest
+            })
+            .fold((vec![], f32::MIN, f32::MAX, 0.0f32, 0.0f32), |(mut klines, highest, lowest, total_body_height, max_vol), (time, kline)| {
+                let body_height = (kline.0 - kline.3).abs();
+                klines.push((*time, *kline));
+                (
+                    klines,
+                    highest.max(kline.1),
+                    lowest.min(kline.2),
+                    total_body_height + body_height,
+                    max_vol.max(kline.4.max(kline.5)) 
+                )
+            });
+
+        if visible_klines.is_empty() {
+            return;
+        }
+        let avg_body_height = avg_body_height / visible_klines.len() as f32;
+        let (highest, lowest) = (highest + avg_body_height, lowest - avg_body_height);
+
+        if earliest != self.x_min_time || latest != self.x_max_time || lowest != self.y_min_price || highest != self.y_max_price {
+            self.x_labels_cache.clear();
+            self.y_labels_cache.clear();
+            self.mesh_cache.clear();
+        }
+
+        self.x_min_time = earliest;
+        self.x_max_time = latest;
+        self.y_min_price = lowest;
+        self.y_max_price = highest;
+    }
+
+    pub fn update(&mut self, message: Message) {
+        match message {
+            Message::Translated(translation) => {
+                if self.autoscale {
+                    self.translation.x = translation.x;
+                } else {
+                    self.translation = translation;
+                }
+
+                self.render_start();
+            }
+            Message::Scaled(scaling, translation) => {
+                self.scaling = scaling;
+                
+                if let Some(translation) = translation {
+                    if self.autoscale {
+                        self.translation.x = translation.x;
+                    } else {
+                        self.translation = translation;
+                    }
+                }
+
+                self.render_start();
+            }
+            Message::ChartBounds(width, height) => {
+                self.chart_width = width;
+                self.chart_height = height;
+            }
+        }
+    }
+
+    pub fn view(&self) -> Element<Message> {
+        let chart = Canvas::new(self)
+            .width(Length::FillPortion(10))
+            .height(Length::FillPortion(10));
+    
+        let axis_labels_x = Canvas::new(
+            AxisLabelXCanvas { 
+                labels_cache: &self.x_labels_cache, min: self.x_min_time, max: self.x_max_time 
+            })
+            .width(Length::FillPortion(10))
+            .height(Length::Fixed(30.0));
+    
+        let axis_labels_y = Canvas::new(
+            AxisLabelYCanvas { 
+                labels_cache: &self.y_labels_cache, min: self.y_min_price, max: self.y_max_price 
+            })
+            .width(Length::Fixed(60.0))
+            .height(Length::FillPortion(10));
+    
+        let empty_space = Container::new(Space::new(Length::Fixed(40.0), Length::Fixed(40.0)))
+            .width(Length::Fixed(60.0))
+            .height(Length::Fixed(30.0));
+    
+        let chart_and_y_labels = Row::new()
+            .push(chart)
+            .push(axis_labels_y)
+            .spacing(0);
+    
+        let bottom_row = Row::new()
+            .push(axis_labels_x)
+            .push(empty_space)
+            .spacing(0);
+    
+        let content = Column::new()
+            .push(chart_and_y_labels)
+            .push(bottom_row)
+            .spacing(0);
+    
+        content.into()
+    }
+}
+
+
+#[derive(Debug, Clone, Copy)]
+pub enum Interaction {
+    None,
+    Drawing,
+    Erasing,
+    Panning { translation: Vector, start: Point },
+}
+
+impl Default for Interaction {
+    fn default() -> Self {
+        Self::None
+    }
+}
+impl canvas::Program<Message> for CustomLine {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        interaction: &mut Interaction,
+        event: Event,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> (event::Status, Option<Message>) {        
+        if bounds.width != self.chart_width || bounds.height != self.chart_height {
+            return (event::Status::Ignored, Some(Message::ChartBounds(bounds.width, bounds.height)));
+        } 
+        
+        if let Event::Mouse(mouse::Event::ButtonReleased(_)) = event {
+            *interaction = Interaction::None;
+        }
+
+        let Some(cursor_position) = cursor.position_in(bounds) else {
+            return (event::Status::Ignored, None);
+        };
+
+        match event {
+            Event::Mouse(mouse_event) => match mouse_event {
+                mouse::Event::ButtonPressed(button) => {
+                    let message = match button {
+                        mouse::Button::Right => {
+                            *interaction = Interaction::Drawing;
+                            None
+                        }
+                        mouse::Button::Left => {
+                            *interaction = Interaction::Panning {
+                                translation: self.translation,
+                                start: cursor_position,
+                            };
+                            None
+                        }
+                        _ => None,
+                    };
+
+                    (event::Status::Captured, message)
+                }
+                mouse::Event::CursorMoved { .. } => {
+                    let message = match *interaction {
+                        Interaction::Drawing => None,
+                        Interaction::Erasing => None,
+                        Interaction::Panning { translation, start } => {
+                            Some(Message::Translated(
+                                translation
+                                    + (cursor_position - start)
+                                        * (1.0 / self.scaling),
+                            ))
+                        }
+                        Interaction::None => None,
+                    };
+
+                    let event_status = match interaction {
+                        Interaction::None => event::Status::Ignored,
+                        _ => event::Status::Captured,
+                    };
+
+                    (event_status, message)
+                }
+                mouse::Event::WheelScrolled { delta } => match delta {
+                    mouse::ScrollDelta::Lines { y, .. }
+                    | mouse::ScrollDelta::Pixels { y, .. } => {
+                        if y < 0.0 && self.scaling > Self::MIN_SCALING
+                            || y > 0.0 && self.scaling < Self::MAX_SCALING
+                        {
+                            //let old_scaling = self.scaling;
+
+                            let scaling = (self.scaling * (1.0 + y / 30.0))
+                                .clamp(
+                                    Self::MIN_SCALING,  // 0.1
+                                    Self::MAX_SCALING,  // 2.0
+                                );
+
+                            //let translation =
+                            //    if let Some(cursor_to_center) =
+                            //        cursor.position_from(bounds.center())
+                            //    {
+                            //        let factor = scaling - old_scaling;
+
+                            //        Some(
+                            //            self.translation
+                            //                - Vector::new(
+                            //                    cursor_to_center.x * factor
+                            //                        / (old_scaling
+                            //                            * old_scaling),
+                            //                    cursor_to_center.y * factor
+                            //                        / (old_scaling
+                            //                            * old_scaling),
+                            //                ),
+                            //        )
+                            //    } else {
+                            //        None
+                            //    };
+
+                            (
+                                event::Status::Captured,
+                                Some(Message::Scaled(scaling, None)),
+                            )
+                        } else {
+                            (event::Status::Captured, None)
+                        }
+                    }
+                },
+                _ => (event::Status::Ignored, None),
+            },
+            _ => (event::Status::Ignored, None),
+        }
+    }
+    
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        _theme: &Theme,
+        bounds: Rectangle,
+        _cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {    
+        let latest: i64 = self.klines_raw.keys().last().map_or(0, |time| time.timestamp() - (self.translation.x*10.0) as i64);
+        let earliest: i64 = latest - (6400.0 / (self.scaling / (bounds.width/800.0))) as i64;
+    
+        let (visible_klines, highest, lowest, avg_body_height, max_volume) = self.klines_raw.iter()
+            .filter(|(time, _)| {
+                let timestamp = time.timestamp();
+                timestamp >= earliest && timestamp <= latest
+            })
+            .fold((vec![], f32::MIN, f32::MAX, 0.0f32, 0.0f32), |(mut klines, highest, lowest, total_body_height, max_vol), (time, kline)| {
+                let body_height = (kline.0 - kline.3).abs();
+                klines.push((*time, *kline));
+                (
+                    klines,
+                    highest.max(kline.1),
+                    lowest.min(kline.2),
+                    total_body_height + body_height,
+                    max_vol.max(kline.4.max(kline.5)) 
+                )
+            });
+    
+        if visible_klines.is_empty() {
+            return vec![];
+        }
+    
+        let avg_body_height = avg_body_height / visible_klines.len() as f32;
+        let (highest, lowest) = (highest + avg_body_height, lowest - avg_body_height);
+        let y_range = highest - lowest;
+
+        let volume_area_height = bounds.height / 8.0; 
+        let candlesticks_area_height = bounds.height - volume_area_height;
+
+        let y_labels_can_fit = (bounds.height / 60.0) as i32;
+        let (step, rounded_lowest) = calculate_price_step(highest, lowest, y_labels_can_fit);
+
+        let x_labels_can_fit = (bounds.width / 100.0) as i32;
+        let (time_step, rounded_earliest) = calculate_time_step(earliest, latest, x_labels_can_fit);
+
+        let background = self.mesh_cache.draw(renderer, bounds.size(), |frame| {
+            frame.with_save(|frame| {
+                let latest_in_millis = latest * 1000; 
+                let earliest_in_millis = earliest * 1000; 
+
+                let mut time = rounded_earliest;
+                let latest_time = NaiveDateTime::from_timestamp(latest, 0);
+
+                while time <= latest_time {
+                    let time_in_millis = time.timestamp_millis();
+                    
+                    let x_position = ((time_in_millis - earliest_in_millis) as f64 / (latest_in_millis - earliest_in_millis) as f64) * bounds.width as f64;
+
+                    if x_position >= 0.0 && x_position <= bounds.width as f64 {
+                        let line = Path::line(
+                            Point::new(x_position as f32, 0.0), 
+                            Point::new(x_position as f32, bounds.height as f32)
+                        );
+                        frame.stroke(&line, Stroke::default().with_color(Color::from_rgba8(40, 40, 40, 1.0)).with_width(1.0))
+                    }
+                    
+                    time = time + time_step;
+                }
+            });
+            
+            frame.with_save(|frame| {
+                let mut y = rounded_lowest;
+
+                while y <= highest {
+                    let y_position = candlesticks_area_height - ((y - lowest) / y_range * candlesticks_area_height);
+                    let line = Path::line(
+                        Point::new(0.0, y_position), 
+                        Point::new(bounds.width as f32, y_position)
+                    );
+                    frame.stroke(&line, Stroke::default().with_color(Color::from_rgba8(40, 40, 40, 1.0)).with_width(1.0));
+                    y += step;
+                }
+            });
+        });
+
+        let candlesticks = 
+            self.candles_cache.draw(renderer, bounds.size(), |frame| {
+                for (time, (open, high, low, close, buy_volume, sell_volume)) in visible_klines {
+                    let x_position: f64 = ((time.timestamp() - earliest) as f64 / (latest - earliest) as f64) * bounds.width as f64;
+                    
+                    let y_open = candlesticks_area_height - ((open - lowest) / y_range * candlesticks_area_height);
+                    let y_high = candlesticks_area_height - ((high - lowest) / y_range * candlesticks_area_height);
+                    let y_low = candlesticks_area_height - ((low - lowest) / y_range * candlesticks_area_height);
+                    let y_close = candlesticks_area_height - ((close - lowest) / y_range * candlesticks_area_height);
+                    
+                    let color = if close >= open { Color::from_rgb8(81, 205, 160) } else { Color::from_rgb8(192, 80, 77) };
+
+                    let body = Path::rectangle(
+                        Point::new(x_position as f32 - (2.0 * self.scaling), y_open.min(y_close)), 
+                        Size::new(4.0 * self.scaling, (y_open - y_close).abs())
+                    );                    
+                    frame.fill(&body, color);
+                    
+                    let wick = Path::line(
+                        Point::new(x_position as f32, y_high), 
+                        Point::new(x_position as f32, y_low)
+                    );
+                    frame.stroke(&wick, Stroke::default().with_color(color).with_width(1.0));
+
+                    let buy_bar_height = (buy_volume / max_volume) * volume_area_height;
+                    let sell_bar_height = (sell_volume / max_volume) * volume_area_height;
+                    
+                    let buy_bar = Path::rectangle(
+                        Point::new(x_position as f32, (bounds.height - buy_bar_height) as f32), 
+                        Size::new(2.0 * self.scaling, buy_bar_height as f32)
+                    );
+                    frame.fill(&buy_bar, Color::from_rgb8(81, 205, 160)); 
+                    
+                    let sell_bar = Path::rectangle(
+                        Point::new(x_position as f32 - (2.0 * self.scaling), (bounds.height - sell_bar_height) as f32), 
+                        Size::new(2.0 * self.scaling, sell_bar_height as f32)
+                    );
+                    frame.fill(&sell_bar, Color::from_rgb8(192, 80, 77)); 
+                }
+            });
+
+        vec![background, candlesticks]
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Drawing => mouse::Interaction::Crosshair,
+            Interaction::Erasing => mouse::Interaction::Crosshair,
+            Interaction::Panning { .. } => mouse::Interaction::Grabbing,
+            Interaction::None if cursor.is_over(bounds) => {
+                mouse::Interaction::Crosshair
+            }
+            Interaction::None => mouse::Interaction::default(),
+        }
+    }
+}
+
+fn calculate_price_step(highest: f32, lowest: f32, labels_can_fit: i32) -> (f32, f32) {
+    let range = highest - lowest;
+    let mut step = 100.0; 
+
+    let steps = [100.0, 50.0, 20.0, 10.0, 5.0, 2.0, 1.0, 0.5];
+
+    for &s in steps.iter().rev() {
+        if range / s <= labels_can_fit as f32 {
+            step = s;
+            break;
+        }
+    }
+    let rounded_lowest = (lowest / step).floor() * step;
+
+    (step, rounded_lowest)
+}
+fn calculate_time_step(earliest: i64, latest: i64, labels_can_fit: i32) -> (Duration, NaiveDateTime) {
+    let duration = latest - earliest;
+    let duration_in_millis = duration * 1000; 
+
+    let steps = [
+        Duration::minutes(60),
+        Duration::minutes(30),
+        Duration::minutes(15),
+        Duration::minutes(10),
+        Duration::minutes(5),
+        Duration::minutes(1),
+    ];
+
+    let mut selected_step = steps[0];
+    for &step in steps.iter() {
+        if duration_in_millis / step.num_milliseconds() >= labels_can_fit as i64 {
+            selected_step = step;
+            break;
+        }
+    }
+
+    let mut rounded_earliest = NaiveDateTime::from_timestamp(earliest, 0)
+        .with_second(0).unwrap()
+        .with_nanosecond(0).unwrap();
+
+    let minutes = rounded_earliest.minute();
+    let step_minutes = selected_step.num_minutes() as u32;
+    let remainder = minutes % step_minutes;
+    if remainder > 0 {
+        rounded_earliest = rounded_earliest + Duration::minutes((step_minutes - remainder) as i64);
+    }
+
+    (selected_step, rounded_earliest)
+}
+
+impl Default for CustomLine {
+    fn default() -> Self {
+        Self::new(vec![], 1)
+    }
+}
+pub struct AxisLabelXCanvas<'a> {
+    labels_cache: &'a Cache,
+    min: i64,
+    max: i64,
+}
+impl canvas::Program<Message> for AxisLabelXCanvas<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        interaction: &mut Interaction,
+        event: Event,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> (event::Status, Option<Message>) {
+        (event::Status::Ignored, None)
+    }
+    
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        _theme: &Theme,
+        bounds: Rectangle,
+        _cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        if self.max == 0 {
+            return vec![];
+        }
+
+        let x_labels_can_fit = (bounds.width / 100.0) as i32;
+        let (time_step, rounded_earliest) = calculate_time_step(self.min, self.max, x_labels_can_fit);
+
+        let labels = self.labels_cache.draw(renderer, bounds.size(), |frame| {
+            frame.with_save(|frame| {
+                let latest_in_millis = self.max * 1000; 
+                let earliest_in_millis = self.min * 1000; 
+
+                let mut time = rounded_earliest;
+                let latest_time = NaiveDateTime::from_timestamp(self.max, 0);
+
+                while time <= latest_time {
+                    let time_in_millis = time.timestamp_millis();
+                    
+                    let x_position = ((time_in_millis - earliest_in_millis) as f64 / (latest_in_millis - earliest_in_millis) as f64) * bounds.width as f64;
+
+                    if x_position >= 0.0 && x_position <= bounds.width as f64 {
+                        let text_size = 12.0;
+                        let label = canvas::Text {
+                            content: time.format("%H:%M").to_string(),
+                            position: Point::new(x_position as f32 - text_size, bounds.height as f32 - 20.0),
+                            size: iced::Pixels(text_size),
+                            color: Color::from_rgba8(200, 200, 200, 1.0),
+                            ..canvas::Text::default()
+                        };  
+
+                        label.draw_with(|path, color| {
+                            frame.fill(&path, color);
+                        });
+                    }
+                    
+                    time = time + time_step;
+                }
+            });
+        });
+
+        vec![labels]
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Drawing => mouse::Interaction::Crosshair,
+            Interaction::Erasing => mouse::Interaction::Crosshair,
+            Interaction::Panning { .. } => mouse::Interaction::ResizingHorizontally,
+            Interaction::None if cursor.is_over(bounds) => {
+                mouse::Interaction::ResizingHorizontally
+            }
+            Interaction::None => mouse::Interaction::default(),
+        }
+    }
+}
+pub struct AxisLabelYCanvas<'a> {
+    labels_cache: &'a Cache,
+    min: f32,
+    max: f32,
+}
+impl canvas::Program<Message> for AxisLabelYCanvas<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        interaction: &mut Interaction,
+        event: Event,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> (event::Status, Option<Message>) {
+        (event::Status::Ignored, None)
+    }
+    
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        _theme: &Theme,
+        bounds: Rectangle,
+        _cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        if self.max == 0.0 {
+            return vec![];
+        }
+
+        let y_labels_can_fit = (bounds.height / 60.0) as i32;
+        let (step, rounded_lowest) = calculate_price_step(self.max, self.min, y_labels_can_fit);
+
+        let volume_area_height = bounds.height / 8.0; 
+        let candlesticks_area_height = bounds.height - volume_area_height;
+
+        let labels = self.labels_cache.draw(renderer, bounds.size(), |frame| {
+            frame.with_save(|frame| {
+                let y_range = self.max - self.min;
+                let mut y = rounded_lowest;
+
+                while y <= self.max {
+                    let y_position = candlesticks_area_height - ((y - self.min) / y_range * candlesticks_area_height);
+
+                    let text_size = 12.0;
+                    let label = canvas::Text {
+                        content: format!("{:.1}", y),
+                        position: Point::new(5.0, y_position - text_size / 2.0),
+                        size: iced::Pixels(text_size),
+                        color: Color::from_rgba8(200, 200, 200, 1.0),
+                        ..canvas::Text::default()
+                    };  
+
+                    label.draw_with(|path, color| {
+                        frame.fill(&path, color);
+                    });
+                    y += step;
+                }
+            });
+        });
+
+        vec![labels]
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Drawing => mouse::Interaction::Crosshair,
+            Interaction::Erasing => mouse::Interaction::Crosshair,
+            Interaction::Panning { .. } => mouse::Interaction::ResizingVertically,
+            Interaction::None if cursor.is_over(bounds) => {
+                mouse::Interaction::ResizingVertically
+            }
+            Interaction::None => mouse::Interaction::default(),
+        }
+    }
+}

+ 1 - 1
src/data_providers/binance/market_data.rs

@@ -244,7 +244,7 @@ impl From<FetchedKlines> for Kline {
     }
 }
 pub async fn fetch_klines(ticker: String, timeframe: String) -> Result<Vec<Kline>, reqwest::Error> {
-    let url = format!("https://fapi.binance.com/fapi/v1/klines?symbol={}&interval={}&limit=180", ticker.to_lowercase(), timeframe);
+    let url = format!("https://fapi.binance.com/fapi/v1/klines?symbol={}&interval={}&limit=720", ticker.to_lowercase(), timeframe);
     let response = reqwest::get(&url).await?;
     let value: serde_json::Value = response.json().await?;
     let fetched_klines: Result<Vec<FetchedKlines>, _> = serde_json::from_value(value);

+ 76 - 6
src/main.rs

@@ -3,17 +3,18 @@
 mod data_providers;
 use data_providers::binance::{user_data, market_data};
 mod charts;
-use charts::{heatmap, candlesticks};
+use charts::{candlesticks, custom_line::{self, CustomLine}, heatmap};
 
 use crate::heatmap::LineChart;
 use crate::candlesticks::CandlestickChart;
 
+use std::time::Instant;
 use std::cell::RefCell;
 use chrono::{NaiveDateTime, DateTime, Utc};
 use iced::{
-    alignment, executor, font, theme, widget::{
-        button, checkbox, pick_list, text_input, tooltip, Column, Container, Row, Slider, Space, Text
-    }, Alignment, Application, Color, Command, Element, Font, Length, Renderer, Settings, Size, Subscription, Theme
+    alignment, executor, font, theme::{self, Custom}, widget::{
+        button, canvas, checkbox, pick_list, text_input, tooltip, Column, Container, Row, Slider, Space, Text
+    }, Alignment, Application, Color, Command, Element, Font, Length, Renderer, Settings, Size, Subscription, Theme, Vector
 };
 
 use iced::widget::pane_grid::{self, PaneGrid};
@@ -148,6 +149,7 @@ impl Default for UserWsState {
 pub enum PaneId {
     HeatmapChart,
     CandlestickChart,
+    CustomChart,
     TimeAndSales,
     TradePanel,
 }
@@ -167,7 +169,6 @@ impl Pane {
     }
 }
 
-
 fn main() {
     State::run(Settings {
         antialiasing: true,
@@ -180,6 +181,8 @@ fn main() {
 pub enum Message {
     Debug(String),
 
+    CustomLine(custom_line::Message),
+
     // Market&User data stream
     UserKeySucceed(String),
     UserKeyError,
@@ -236,6 +239,7 @@ struct State {
     trades_chart: Option<heatmap::LineChart>,
     candlestick_chart: Option<candlesticks::CandlestickChart>,
     time_and_sales: Option<TimeAndSales>,
+    custom_line: Option<CustomLine>,
 
     // data streams
     listen_key: Option<String>,
@@ -304,6 +308,7 @@ impl Application for State {
                 trades_chart: None,
                 candlestick_chart: None,
                 time_and_sales: None,
+                custom_line: None,
                 listen_key: None,
                 selected_ticker: None,
                 selected_timeframe: Some(Timeframe::M1),
@@ -393,6 +398,13 @@ impl Application for State {
 
     fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
         match message {
+            Message::CustomLine(message) => {
+                if let Some(custom_line) = &mut self.custom_line {
+                    custom_line.update(message);
+                }
+                Command::none()
+            },
+
             Message::TickerSelected(ticker) => {
                 self.selected_ticker = Some(ticker);
                 Command::none()
@@ -528,6 +540,7 @@ impl Application for State {
                     self.trades_chart = None;
                     self.candlestick_chart = None;
                     self.time_and_sales = None;
+                    self.custom_line = None;
 
                     self.open_orders.clear();
                     self.orders_rows.clear();
@@ -539,6 +552,7 @@ impl Application for State {
             Message::FetchEvent(klines) => {
                 match klines {
                     Ok(klines) => {
+                        let klines_clone = klines.clone(); // Clone klines
                         let timeframe_in_minutes = match &self.selected_timeframe {
                             Some(timeframe) => {
                                 match timeframe {
@@ -556,6 +570,8 @@ impl Application for State {
                         };
 
                         self.candlestick_chart = Some(CandlestickChart::new(klines, timeframe_in_minutes));
+
+                        self.custom_line = Some(CustomLine::new(klines_clone, timeframe_in_minutes))
                     },
                     Err(err) => {
                         eprintln!("Error fetching klines: {}", err);
@@ -581,9 +597,15 @@ impl Application for State {
                         } 
                     }
                     market_data::Event::KlineReceived(kline) => {
+                        let kline_clone = kline.clone();
+
                         if let Some(chart) = &mut self.candlestick_chart {
                             chart.update(kline);
                         }
+                        
+                        if let Some(custom_line) = &mut self.custom_line {
+                            custom_line.insert_datapoint(kline_clone);
+                        }
                     }
                 };
                 Command::none()
@@ -724,6 +746,9 @@ impl Application for State {
                         PaneId::CandlestickChart => {
                             self.panes_open.insert(PaneId::CandlestickChart, false);
                         },
+                        PaneId::CustomChart => {
+                            self.panes_open.insert(PaneId::CustomChart, false);
+                        },
                         PaneId::TimeAndSales => {
                             self.panes_open.insert(PaneId::TimeAndSales, false);
                             self.time_and_sales = None;
@@ -970,6 +995,7 @@ impl Application for State {
                     &self.time_and_sales,
                     &self.trades_chart, 
                     &self.candlestick_chart, 
+                    &self.custom_line,
                     self.qty_input_val.borrow().clone(), 
                     self.price_input_val.borrow().clone(),
                     &self.orders_header,
@@ -1001,6 +1027,7 @@ impl Application for State {
             let title = match pane.id {
                 PaneId::HeatmapChart => "Heatmap Chart",
                 PaneId::CandlestickChart => "Candlestick Chart",
+                PaneId::CustomChart => "Custom Chart",
                 PaneId::TimeAndSales => "Time & Sales",
                 PaneId::TradePanel => "Trading Panel",
             };            
@@ -1049,6 +1076,7 @@ impl Application for State {
                 menu_tpl_1(menu_items!(
                     (debug_button(PaneId::HeatmapChart, self.panes_open.get(&PaneId::HeatmapChart).unwrap_or(&false), self.first_pane))
                     (debug_button(PaneId::CandlestickChart, self.panes_open.get(&PaneId::CandlestickChart).unwrap_or(&false), self.first_pane))
+                    (debug_button(PaneId::CustomChart, self.panes_open.get(&PaneId::CustomChart).unwrap_or(&false), self.first_pane))
                     (debug_button(PaneId::TimeAndSales, self.panes_open.get(&PaneId::TimeAndSales).unwrap_or(&false), self.first_pane))
                     (debug_button(PaneId::TradePanel, self.panes_open.get(&PaneId::TradePanel).unwrap_or(&false), self.first_pane))
                 )).width(200.0)
@@ -1192,6 +1220,7 @@ fn view_content<'a, 'b: 'a>(
     time_and_sales: &'a Option<TimeAndSales>,
     trades_chart: &'a Option<LineChart>,
     candlestick_chart: &'a Option<CandlestickChart>,
+    custom_line: &'a Option<CustomLine>,
     qty_input_val: Option<String>,
     price_input_val: Option<String>, 
     orders_header: &'b scrollable::Id,
@@ -1276,6 +1305,47 @@ fn view_content<'a, 'b: 'a>(
                 .align_y(alignment::Vertical::Center)
                 .into()
         },
+
+        PaneId::CustomChart => { 
+            let underlay; 
+            if let Some(custom_line) = custom_line {
+                underlay =
+                    custom_line
+                        .view()
+                        .map(move |message| Message::CustomLine(message));
+            } else {
+                underlay = Text::new("No data").into();
+            }
+
+            let overlay = if show_modal {
+                Some(
+                    Card::new(
+                        Text::new("Custom Chart -> Settings"),
+                        Column::new()
+                            .push(Text::new("Test"))
+                    )
+                    .foot(
+                        Row::new()
+                            .spacing(10)
+                            .padding(5)
+                            .width(Length::Fill)
+                            .push(
+                                Text::new("Footer").size(16)
+                            )
+                    )
+                    .max_width(500.0)
+                    .on_close(Message::CloseModal)
+                )
+            } else {
+                None
+            };
+
+            modal(underlay, overlay)
+                .backdrop(Message::CloseModal)
+                .on_esc(Message::CloseModal)
+                .align_y(alignment::Vertical::Center)
+                .into()
+        },
         
         PaneId::TimeAndSales => { 
             let underlay = time_and_sales.as_ref().map(TimeAndSales::view).unwrap_or_else(|| Text::new("No data").into()); 
@@ -1319,7 +1389,7 @@ fn view_content<'a, 'b: 'a>(
         },  
         
         PaneId::TradePanel => if account_info_usdt.is_none() {
-            Text::new("No account info").into()
+            Text::new("No account info found").into()
         } else {
             let form_select_0_button = button("Market Order")
                 .on_press(Message::TabSelected(0, "order_form".to_string()));