Преглед изворни кода

Improved chart panels (#24)

* initial commit

* make indicator labels rendering modular

* chore: naming conventions

* chore: organize charts.rs for modularity

* improve label generation on charts
+ handle Bybit websocket `Close` received frame

* improve integrity fetch functionality
-- reuse same `RequestHandler` by restricting its conditions

* implement custom widget `HSplit` to properly handle chart splits

* fix `HSplit`'s split ratio not being persistent
-- modal interactions were resetting split ratio, had to store the state of the split

* fix blank indicator showing up, +chores for splitter bounds

* persistent chart layouts
-- store chart's layout(split ratio, chosen indicators) in the saved state

* chore: bump for `iced`, +handle edge case in chart labels

* fix x-axis labels not showing up on some charts

* rollback to older `iced` commit due to an introduced bug
>> click on panes wasnt registering,
++ improve indicator toggle state checkers

* bump for iced, fix freeze on certain timeframe change actions

* fix pane stream settings not being persistent

* restrict fail-safe impl. of open interest fetch
-- integrity checkers on charts were trying over and over again to get desired data, but sometimes exchanges fail to deliver it on time to public apis. For now, fixed it by limiting fetch only when new range detected until more logical approach found
Berke пре 10 месеци
родитељ
комит
9db4fed853

+ 10 - 14
Cargo.lock

@@ -1943,7 +1943,7 @@ dependencies = [
 [[package]]
 name = "iced"
 version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced?rev=a687a837653a576cb0599f7bc8ecd9c6054213a9#a687a837653a576cb0599f7bc8ecd9c6054213a9"
+source = "git+https://github.com/iced-rs/iced?rev=e722c4ee4f80833ba0b1013cadd546ebc3f490ce#e722c4ee4f80833ba0b1013cadd546ebc3f490ce"
 dependencies = [
  "iced_core 0.14.0-dev",
  "iced_futures 0.14.0-dev",
@@ -1976,7 +1976,7 @@ dependencies = [
 [[package]]
 name = "iced_core"
 version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced?rev=a687a837653a576cb0599f7bc8ecd9c6054213a9#a687a837653a576cb0599f7bc8ecd9c6054213a9"
+source = "git+https://github.com/iced-rs/iced?rev=e722c4ee4f80833ba0b1013cadd546ebc3f490ce#e722c4ee4f80833ba0b1013cadd546ebc3f490ce"
 dependencies = [
  "bitflags 2.6.0",
  "bytes",
@@ -1984,7 +1984,6 @@ dependencies = [
  "glam",
  "log",
  "num-traits",
- "once_cell",
  "palette",
  "rustc-hash 2.0.0",
  "smol_str",
@@ -2009,7 +2008,7 @@ dependencies = [
 [[package]]
 name = "iced_futures"
 version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced?rev=a687a837653a576cb0599f7bc8ecd9c6054213a9#a687a837653a576cb0599f7bc8ecd9c6054213a9"
+source = "git+https://github.com/iced-rs/iced?rev=e722c4ee4f80833ba0b1013cadd546ebc3f490ce#e722c4ee4f80833ba0b1013cadd546ebc3f490ce"
 dependencies = [
  "futures",
  "iced_core 0.14.0-dev",
@@ -2023,7 +2022,7 @@ dependencies = [
 [[package]]
 name = "iced_graphics"
 version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced?rev=a687a837653a576cb0599f7bc8ecd9c6054213a9#a687a837653a576cb0599f7bc8ecd9c6054213a9"
+source = "git+https://github.com/iced-rs/iced?rev=e722c4ee4f80833ba0b1013cadd546ebc3f490ce#e722c4ee4f80833ba0b1013cadd546ebc3f490ce"
 dependencies = [
  "bitflags 2.6.0",
  "bytemuck",
@@ -2035,7 +2034,6 @@ dependencies = [
  "kamadak-exif",
  "log",
  "lyon_path",
- "once_cell",
  "raw-window-handle",
  "rustc-hash 2.0.0",
  "thiserror 1.0.69",
@@ -2045,7 +2043,7 @@ dependencies = [
 [[package]]
 name = "iced_renderer"
 version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced?rev=a687a837653a576cb0599f7bc8ecd9c6054213a9#a687a837653a576cb0599f7bc8ecd9c6054213a9"
+source = "git+https://github.com/iced-rs/iced?rev=e722c4ee4f80833ba0b1013cadd546ebc3f490ce#e722c4ee4f80833ba0b1013cadd546ebc3f490ce"
 dependencies = [
  "iced_graphics",
  "iced_tiny_skia",
@@ -2057,7 +2055,7 @@ dependencies = [
 [[package]]
 name = "iced_runtime"
 version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced?rev=a687a837653a576cb0599f7bc8ecd9c6054213a9#a687a837653a576cb0599f7bc8ecd9c6054213a9"
+source = "git+https://github.com/iced-rs/iced?rev=e722c4ee4f80833ba0b1013cadd546ebc3f490ce#e722c4ee4f80833ba0b1013cadd546ebc3f490ce"
 dependencies = [
  "bytes",
  "iced_core 0.14.0-dev",
@@ -2069,7 +2067,7 @@ dependencies = [
 [[package]]
 name = "iced_tiny_skia"
 version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced?rev=a687a837653a576cb0599f7bc8ecd9c6054213a9#a687a837653a576cb0599f7bc8ecd9c6054213a9"
+source = "git+https://github.com/iced-rs/iced?rev=e722c4ee4f80833ba0b1013cadd546ebc3f490ce#e722c4ee4f80833ba0b1013cadd546ebc3f490ce"
 dependencies = [
  "bytemuck",
  "cosmic-text",
@@ -2084,7 +2082,7 @@ dependencies = [
 [[package]]
 name = "iced_wgpu"
 version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced?rev=a687a837653a576cb0599f7bc8ecd9c6054213a9#a687a837653a576cb0599f7bc8ecd9c6054213a9"
+source = "git+https://github.com/iced-rs/iced?rev=e722c4ee4f80833ba0b1013cadd546ebc3f490ce#e722c4ee4f80833ba0b1013cadd546ebc3f490ce"
 dependencies = [
  "bitflags 2.6.0",
  "bytemuck",
@@ -2095,7 +2093,6 @@ dependencies = [
  "iced_graphics",
  "log",
  "lyon",
- "once_cell",
  "rustc-hash 2.0.0",
  "thiserror 1.0.69",
  "wgpu",
@@ -2104,12 +2101,11 @@ dependencies = [
 [[package]]
 name = "iced_widget"
 version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced?rev=a687a837653a576cb0599f7bc8ecd9c6054213a9#a687a837653a576cb0599f7bc8ecd9c6054213a9"
+source = "git+https://github.com/iced-rs/iced?rev=e722c4ee4f80833ba0b1013cadd546ebc3f490ce#e722c4ee4f80833ba0b1013cadd546ebc3f490ce"
 dependencies = [
  "iced_renderer",
  "iced_runtime",
  "num-traits",
- "once_cell",
  "ouroboros",
  "rustc-hash 2.0.0",
  "thiserror 1.0.69",
@@ -2119,7 +2115,7 @@ dependencies = [
 [[package]]
 name = "iced_winit"
 version = "0.14.0-dev"
-source = "git+https://github.com/iced-rs/iced?rev=a687a837653a576cb0599f7bc8ecd9c6054213a9#a687a837653a576cb0599f7bc8ecd9c6054213a9"
+source = "git+https://github.com/iced-rs/iced?rev=e722c4ee4f80833ba0b1013cadd546ebc3f490ce#e722c4ee4f80833ba0b1013cadd546ebc3f490ce"
 dependencies = [
  "iced_futures 0.14.0-dev",
  "iced_graphics",

+ 1 - 1
Cargo.toml

@@ -46,4 +46,4 @@ version = "0.25"
 features = ["tokio-rustls-webpki-roots"]
 
 [patch.crates-io]
-iced = { git = "https://github.com/iced-rs/iced", rev = "a687a837653a576cb0599f7bc8ecd9c6054213a9" }
+iced = { git = "https://github.com/iced-rs/iced", rev = "e722c4ee4f80833ba0b1013cadd546ebc3f490ce" }

Разлика између датотеке није приказан због своје велике величине
+ 105 - 766
src/charts.rs


+ 94 - 109
src/charts/candlestick.rs

@@ -6,16 +6,17 @@ use iced::{mouse, Element, Length, Point, Rectangle, Renderer, Size, Task, Theme
 use iced::widget::{canvas::{self, Event, Geometry}, column};
 
 use crate::data_providers::TickerInfo;
+use crate::layout::SerializableChartData;
 use crate::screen::UserTimezone;
 use crate::data_providers::{
     fetcher::{FetchRange, RequestHandler},
     Kline, OpenInterest as OIData, Timeframe
 };
 
+use super::scales::PriceInfoLabel;
 use super::indicators::{self, CandlestickIndicator, Indicator};
-
-use super::{request_fetch, Caches, Chart, ChartConstants, CommonChartData, Interaction, Message, PriceInfoLabel};
-use super::{canvas_interaction, view_chart, update_chart, count_decimals};
+use super::{Caches, Chart, ChartConstants, CommonChartData, Interaction, Message};
+use super::{canvas_interaction, view_chart, update_chart, count_decimals, request_fetch};
 
 impl Chart for CandlestickChart {
     fn get_common_data(&self) -> &CommonChartData {
@@ -99,17 +100,18 @@ pub struct CandlestickChart {
     indicators: HashMap<CandlestickIndicator, IndicatorData>,
     request_handler: RequestHandler,
     fetching_oi: bool,
-    integrity: bool,
 }
 
 impl CandlestickChart {
     pub fn new(
+        layout: SerializableChartData,
         klines_raw: Vec<Kline>,
         timeframe: Timeframe,
         tick_size: f32,
         timezone: UserTimezone,
         enabled_indicators: &[CandlestickIndicator],
     ) -> CandlestickChart {
+        let mut loading_chart = true;
         let mut data_points = BTreeMap::new();
         let mut volume_data = BTreeMap::new();
 
@@ -129,6 +131,10 @@ impl CandlestickChart {
             latest_x = latest_x.max(*time);
         });
 
+        if !data_points.is_empty() {
+            loading_chart = false;
+        }
+
         let y_ticks = (scale_high - scale_low) / tick_size;
 
         CandlestickChart {
@@ -141,8 +147,10 @@ impl CandlestickChart {
                 timeframe: timeframe.to_milliseconds(),
                 tick_size,
                 timezone,
-                indicators_height: 30,
+                crosshair: layout.crosshair,
+                indicators_split: layout.indicators_split,
                 decimals: count_decimals(tick_size),
+                loading_chart,
                 ..Default::default()
             },
             data_points,
@@ -167,10 +175,13 @@ impl CandlestickChart {
             },
             request_handler: RequestHandler::new(),
             fetching_oi: false,
-            integrity: false,
         }
     }
 
+    pub fn set_loading_state(&mut self, loading: bool) {
+        self.chart.loading_chart = loading;
+    }
+
     pub fn change_timezone(&mut self, timezone: UserTimezone) {
         let chart = self.get_common_data_mut();
         chart.timezone = timezone;
@@ -182,7 +193,7 @@ impl CandlestickChart {
 
     pub fn update_latest_kline(&mut self, kline: &Kline) -> Task<Message> {
         let mut task = None;
-        
+
         self.data_points.insert(kline.time as i64, *kline);
 
         if let Some(IndicatorData::Volume(_, data)) = 
@@ -235,19 +246,15 @@ impl CandlestickChart {
                     let (oi_earliest, oi_latest) = self.get_oi_timerange(kline_latest);
 
                     if visible_earliest < oi_earliest {
-                        let latest = oi_earliest;
-
                         if let Some(fetch_task) = request_fetch(
-                            &mut self.request_handler, FetchRange::OpenInterest(earliest, latest)
+                            &mut self.request_handler, FetchRange::OpenInterest(earliest, oi_earliest)
                         ) {
                             self.fetching_oi = true;
                             task = Some(fetch_task);
                         }
                     } else if oi_latest < kline_latest {
-                        let latest = visible_latest;
-
                         if let Some(fetch_task) = request_fetch(
-                            &mut self.request_handler, FetchRange::OpenInterest(oi_latest, latest)
+                            &mut self.request_handler, FetchRange::OpenInterest(oi_latest, kline_latest)
                         ) {
                             self.fetching_oi = true;
                             task = Some(fetch_task);
@@ -258,64 +265,27 @@ impl CandlestickChart {
         };
 
         if task.is_none() {
-            if let Some(missing_keys) = self.check_data_integrity(kline_earliest, kline_latest) {
-                let (latest, earliest) = (
-                    missing_keys.iter().max().unwrap_or(&visible_latest) + self.chart.timeframe as i64,
-                    missing_keys.iter().min().unwrap_or(&visible_earliest) - self.chart.timeframe as i64,
-                );
-
-                self.request_handler = RequestHandler::new();
-
-                if let Some(fetch_task) = request_fetch(
-                    &mut self.request_handler, FetchRange::Kline(earliest, latest)
-                ) {
-                    self.get_common_data_mut().already_fetching = true;
-                    task = Some(fetch_task);
+            if let Some(missing_keys) = self.get_common_data()
+                .check_kline_integrity(kline_earliest, kline_latest, &self.data_points) {
+                    let (latest, earliest) = (
+                        missing_keys.iter()
+                            .max().unwrap_or(&visible_latest) + self.chart.timeframe as i64,
+                        missing_keys.iter()
+                            .min().unwrap_or(&visible_earliest) - self.chart.timeframe as i64,
+                    );
+    
+                    if let Some(fetch_task) = request_fetch(
+                        &mut self.request_handler, FetchRange::Kline(earliest, latest)
+                    ) {
+                        self.get_common_data_mut().already_fetching = true;
+                        task = Some(fetch_task);
+                    }
                 }
-            }
         }
 
         task
     }
 
-    fn check_data_integrity(&mut self, earliest: i64, latest: i64) -> Option<Vec<i64>> {
-        if self.integrity || self.fetching_oi {
-            return None;
-        }
-        if self.get_common_data().already_fetching {
-            return None;
-        }
-    
-        let interval = self.get_common_data().timeframe as i64;
-        
-        let mut time = earliest;
-        let mut missing_count = 0;
-        while time < latest {
-            if !self.data_points.contains_key(&time) {
-                missing_count += 1;
-                break; 
-            }
-            time += interval;
-        }
-    
-        if missing_count > 0 {
-            let mut missing_keys = Vec::with_capacity(((latest - earliest) / interval) as usize);
-            let mut time = earliest;
-            while time < latest {
-                if !self.data_points.contains_key(&time) {
-                    missing_keys.push(time);
-                }
-                time += interval;
-            }
-            
-            log::warn!("Integrity check failed: missing {} klines", missing_keys.len());
-            Some(missing_keys)
-        } else {
-            self.integrity = true;
-            None
-        }
-    }
-
     pub fn insert_new_klines(&mut self, req_id: uuid::Uuid, klines_raw: &Vec<Kline>) {
         let mut volume_data = BTreeMap::new();
 
@@ -329,7 +299,7 @@ impl CandlestickChart {
                 data.extend(volume_data.clone());
             };
 
-        if klines_raw.len() > 1 {
+        if klines_raw.len() >= 1 {
             self.request_handler.mark_completed(req_id);
         } else {
             self.request_handler
@@ -338,18 +308,28 @@ impl CandlestickChart {
 
         self.get_common_data_mut().already_fetching = false;
 
+        self.chart.loading_chart = false;        
+
         self.render_start();
     }
 
-    pub fn insert_open_interest(&mut self, _req_id: Option<uuid::Uuid>, oi_data: Vec<OIData>) {
+    pub fn insert_open_interest(&mut self, req_id: Option<uuid::Uuid>, oi_data: Vec<OIData>) {
+        if let Some(req_id) = req_id {
+            if oi_data.len() >= 1 {
+                self.request_handler.mark_completed(req_id);
+                self.fetching_oi = false; 
+            } else {
+                self.request_handler
+                    .mark_failed(req_id, "No data received".to_string());
+            }
+        }
+
         if let Some(IndicatorData::OpenInterest(_, data)) = 
             self.indicators.get_mut(&CandlestickIndicator::OpenInterest) {
                 data.extend(oi_data
                     .iter().map(|oi| (oi.time, oi.value))
                 );
             };
-    
-        self.fetching_oi = false;
     }
 
     fn get_kline_timerange(&self) -> (i64, i64) {
@@ -382,20 +362,22 @@ impl CandlestickChart {
     fn render_start(&mut self) {
         let chart_state = &mut self.chart;
 
-        if chart_state.autoscale {
-            chart_state.translation =
-                Vector::new(
-                    0.5 * (chart_state.bounds.width / chart_state.scaling) - (8.0 * chart_state.cell_width / chart_state.scaling),
-                    {
-                    if let Some((_, kline)) = self.data_points.last_key_value() {
-                        let y_low = chart_state.price_to_y(kline.low);
-                        let y_high = chart_state.price_to_y(kline.high);
+        if chart_state.loading_chart {
+            return;
+        }
 
-                        -(y_low + y_high) / 2.0
-                    } else {
-                        0.0
-                    }
-                });
+        if chart_state.autoscale {
+            chart_state.translation = Vector::new(
+                0.5 * (chart_state.bounds.width / chart_state.scaling) - (8.0 * chart_state.cell_width / chart_state.scaling),
+                if let Some((_, kline)) = self.data_points.last_key_value() {
+                    let y_low = chart_state.price_to_y(kline.low);
+                    let y_high = chart_state.price_to_y(kline.high);
+
+                    -(y_low + y_high) / 2.0
+                } else {
+                    0.0
+                },
+            );
         }
 
         chart_state.cache.clear_all();
@@ -405,6 +387,10 @@ impl CandlestickChart {
         });
     }
 
+    pub fn get_chart_layout(&self) -> SerializableChartData {
+        self.chart.get_chart_layout()
+    }
+
     pub fn toggle_indicator(&mut self, indicator: CandlestickIndicator) {
         if self.indicators.contains_key(&indicator) {
             self.indicators.remove(&indicator);
@@ -425,8 +411,17 @@ impl CandlestickChart {
                         indicator,
                         IndicatorData::OpenInterest(Caches::default(), BTreeMap::new())
                     );
+                    self.fetching_oi = false;
                 }
             }
+
+            if self.chart.indicators_split.is_none() {
+                self.chart.indicators_split = Some(0.8);
+            }
+        }
+
+        if self.indicators.is_empty() {
+            self.chart.indicators_split = None;
         }
     }
 
@@ -435,7 +430,11 @@ impl CandlestickChart {
         enabled: &[I], 
         ticker_info: Option<TickerInfo>
     ) -> Option<Element<Message>> {
-        let chart_state: &CommonChartData = self.get_common_data();
+        let chart_state = self.get_common_data();
+
+        if chart_state.loading_chart {
+            return None;
+        }
 
         let visible_region = chart_state.visible_region(chart_state.bounds.size());
 
@@ -446,7 +445,7 @@ impl CandlestickChart {
 
         for indicator in I::get_enabled(
             enabled, 
-            ticker_info.map(|info| info.market_type)
+            ticker_info.map(|info| info.get_market_type())
         ) {
             if let Some(candlestick_indicator) = indicator
                 .as_any()
@@ -462,12 +461,14 @@ impl CandlestickChart {
                             }
                     },
                     CandlestickIndicator::OpenInterest => {
-                        if let Some(IndicatorData::OpenInterest(cache, data)) = 
-                            self.indicators.get(&CandlestickIndicator::OpenInterest) {
-                                indicators = indicators.push(
-                                    indicators::open_interest::create_indicator_elem(chart_state, cache, data, earliest, latest)
-                                );
-                            }
+                        if chart_state.timeframe >= Timeframe::M5.to_milliseconds() {
+                            if let Some(IndicatorData::OpenInterest(cache, data)) = self.indicators
+                                .get(&CandlestickIndicator::OpenInterest) {
+                                    indicators = indicators.push(
+                                        indicators::open_interest::create_indicator_elem(chart_state, cache, data, earliest, latest)
+                                    );
+                                }
+                        }
                     }
                 }
             }
@@ -476,7 +477,7 @@ impl CandlestickChart {
         Some(
             container(indicators)
                 .width(Length::FillPortion(10))
-                .height(Length::FillPortion(chart_state.indicators_height))
+                .height(Length::Fill)
                 .into()
         )
     }
@@ -648,27 +649,11 @@ impl canvas::Program<Message> for CandlestickChart {
             Interaction::Panning { .. } => mouse::Interaction::Grabbing,
             Interaction::Zoomin { .. } => mouse::Interaction::ZoomIn,
             Interaction::None => {
-                if cursor.is_over(Rectangle {
-                    x: bounds.x,
-                    y: bounds.y,
-                    width: bounds.width,
-                    height: bounds.height - 8.0,
-                }) {
-                    if self.chart.crosshair {
-                        return mouse::Interaction::Crosshair;
-                    }
-                } else if cursor.is_over(Rectangle {
-                    x: bounds.x,
-                    y: bounds.y + bounds.height - 8.0,
-                    width: bounds.width,
-                    height: 8.0,
-                }) {
-                    return mouse::Interaction::ResizingVertically;
+                if cursor.is_over(bounds) && self.chart.crosshair {
+                    return mouse::Interaction::Crosshair;
                 }
-
                 mouse::Interaction::default()
             }
-            _ => mouse::Interaction::default(),
         }
     }
 }

+ 85 - 103
src/charts/footprint.rs

@@ -7,17 +7,17 @@ use iced::widget::{column, canvas::{self, Event, Geometry}};
 use ordered_float::OrderedFloat;
 
 use crate::data_providers::TickerInfo;
+use crate::layout::SerializableChartData;
 use crate::screen::UserTimezone;
 use crate::data_providers::{
     fetcher::{FetchRange, RequestHandler},
     Kline, Timeframe, Trade, OpenInterest as OIData,
 };
 
+use super::scales::PriceInfoLabel;
 use super::indicators::{self, FootprintIndicator, Indicator};
-use super::{
-    request_fetch, round_to_tick, Caches, Chart, ChartConstants, CommonChartData, Interaction, Message, PriceInfoLabel
-};
-use super::{canvas_interaction, view_chart, update_chart, count_decimals, convert_to_qty_abbr};
+use super::{Caches, Chart, ChartConstants, CommonChartData, Interaction, Message};
+use super::{canvas_interaction, view_chart, update_chart, count_decimals, request_fetch, abbr_large_numbers, round_to_tick};
 
 impl Chart for FootprintChart {
     fn get_common_data(&self) -> &CommonChartData {
@@ -104,11 +104,11 @@ pub struct FootprintChart {
     fetching_oi: bool,
     fetching_trades: bool,
     request_handler: RequestHandler,
-    kline_integrity: bool,
 }
 
 impl FootprintChart {
     pub fn new(
+        layout: SerializableChartData,
         timeframe: Timeframe,
         tick_size: f32,
         klines_raw: Vec<Kline>,
@@ -116,6 +116,7 @@ impl FootprintChart {
         timezone: UserTimezone,
         enabled_indicators: &[FootprintIndicator],
     ) -> Self {
+        let mut loading_chart = true;
         let mut data_points = BTreeMap::new();
         let mut volume_data = BTreeMap::new();
 
@@ -164,6 +165,10 @@ impl FootprintChart {
             }
         }
 
+        if !data_points.is_empty() {
+            loading_chart = false;
+        }
+
         let y_ticks = (scale_high - scale_low) / tick_size;
 
         FootprintChart {
@@ -177,11 +182,12 @@ impl FootprintChart {
                 tick_size,
                 timezone,
                 decimals: count_decimals(tick_size),
-                indicators_height: 30,
+                crosshair: layout.crosshair,
+                indicators_split: layout.indicators_split,
+                loading_chart,
                 ..Default::default()
             },
             data_points,
-            kline_integrity: false,
             raw_trades,
             indicators: {
                 let mut indicators = HashMap::new();
@@ -208,6 +214,10 @@ impl FootprintChart {
         }
     }
 
+    pub fn set_loading_state(&mut self, loading: bool) {
+        self.chart.loading_chart = loading;
+    }
+
     pub fn update_latest_kline(&mut self, kline: &Kline) -> Task<Message> {
         let mut task = None;
 
@@ -294,19 +304,15 @@ impl FootprintChart {
                     let (oi_earliest, oi_latest) = self.get_oi_timerange(kline_latest);
 
                     if visible_earliest < oi_earliest {
-                        let latest = oi_earliest;
-
                         if let Some(fetch_task) = request_fetch(
-                            &mut self.request_handler, FetchRange::OpenInterest(earliest, latest)
+                            &mut self.request_handler, FetchRange::OpenInterest(earliest, oi_earliest)
                         ) {
                             self.fetching_oi = true;
                             task = Some(fetch_task);
                         }
                     } else if oi_latest < kline_latest {
-                        let latest = visible_latest;
-
                         if let Some(fetch_task) = request_fetch(
-                            &mut self.request_handler, FetchRange::OpenInterest(oi_latest, latest)
+                            &mut self.request_handler, FetchRange::OpenInterest(oi_latest, kline_latest)
                         ) {
                             self.fetching_oi = true;
                             task = Some(fetch_task);
@@ -317,64 +323,27 @@ impl FootprintChart {
         };
 
         if task.is_none() {
-            if let Some(missing_keys) = self.check_data_integrity(kline_earliest, kline_latest) {
-                let (latest, earliest) = (
-                    missing_keys.iter().max().unwrap_or(&visible_latest) + self.chart.timeframe as i64,
-                    missing_keys.iter().min().unwrap_or(&visible_earliest) - self.chart.timeframe as i64,
-                );
-
-                self.request_handler = RequestHandler::new();
-
-                if let Some(fetch_task) = request_fetch(
-                    &mut self.request_handler, FetchRange::Kline(earliest, latest)
-                ) {
-                    self.get_common_data_mut().already_fetching = true;
-                    task = Some(fetch_task);
+            if let Some(missing_keys) = self.get_common_data()
+                .check_kline_integrity(kline_earliest, kline_latest, &self.data_points) {
+                    let (latest, earliest) = (
+                        missing_keys.iter()
+                            .max().unwrap_or(&visible_latest) + self.chart.timeframe as i64,
+                        missing_keys.iter()
+                            .min().unwrap_or(&visible_earliest) - self.chart.timeframe as i64,
+                    );
+        
+                    if let Some(fetch_task) = request_fetch(
+                        &mut self.request_handler, FetchRange::Kline(earliest, latest)
+                    ) {
+                        self.get_common_data_mut().already_fetching = true;
+                        task = Some(fetch_task);
+                    }
                 }
-            }
         }
 
         task
     }
 
-    fn check_data_integrity(&mut self, earliest: i64, latest: i64) -> Option<Vec<i64>> {
-        if self.kline_integrity || self.fetching_oi {
-            return None;
-        }
-        if self.get_common_data().already_fetching {
-            return None;
-        }
-    
-        let interval = self.get_common_data().timeframe as i64;
-        
-        let mut time = earliest;
-        let mut missing_count = 0;
-        while time < latest {
-            if !self.data_points.contains_key(&time) {
-                missing_count += 1;
-                break; 
-            }
-            time += interval;
-        }
-    
-        if missing_count > 0 {
-            let mut missing_keys = Vec::with_capacity(((latest - earliest) / interval) as usize);
-            let mut time = earliest;
-            while time < latest {
-                if !self.data_points.contains_key(&time) {
-                    missing_keys.push(time);
-                }
-                time += interval;
-            }
-            
-            log::warn!("Integrity check failed: missing {} klines", missing_keys.len());
-            Some(missing_keys)
-        } else {
-            self.kline_integrity = true;
-            None
-        }
-    }
-
     pub fn reset_request_handler(&mut self) {
         self.request_handler = RequestHandler::new();
         self.fetching_trades = false;
@@ -429,6 +398,10 @@ impl FootprintChart {
         self.chart.tick_size
     }
 
+    pub fn get_chart_layout(&self) -> SerializableChartData {
+        self.chart.get_chart_layout()
+    }
+
     pub fn change_tick_size(&mut self, new_tick_size: f32) {
         let chart = self.get_common_data_mut();
         let old_tick_size = chart.tick_size;
@@ -562,7 +535,7 @@ impl FootprintChart {
                 data.extend(volume_data.clone());
             };
 
-        if klines_raw.len() > 1 {
+        if klines_raw.len() >= 1 {
             self.request_handler.mark_completed(req_id);
         } else {
             self.request_handler
@@ -571,18 +544,28 @@ impl FootprintChart {
 
         self.get_common_data_mut().already_fetching = false;
 
+        self.chart.loading_chart = false;
+
         self.render_start();
     }
 
-    pub fn insert_open_interest(&mut self, _req_id: Option<uuid::Uuid>, oi_data: Vec<OIData>) {
+    pub fn insert_open_interest(&mut self, req_id: Option<uuid::Uuid>, oi_data: Vec<OIData>) {
+        if let Some(req_id) = req_id {
+            if oi_data.len() >= 1 {
+                self.request_handler.mark_completed(req_id);
+                self.fetching_oi = false;
+            } else {
+                self.request_handler
+                    .mark_failed(req_id, "No data received".to_string());
+            }
+        }
+
         if let Some(IndicatorData::OpenInterest(_, data)) = 
             self.indicators.get_mut(&FootprintIndicator::OpenInterest) {
                 data.extend(oi_data
                     .iter().map(|oi| (oi.time, oi.value))
                 );
             };
-    
-        self.fetching_oi = false;
     }
 
     fn calc_qty_scales(
@@ -618,20 +601,22 @@ impl FootprintChart {
     fn render_start(&mut self) {
         let chart_state = &mut self.chart;
 
+        if chart_state.loading_chart {
+            return;
+        }
+
         if chart_state.autoscale {
-            chart_state.translation =
-                Vector::new(
-                    0.5 * (chart_state.bounds.width / chart_state.scaling) - (chart_state.cell_width / chart_state.scaling),
-                    {
-                    if let Some((_, (_, kline))) = self.data_points.last_key_value() {
-                        let y_low = chart_state.price_to_y(kline.low);
-                        let y_high = chart_state.price_to_y(kline.high);
+            chart_state.translation = Vector::new(
+                0.5 * (chart_state.bounds.width / chart_state.scaling) - (chart_state.cell_width / chart_state.scaling),
+                if let Some((_, (_, kline))) = self.data_points.last_key_value() {
+                    let y_low = chart_state.price_to_y(kline.low);
+                    let y_high = chart_state.price_to_y(kline.high);
 
-                        -(y_low + y_high) / 2.0
-                    } else {
-                        0.0
-                    }
-                });
+                    -(y_low + y_high) / 2.0
+                } else {
+                    0.0
+                },
+            );
         }
 
         chart_state.cache.clear_all();
@@ -661,8 +646,17 @@ impl FootprintChart {
                         indicator,
                         IndicatorData::OpenInterest(Caches::default(), BTreeMap::new())
                     );
+                    self.fetching_oi = false;
                 }
             }
+
+            if self.chart.indicators_split.is_none() {
+                self.chart.indicators_split = Some(0.8);
+            }
+        }
+
+        if self.indicators.is_empty() {
+            self.chart.indicators_split = None;
         }
     }
 
@@ -673,6 +667,10 @@ impl FootprintChart {
     ) -> Option<Element<Message>> {
         let chart_state: &CommonChartData = self.get_common_data();
 
+        if chart_state.loading_chart {
+            return None;
+        }
+
         let mut indicators: iced::widget::Column<'_, Message> = column![];
 
         let visible_region = chart_state.visible_region(chart_state.bounds.size());
@@ -682,7 +680,7 @@ impl FootprintChart {
 
         for indicator in I::get_enabled(
             enabled, 
-            ticker_info.map(|info| info.market_type)
+            ticker_info.map(|info| info.get_market_type())
         ) {
             if let Some(candlestick_indicator) = indicator
                 .as_any()
@@ -712,7 +710,7 @@ impl FootprintChart {
         Some(
             container(indicators)
                 .width(Length::FillPortion(10))
-                .height(Length::FillPortion(chart_state.indicators_height))
+                .height(Length::Fill)
                 .into()
         )
     }
@@ -843,7 +841,7 @@ impl canvas::Program<Message> for FootprintChart {
 
                             if trade.1 .0 > 0.0 {
                                 if cell_height_unscaled > 12.0 && cell_width_unscaled > 108.0 {
-                                    let text_content = convert_to_qty_abbr(trade.1 .0);
+                                    let text_content = abbr_large_numbers(trade.1 .0);
 
                                     let text_position =
                                         Point::new(x_position + (candle_width / 4.0), y_position);
@@ -874,7 +872,7 @@ impl canvas::Program<Message> for FootprintChart {
                             }
                             if trade.1 .1 > 0.0 {
                                 if cell_height_unscaled > 12.0 && cell_width_unscaled > 108.0 {
-                                    let text_content = convert_to_qty_abbr(trade.1 .1);
+                                    let text_content = abbr_large_numbers(trade.1 .1);
 
                                     let text_position =
                                         Point::new(x_position - (candle_width / 4.0), y_position);
@@ -984,27 +982,11 @@ impl canvas::Program<Message> for FootprintChart {
             Interaction::Panning { .. } => mouse::Interaction::Grabbing,
             Interaction::Zoomin { .. } => mouse::Interaction::ZoomIn,
             Interaction::None => {
-                if cursor.is_over(Rectangle {
-                    x: bounds.x,
-                    y: bounds.y,
-                    width: bounds.width,
-                    height: bounds.height - 8.0,
-                }) {
-                    if self.chart.crosshair {
-                        return mouse::Interaction::Crosshair;
-                    }
-                } else if cursor.is_over(Rectangle {
-                    x: bounds.x,
-                    y: bounds.y + bounds.height - 8.0,
-                    width: bounds.width,
-                    height: 8.0,
-                }) {
-                    return mouse::Interaction::ResizingVertically;
+                if cursor.is_over(bounds) && self.chart.crosshair {
+                    return mouse::Interaction::Crosshair;
                 }
-
                 mouse::Interaction::default()
             }
-            _ => mouse::Interaction::default(),
         }
     }
 }

+ 21 - 24
src/charts/heatmap.rs

@@ -6,7 +6,7 @@ use iced::{
 };
 use iced::widget::canvas::{self, Event, Geometry, Path};
 
-use crate::data_providers::TickerInfo;
+use crate::{data_providers::TickerInfo, layout::SerializableChartData};
 use crate::{
     data_providers::{Depth, Trade},
     screen::UserTimezone,
@@ -14,7 +14,7 @@ use crate::{
 
 use super::indicators::{HeatmapIndicator, Indicator};
 use super::{Chart, ChartConstants, CommonChartData, Interaction, Message};
-use super::{canvas_interaction, view_chart, update_chart, count_decimals, convert_to_qty_abbr};
+use super::{canvas_interaction, view_chart, update_chart, abbr_large_numbers, count_decimals};
 
 use ordered_float::OrderedFloat;
 
@@ -240,7 +240,13 @@ pub struct HeatmapChart {
 }
 
 impl HeatmapChart {
-    pub fn new(tick_size: f32, aggr_time: i64, timezone: UserTimezone, enabled_indicators: &[HeatmapIndicator]) -> Self {
+    pub fn new(
+        layout: SerializableChartData, 
+        tick_size: f32, 
+        aggr_time: i64, 
+        timezone: UserTimezone, 
+        enabled_indicators: &[HeatmapIndicator]
+    ) -> Self {
         HeatmapChart {
             chart: CommonChartData {
                 cell_width: Self::DEFAULT_CELL_WIDTH,
@@ -249,6 +255,8 @@ impl HeatmapChart {
                 tick_size,
                 decimals: count_decimals(tick_size),
                 timezone,
+                crosshair: layout.crosshair,
+                indicators_split: layout.indicators_split,
                 ..Default::default()
             },
             indicators: {
@@ -276,6 +284,7 @@ impl HeatmapChart {
 
     pub fn insert_datapoint(&mut self, trades_buffer: &[Trade], depth_update: i64, depth: &Depth) {
         let chart = &mut self.chart;
+        chart.loading_chart = false;
 
         if self.data_points.len() > 2400 {
             self.data_points.drain(0..400);
@@ -380,6 +389,10 @@ impl HeatmapChart {
         (self.trade_size_filter, self.order_size_filter)
     }
 
+    pub fn get_chart_layout(&self) -> SerializableChartData {
+        self.chart.get_chart_layout()
+    }
+
     pub fn change_timezone(&mut self, timezone: UserTimezone) {
         let chart = self.get_common_data_mut();
         chart.timezone = timezone;
@@ -580,7 +593,7 @@ impl canvas::Program<Message> for HeatmapChart {
                                         && color_alpha > 0.4
                                     {
                                         frame.fill_text(canvas::Text {
-                                            content: convert_to_qty_abbr(run.qty.0),
+                                            content: abbr_large_numbers(run.qty.0),
                                             position: Point::new(
                                                 start_x + (cell_height / 2.0),
                                                 y_position,
@@ -641,7 +654,7 @@ impl canvas::Program<Message> for HeatmapChart {
 
                     // max bid/ask quantity text
                     let text_size = 9.0 / chart.scaling;
-                    let text_content = convert_to_qty_abbr(max_qty);
+                    let text_content = abbr_large_numbers(max_qty);
                     let text_position = Point::new(50.0, region.y);
 
                     frame.fill_text(canvas::Text {
@@ -704,7 +717,7 @@ impl canvas::Program<Message> for HeatmapChart {
 
                 if volume_indicator && max_aggr_volume > 0.0 {
                     let text_size = 9.0 / chart.scaling;
-                    let text_content = convert_to_qty_abbr(max_aggr_volume);
+                    let text_content = abbr_large_numbers(max_aggr_volume);
                     let text_width = (text_content.len() as f32 * text_size) / 1.5;
 
                     let text_position = Point::new(
@@ -748,27 +761,11 @@ impl canvas::Program<Message> for HeatmapChart {
             Interaction::Panning { .. } => mouse::Interaction::Grabbing,
             Interaction::Zoomin { .. } => mouse::Interaction::ZoomIn,
             Interaction::None => {
-                if cursor.is_over(Rectangle {
-                    x: bounds.x,
-                    y: bounds.y,
-                    width: bounds.width,
-                    height: bounds.height - 8.0,
-                }) {
-                    if self.chart.crosshair {
-                        return mouse::Interaction::Crosshair;
-                    }
-                } else if cursor.is_over(Rectangle {
-                    x: bounds.x,
-                    y: bounds.y + bounds.height - 8.0,
-                    width: bounds.width,
-                    height: 8.0,
-                }) {
-                    return mouse::Interaction::ResizingVertically;
+                if cursor.is_over(bounds) && self.chart.crosshair {
+                    return mouse::Interaction::Crosshair;
                 }
-
                 mouse::Interaction::default()
             }
-            _ => mouse::Interaction::default(),
         }
     }
 }

+ 132 - 1
src/charts/indicators.rs

@@ -3,9 +3,20 @@ pub mod open_interest;
 
 use std::{any::Any, fmt::{self, Debug, Display}};
 
+use iced::{
+    mouse, theme::palette::Extended, 
+    widget::canvas::{self, Cache, Frame, Geometry}, 
+    Event, Point, Rectangle, Renderer, Size, Theme
+};
 use serde::{Deserialize, Serialize};
 
-use crate::data_providers::MarketType;
+use super::{abbr_large_numbers, round_to_tick, scales::linear};
+use crate::{
+    charts::scales::{calc_label_rect, AxisLabel, Label}, 
+    data_providers::MarketType,
+};
+
+use super::{Interaction, Message};
 
 pub trait Indicator: PartialEq + Display + ToString + Debug + 'static  {
     fn get_available(market_type: Option<MarketType>) -> &'static [Self] where Self: Sized;
@@ -125,4 +136,124 @@ impl Display for FootprintIndicator {
             FootprintIndicator::OpenInterest => write!(f, "Open Interest"),
         }
     }
+}
+
+fn draw_borders(
+    frame: &mut Frame,
+    bounds: Rectangle,
+    palette: &Extended,
+) {
+    frame.fill_rectangle(
+        Point::new(0.0, 0.0),
+        Size::new(1.0, bounds.height),
+        if palette.is_dark {
+            palette.background.weak.color.scale_alpha(0.4)
+        } else {
+            palette.background.strong.color.scale_alpha(0.4)
+        },
+    );
+}
+
+pub struct IndicatorLabel<'a> {
+    pub label_cache: &'a Cache,
+    pub crosshair: bool,
+    pub max: f32,
+    pub min: f32,
+    pub chart_bounds: Rectangle,
+}
+
+impl canvas::Program<Message> for IndicatorLabel<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        _state: &mut Self::State,
+        _event: Event,
+        _bounds: Rectangle,
+        _cursor: mouse::Cursor,
+    ) -> Option<canvas::Action<Message>> {
+        None
+    }
+
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        theme: &Theme,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        let palette = theme.extended_palette();
+
+        let (highest, lowest) = (self.max, self.min);
+        let range = highest - lowest;
+        
+        let text_size = 12.0;
+
+        let labels = self.label_cache.draw(renderer, bounds.size(), |frame| {
+            draw_borders(frame, bounds, palette);
+
+            let mut all_labels = linear::generate_labels(
+                bounds,
+                self.min,
+                self.max,
+                text_size,
+                palette.background.base.text,
+                10.0,
+                None,
+            );
+
+            if self.crosshair {
+                let common_bounds = Rectangle {
+                    x: self.chart_bounds.x,
+                    y: bounds.y,
+                    width: self.chart_bounds.width,
+                    height: bounds.height,
+                };
+
+                if let Some(crosshair_pos) = cursor.position_in(common_bounds) {
+                    let rounded_value = round_to_tick(
+                        lowest + (range * (bounds.height - crosshair_pos.y) / bounds.height), 
+                        10.0
+                    );
+
+                    let label = Label {
+                        content: abbr_large_numbers(rounded_value),
+                        background_color: Some(palette.secondary.base.color),
+                        text_color: palette.secondary.base.text,
+                        text_size,
+                    };
+
+                    let y_position =
+                        bounds.height - ((rounded_value - lowest) / range * bounds.height);
+
+                    all_labels.push(
+                        AxisLabel::Y(
+                            calc_label_rect(y_position, 1, text_size, bounds),
+                            label, 
+                            None,
+                        )
+                    );
+                }
+            }
+
+            AxisLabel::filter_and_draw(&all_labels, frame);
+        });
+
+        vec![labels]
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Zoomin { .. } => mouse::Interaction::ResizingVertically,
+            Interaction::Panning { .. } => mouse::Interaction::None,
+            Interaction::None if cursor.is_over(bounds) => mouse::Interaction::ResizingVertically,
+            _ => mouse::Interaction::default(),
+        }
+    }
 }

+ 3 - 158
src/charts/indicators/open_interest.rs

@@ -5,7 +5,7 @@ use iced::{mouse, Element, Length, Point, Rectangle, Renderer, Size, Theme, Vect
 use iced::widget::canvas::{self, Cache, Event, Geometry, LineDash, Path, Stroke};
 
 use crate::charts::{
-    calc_price_step, convert_to_qty_abbr, round_to_tick, AxisLabel, Caches, CommonChartData, Interaction, Label, Message
+    round_to_tick, Caches, CommonChartData, Interaction, Message
 };
 use crate::data_providers::format_with_commas;
 
@@ -45,7 +45,7 @@ pub fn create_indicator_elem<'a>(
     max_value += padding;
     min_value -= padding;
 
-    let indi_labels = Canvas::new(OpenInterestLabels {
+    let indi_labels = Canvas::new(super::IndicatorLabel {
         label_cache: &cache.y_labels,
         max: max_value,
         min: min_value,
@@ -356,159 +356,4 @@ impl canvas::Program<Message> for OpenInterest<'_> {
             _ => mouse::Interaction::default(),
         }
     }
-}
-
-pub struct OpenInterestLabels<'a> {
-    pub label_cache: &'a Cache,
-    pub crosshair: bool,
-    pub max: f32,
-    pub min: f32,
-    pub chart_bounds: Rectangle,
-}
-
-impl canvas::Program<Message> for OpenInterestLabels<'_> {
-    type State = Interaction;
-
-    fn update(
-        &self,
-        _state: &mut Self::State,
-        _event: Event,
-        _bounds: Rectangle,
-        _cursor: mouse::Cursor,
-    ) -> Option<canvas::Action<Message>> {
-        None
-    }
-
-    fn draw(
-        &self,
-        _state: &Self::State,
-        renderer: &Renderer,
-        theme: &Theme,
-        bounds: Rectangle,
-        cursor: mouse::Cursor,
-    ) -> Vec<Geometry> {
-        let palette = theme.extended_palette();
-
-        let highest = self.max;
-        let lowest = self.min;
-
-        let text_size = 12.0;
-
-        let labels = self.label_cache.draw(renderer, bounds.size(), |frame| {
-            frame.fill_rectangle(
-                Point::new(0.0, 0.0),
-                Size::new(bounds.width, 1.0),
-                if palette.is_dark {
-                    palette.background.weak.color.scale_alpha(0.2)
-                } else {
-                    palette.background.strong.color.scale_alpha(0.2)
-                },
-            );
-
-            frame.fill_rectangle(
-                Point::new(0.0, 0.0),
-                Size::new(1.0, bounds.height),
-                if palette.is_dark {
-                    palette.background.weak.color.scale_alpha(0.4)
-                } else {
-                    palette.background.strong.color.scale_alpha(0.4)
-                },
-            );
-
-            let y_range = highest - lowest;
-
-            let y_labels_can_fit: i32 = (bounds.height / (text_size * 2.0)) as i32;
-
-            let mut all_labels: Vec<AxisLabel> =
-                Vec::with_capacity((y_labels_can_fit + 2) as usize); // +2 for last_price and crosshair
-
-            let rect = |y_pos: f32, label_amt: i16| {
-                let label_offset = text_size + (f32::from(label_amt) * (text_size / 2.0) + 2.0);
-
-                Rectangle {
-                    x: 6.0,
-                    y: y_pos - label_offset / 2.0,
-                    width: bounds.width - 8.0,
-                    height: label_offset,
-                }
-            };
-
-            // Regular price labels (priority 1)
-            let (step, rounded_lowest) = calc_price_step(highest, lowest, y_labels_can_fit, 1.0);
-
-            let mut y = rounded_lowest;
-
-            while y <= highest {
-                let y_position = bounds.height - ((y - lowest) / y_range * bounds.height);
-
-                if y > 0.0 {
-                    let text_content = convert_to_qty_abbr(y);
-
-                    let label = Label {
-                        content: text_content,
-                        background_color: None,
-                        marker_color: if palette.is_dark {
-                            palette.background.weak.color.scale_alpha(0.6)
-                        } else {
-                            palette.background.strong.color.scale_alpha(0.6)
-                        },
-                        text_color: palette.background.base.text,
-                        text_size: 12.0,
-                    };
-
-                    all_labels.push(AxisLabel::Y(rect(y_position, 1), label, None));
-                }
-
-                y += step;
-            }
-
-            // Crosshair price (priority 3)
-            if self.crosshair {
-                let common_bounds = Rectangle {
-                    x: self.chart_bounds.x,
-                    y: bounds.y,
-                    width: self.chart_bounds.width,
-                    height: bounds.height,
-                };
-
-                if let Some(crosshair_pos) = cursor.position_in(common_bounds) {
-                    let raw_price =
-                        lowest + (y_range * (bounds.height - crosshair_pos.y) / bounds.height);
-                    let rounded_price = round_to_tick(raw_price, 1.0);
-                    let y_position =
-                        bounds.height - ((rounded_price - lowest) / y_range * bounds.height);
-
-                    let text_content = convert_to_qty_abbr(rounded_price);
-
-                    let label = Label {
-                        content: text_content,
-                        background_color: Some(palette.secondary.base.color),
-                        marker_color: palette.background.strong.color,
-                        text_color: palette.secondary.base.text,
-                        text_size: 12.0,
-                    };
-
-                    all_labels.push(AxisLabel::Y(rect(y_position, 1), label, None));
-                }
-            }
-
-            AxisLabel::filter_and_draw(&all_labels, frame);
-        });
-
-        vec![labels]
-    }
-
-    fn mouse_interaction(
-        &self,
-        interaction: &Interaction,
-        bounds: Rectangle,
-        cursor: mouse::Cursor,
-    ) -> mouse::Interaction {
-        match interaction {
-            Interaction::Zoomin { .. } => mouse::Interaction::ResizingVertically,
-            Interaction::Panning { .. } => mouse::Interaction::None,
-            Interaction::None if cursor.is_over(bounds) => mouse::Interaction::ResizingVertically,
-            _ => mouse::Interaction::default(),
-        }
-    }
-}
+}

+ 3 - 168
src/charts/indicators/volume.rs

@@ -6,7 +6,7 @@ use iced::{mouse, Point, Rectangle, Renderer, Size, Theme, Vector};
 use iced::widget::canvas::{self, Cache, Event, Geometry, LineDash, Path, Stroke};
 
 use crate::charts::{
-    calc_price_step, convert_to_qty_abbr, round_to_tick, AxisLabel, Caches, CommonChartData, Interaction, Label, Message
+    round_to_tick, Caches, CommonChartData, Interaction, Message
 };
 use crate::data_providers::format_with_commas;
 
@@ -38,7 +38,7 @@ pub fn create_indicator_elem<'a>(
         .max_by(|a, b| a.partial_cmp(b).unwrap())
         .unwrap_or(0.0);
 
-    let indi_labels = Canvas::new(VolumeLabels {
+    let indi_labels = Canvas::new(super::IndicatorLabel {
         label_cache: &cache.y_labels,
         max: max_volume,
         min: 0.0,
@@ -153,16 +153,6 @@ impl canvas::Program<Message> for VolumeIndicator<'_> {
 
             let region = self.visible_region(frame.size());
 
-            frame.fill_rectangle(
-                Point::new(region.x, 0.0),
-                Size::new(region.width, 1.0 / self.scaling),
-                if palette.is_dark {
-                    palette.background.weak.color.scale_alpha(0.2)
-                } else {
-                    palette.background.strong.color.scale_alpha(0.2)
-                },
-            );
-
             let (earliest, latest) = (
                 self.x_to_time(region.x) - i64::from(self.timeframe / 2),
                 self.x_to_time(region.x + region.width) + i64::from(self.timeframe / 2),
@@ -349,159 +339,4 @@ impl canvas::Program<Message> for VolumeIndicator<'_> {
             _ => mouse::Interaction::default(),
         }
     }
-}
-
-pub struct VolumeLabels<'a> {
-    pub label_cache: &'a Cache,
-    pub crosshair: bool,
-    pub max: f32,
-    pub min: f32,
-    pub chart_bounds: Rectangle,
-}
-
-impl canvas::Program<Message> for VolumeLabels<'_> {
-    type State = Interaction;
-
-    fn update(
-        &self,
-        _state: &mut Self::State,
-        _event: Event,
-        _bounds: Rectangle,
-        _cursor: mouse::Cursor,
-    ) -> Option<canvas::Action<Message>> {
-        None
-    }
-
-    fn draw(
-        &self,
-        _state: &Self::State,
-        renderer: &Renderer,
-        theme: &Theme,
-        bounds: Rectangle,
-        cursor: mouse::Cursor,
-    ) -> Vec<Geometry> {
-        let palette = theme.extended_palette();
-
-        let highest = self.max;
-        let lowest = self.min;
-
-        let text_size = 12.0;
-
-        let labels = self.label_cache.draw(renderer, bounds.size(), |frame| {
-            frame.fill_rectangle(
-                Point::new(0.0, 0.0),
-                Size::new(bounds.width, 1.0),
-                if palette.is_dark {
-                    palette.background.weak.color.scale_alpha(0.2)
-                } else {
-                    palette.background.strong.color.scale_alpha(0.2)
-                },
-            );
-
-            frame.fill_rectangle(
-                Point::new(0.0, 0.0),
-                Size::new(1.0, bounds.height),
-                if palette.is_dark {
-                    palette.background.weak.color.scale_alpha(0.4)
-                } else {
-                    palette.background.strong.color.scale_alpha(0.4)
-                },
-            );
-
-            let y_range = highest - lowest;
-
-            let y_labels_can_fit: i32 = (bounds.height / (text_size * 2.0)) as i32;
-
-            let mut all_labels: Vec<AxisLabel> =
-                Vec::with_capacity((y_labels_can_fit + 2) as usize); // +2 for last_price and crosshair
-
-            let rect = |y_pos: f32, label_amt: i16| {
-                let label_offset = text_size + (f32::from(label_amt) * (text_size / 2.0) + 2.0);
-
-                Rectangle {
-                    x: 6.0,
-                    y: y_pos - label_offset / 2.0,
-                    width: bounds.width - 8.0,
-                    height: label_offset,
-                }
-            };
-
-            // Regular price labels (priority 1)
-            let (step, rounded_lowest) = calc_price_step(highest, lowest, y_labels_can_fit, 1.0);
-
-            let mut y = rounded_lowest;
-
-            while y <= highest {
-                let y_position = bounds.height - ((y - lowest) / y_range * bounds.height);
-
-                if y > 0.0 {
-                    let text_content = convert_to_qty_abbr(y);
-
-                    let label = Label {
-                        content: text_content,
-                        background_color: None,
-                        marker_color: if palette.is_dark {
-                            palette.background.weak.color.scale_alpha(0.6)
-                        } else {
-                            palette.background.strong.color.scale_alpha(0.6)
-                        },
-                        text_color: palette.background.base.text,
-                        text_size: 12.0,
-                    };
-
-                    all_labels.push(AxisLabel::Y(rect(y_position, 1), label, None));
-                }
-
-                y += step;
-            }
-
-            // Crosshair price (priority 3)
-            if self.crosshair {
-                let common_bounds = Rectangle {
-                    x: self.chart_bounds.x,
-                    y: bounds.y,
-                    width: self.chart_bounds.width,
-                    height: bounds.height,
-                };
-
-                if let Some(crosshair_pos) = cursor.position_in(common_bounds) {
-                    let raw_price =
-                        lowest + (y_range * (bounds.height - crosshair_pos.y) / bounds.height);
-                    let rounded_price = round_to_tick(raw_price, 1.0);
-                    let y_position =
-                        bounds.height - ((rounded_price - lowest) / y_range * bounds.height);
-
-                    let text_content = convert_to_qty_abbr(rounded_price);
-
-                    let label = Label {
-                        content: text_content,
-                        background_color: Some(palette.secondary.base.color),
-                        marker_color: palette.background.strong.color,
-                        text_color: palette.secondary.base.text,
-                        text_size: 12.0,
-                    };
-
-                    all_labels.push(AxisLabel::Y(rect(y_position, 1), label, None));
-                }
-            }
-
-            AxisLabel::filter_and_draw(&all_labels, frame);
-        });
-
-        vec![labels]
-    }
-
-    fn mouse_interaction(
-        &self,
-        interaction: &Interaction,
-        bounds: Rectangle,
-        cursor: mouse::Cursor,
-    ) -> mouse::Interaction {
-        match interaction {
-            Interaction::Zoomin { .. } => mouse::Interaction::ResizingVertically,
-            Interaction::Panning { .. } => mouse::Interaction::None,
-            Interaction::None if cursor.is_over(bounds) => mouse::Interaction::ResizingVertically,
-            _ => mouse::Interaction::default(),
-        }
-    }
-}
+}

+ 674 - 0
src/charts/scales.rs

@@ -0,0 +1,674 @@
+pub mod linear;
+pub mod timeseries;
+
+use chrono::DateTime;
+use iced::{mouse, widget::canvas::{self, Cache, Frame, Geometry}, Alignment, Color, Event, Point, Rectangle, Renderer, Size, Theme};
+
+use crate::screen::UserTimezone;
+
+use super::{Interaction, Message, round_to_tick};
+
+/// calculates `Rectangle` from given content, clamps it within bounds if needed
+pub fn calc_label_rect(
+    y_pos: f32,
+    content_amt: i16,
+    text_size: f32,
+    bounds: Rectangle,
+) -> Rectangle {
+    let content_amt = content_amt.max(1);
+    let label_height = text_size + (f32::from(content_amt) * (text_size / 2.0) + 4.0);
+    
+    let rect = Rectangle {
+        x: 1.0,
+        y: y_pos - label_height / 2.0,
+        width: bounds.width - 1.0,
+        height: label_height,
+    };
+
+    // clamp when label is partially visible within bounds
+    if rect.y < bounds.height && rect.y + label_height > 0.0 {
+        Rectangle {
+            y: rect.y.clamp(0.0, (bounds.height - label_height).max(0.0)),
+            ..rect
+        }
+    } else {
+        rect
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct Label {
+    pub content: String,
+    pub background_color: Option<Color>,
+    pub text_color: Color,
+    pub text_size: f32,
+}
+
+#[derive(Debug, Clone)]
+pub enum AxisLabel {
+    X(Rectangle, Label),
+    Y(Rectangle, Label, Option<Label>),
+}
+
+impl AxisLabel {
+    fn intersects(&self, other: &AxisLabel) -> bool {
+        match (self, other) {
+            (AxisLabel::X(self_rect, ..), AxisLabel::X(other_rect, ..)) => {
+                self_rect.intersects(other_rect)
+            }
+            (AxisLabel::Y(self_rect, ..), AxisLabel::Y(other_rect, ..)) => {
+                self_rect.intersects(other_rect)
+            }
+            _ => false,
+        }
+    }
+
+    pub fn filter_and_draw(labels: &[AxisLabel], frame: &mut Frame) {
+        for i in (0..labels.len()).rev() {
+            let should_draw = labels[i + 1..]
+                .iter()
+                .all(|existing| !existing.intersects(&labels[i]));
+
+            if should_draw {
+                labels[i].draw(frame);
+            }
+        }
+    }
+
+    fn draw(&self, frame: &mut Frame) {
+        match self {
+            AxisLabel::X(rect, label) => {
+                if let Some(background_color) = label.background_color {
+                    frame.fill_rectangle(
+                        Point::new(rect.x, rect.y),
+                        Size::new(rect.width, rect.height),
+                        background_color,
+                    );
+                }
+
+                let label = canvas::Text {
+                    content: label.content.clone(),
+                    position: rect.center(),
+                    color: label.text_color,
+                    vertical_alignment: Alignment::Center.into(),
+                    horizontal_alignment: Alignment::Center.into(),
+                    size: label.text_size.into(),
+                    ..canvas::Text::default()
+                };
+
+                frame.fill_text(label);
+            }
+            AxisLabel::Y(rect, price_label, timer_label) => {
+                if let Some(background_color) = price_label.background_color {
+                    frame.fill_rectangle(
+                        Point::new(rect.x, rect.y),
+                        Size::new(rect.width, rect.height),
+                        background_color,
+                    );
+                }
+
+                if let Some(timer_label) = timer_label {
+                    let price_label = canvas::Text {
+                        content: price_label.content.clone(),
+                        position: Point::new(rect.x + 4.0, rect.center_y() - 6.0),
+                        color: price_label.text_color,
+                        size: price_label.text_size.into(),
+                        vertical_alignment: Alignment::Center.into(),
+                        ..canvas::Text::default()
+                    };
+
+                    frame.fill_text(price_label);
+
+                    let timer_label = canvas::Text {
+                        content: timer_label.content.clone(),
+                        position: Point::new(rect.x + 4.0, rect.center_y() + 6.0),
+                        color: timer_label.text_color,
+                        size: timer_label.text_size.into(),
+                        vertical_alignment: Alignment::Center.into(),
+                        ..canvas::Text::default()
+                    };
+
+                    frame.fill_text(timer_label);
+                } else {
+                    let price_label = canvas::Text {
+                        content: price_label.content.clone(),
+                        position: Point::new(rect.x + 4.0, rect.center_y()),
+                        color: price_label.text_color,
+                        size: price_label.text_size.into(),
+                        vertical_alignment: Alignment::Center.into(),
+                        ..canvas::Text::default()
+                    };
+
+                    frame.fill_text(price_label);
+                }
+            }
+        }
+    }
+}
+
+// X-AXIS LABELS
+pub struct AxisLabelsX<'a> {
+    pub labels_cache: &'a Cache,
+    pub crosshair: bool,
+    pub max: i64,
+    pub scaling: f32,
+    pub translation_x: f32,
+    pub timeframe: u32,
+    pub cell_width: f32,
+    pub timezone: &'a UserTimezone,
+    pub chart_bounds: Rectangle,
+}
+
+impl AxisLabelsX<'_> {
+    fn visible_region(&self, size: Size) -> Rectangle {
+        let width = size.width / self.scaling;
+        let height = size.height / self.scaling;
+
+        Rectangle {
+            x: -self.translation_x - width / 2.0,
+            y: 0.0,
+            width,
+            height,
+        }
+    }
+
+    fn x_to_time(&self, x: f32) -> i64 {
+        let time_per_cell = self.timeframe;
+        self.max + ((x / self.cell_width) * time_per_cell as f32) as i64
+    }
+}
+
+impl canvas::Program<Message> for AxisLabelsX<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        interaction: &mut Interaction,
+        event: Event,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Option<canvas::Action<Message>> {
+        if let Event::Mouse(mouse::Event::ButtonReleased(_)) = event {
+            *interaction = Interaction::None;
+        }
+
+        let cursor_position = cursor.position_in(bounds)?;
+
+        if let Event::Mouse(mouse_event) = event {
+            match mouse_event {
+                mouse::Event::ButtonPressed(mouse::Button::Left) => {
+                    *interaction = Interaction::Zoomin {
+                        last_position: cursor_position,
+                    };
+                }
+                mouse::Event::CursorMoved { .. } => {
+                    if let Interaction::Zoomin {
+                        ref mut last_position,
+                    } = *interaction
+                    {
+                        let difference_x = last_position.x - cursor_position.x;
+
+                        if difference_x.abs() > 1.0 {
+                            *last_position = cursor_position;
+
+                            let message = Message::XScaling(difference_x * 0.2, 0.0, false);
+
+                            return Some(canvas::Action::publish(message).and_capture());
+                        }
+                    }
+                }
+                mouse::Event::WheelScrolled { delta } => match delta {
+                    mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
+                        let message = Message::XScaling(
+                            y,
+                            {
+                                if let Some(cursor_to_center) =
+                                    cursor.position_from(bounds.center())
+                                {
+                                    cursor_to_center.x
+                                } else {
+                                    0.0
+                                }
+                            },
+                            true,
+                        );
+
+                        return Some(canvas::Action::publish(message).and_capture());
+                    }
+                },
+                _ => {}
+            }
+        }
+
+        None
+    }
+
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        theme: &Theme,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        let text_size = 12.0;
+
+        let palette = theme.extended_palette();
+
+        let labels = self.labels_cache.draw(renderer, bounds.size(), |frame| {
+            let region = self.visible_region(frame.size());
+
+            let earliest_in_millis = self.x_to_time(region.x);
+            let latest_in_millis = self.x_to_time(region.x + region.width);
+
+            let x_labels_can_fit = (bounds.width / 192.0) as i32;
+
+            let mut all_labels: Vec<AxisLabel> = Vec::with_capacity(x_labels_can_fit as usize + 1); // +1 for crosshair
+
+            // Regular time labels (priority 1)
+            let (time_step, rounded_earliest) = timeseries::calc_time_step(
+                earliest_in_millis,
+                latest_in_millis,
+                x_labels_can_fit,
+                self.timeframe,
+            );
+            let mut time: i64 = rounded_earliest;
+
+            while time <= latest_in_millis {
+                let x_position = ((time - earliest_in_millis) as f64
+                    / (latest_in_millis - earliest_in_millis) as f64)
+                    * f64::from(bounds.width);
+
+                if x_position >= 0.0 && x_position <= f64::from(bounds.width) {
+                    if let Some(time_as_datetime) = DateTime::from_timestamp(time / 1000, 0) {
+                        let text_content = match self.timezone {
+                            UserTimezone::Local => {
+                                let time_with_zone = time_as_datetime.with_timezone(&chrono::Local);
+
+                                if self.timeframe < 10000 {
+                                    time_with_zone.format("%M:%S").to_string()
+                                } else if time_with_zone.format("%H:%M").to_string() == "00:00" {
+                                    time_with_zone.format("%-d").to_string()
+                                } else {
+                                    time_with_zone.format("%H:%M").to_string()
+                                }
+                            }
+                            UserTimezone::Utc => {
+                                let time_with_zone = time_as_datetime.with_timezone(&chrono::Utc);
+
+                                if self.timeframe < 10000 {
+                                    time_with_zone.format("%M:%S").to_string()
+                                } else if time_with_zone.format("%H:%M").to_string() == "00:00" {
+                                    time_with_zone.format("%-d").to_string()
+                                } else {
+                                    time_with_zone.format("%H:%M").to_string()
+                                }
+                            }
+                        };
+
+                        let content_width = text_content.len() as f32 * (text_size / 3.0);
+
+                        let rect = Rectangle {
+                            x: (x_position as f32) - content_width,
+                            y: 4.0,
+                            width: 2.0 * content_width,
+                            height: bounds.height - 8.0,
+                        };
+
+                        let label = Label {
+                            content: text_content,
+                            background_color: None,
+                            text_color: palette.background.base.text,
+                            text_size: 12.0,
+                        };
+
+                        all_labels.push(AxisLabel::X(rect, label));
+                    }
+                }
+                time += time_step;
+            }
+
+            // Crosshair label (priority 2)
+            if self.crosshair {
+                if let Some(crosshair_pos) = cursor.position_in(self.chart_bounds) {
+                    let crosshair_ratio = f64::from(crosshair_pos.x) / f64::from(bounds.width);
+                    let crosshair_millis = earliest_in_millis as f64
+                        + crosshair_ratio * (latest_in_millis - earliest_in_millis) as f64;
+
+                    let (snap_ratio, text_content) = {
+                        if let Some(crosshair_time) =
+                            DateTime::from_timestamp_millis(crosshair_millis as i64)
+                        {
+                            let rounded_timestamp = (crosshair_time.timestamp_millis() as f64
+                                / f64::from(self.timeframe))
+                            .round() as i64
+                                * i64::from(self.timeframe);
+
+                            if let Some(rounded_time) =
+                                DateTime::from_timestamp_millis(rounded_timestamp)
+                            {
+                                let snap_ratio = (rounded_timestamp as f64
+                                    - earliest_in_millis as f64)
+                                    / (latest_in_millis as f64 - earliest_in_millis as f64);
+
+                                (snap_ratio, {
+                                    if self.timeframe < 10000 {
+                                        rounded_time
+                                            .format("%M:%S:%3f")
+                                            .to_string()
+                                            .replace('.', "")
+                                    } else {
+                                        match self.timezone {
+                                            UserTimezone::Local => rounded_time
+                                                .with_timezone(&chrono::Local)
+                                                .format("%a %b %-d  %H:%M")
+                                                .to_string(),
+                                            UserTimezone::Utc => rounded_time
+                                                .with_timezone(&chrono::Utc)
+                                                .format("%a %b %-d  %H:%M")
+                                                .to_string(),
+                                        }
+                                    }
+                                })
+                            } else {
+                                (0.0, String::new())
+                            }
+                        } else {
+                            (0.0, String::new())
+                        }
+                    };
+
+                    let snap_x = snap_ratio * f64::from(bounds.width);
+
+                    if snap_x.is_nan() {
+                        return;
+                    }
+
+                    let content_width = text_content.len() as f32 * (text_size / 3.0);
+
+                    let rect = Rectangle {
+                        x: (snap_x as f32) - content_width,
+                        y: 4.0,
+                        width: 2.0 * (content_width),
+                        height: bounds.height - 8.0,
+                    };
+
+                    let label = Label {
+                        content: text_content,
+                        background_color: Some(palette.secondary.base.color),
+                        text_color: palette.secondary.base.text,
+                        text_size: 12.0,
+                    };
+
+                    all_labels.push(AxisLabel::X(rect, label));
+                }
+            }
+
+            AxisLabel::filter_and_draw(&all_labels, frame);
+        });
+
+        vec![labels]
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Panning { .. } => mouse::Interaction::None,
+            Interaction::Zoomin { .. } => mouse::Interaction::ResizingHorizontally,
+            Interaction::None if cursor.is_over(bounds) => mouse::Interaction::ResizingHorizontally,
+            _ => mouse::Interaction::default(),
+        }
+    }
+}
+
+// Y-AXIS LABELS
+pub struct AxisLabelsY<'a> {
+    pub labels_cache: &'a Cache,
+    pub crosshair: bool,
+    pub translation_y: f32,
+    pub scaling: f32,
+    pub min: f32,
+    pub last_price: Option<PriceInfoLabel>,
+    pub tick_size: f32,
+    pub decimals: usize,
+    pub cell_height: f32,
+    pub timeframe: u32,
+    pub chart_bounds: Rectangle,
+}
+
+impl AxisLabelsY<'_> {
+    fn visible_region(&self, size: Size) -> Rectangle {
+        let width = size.width / self.scaling;
+        let height = size.height / self.scaling;
+
+        Rectangle {
+            x: 0.0,
+            y: -self.translation_y - height / 2.0,
+            width,
+            height,
+        }
+    }
+
+    fn y_to_price(&self, y: f32) -> f32 {
+        self.min - (y / self.cell_height) * self.tick_size
+    }
+}
+
+impl canvas::Program<Message> for AxisLabelsY<'_> {
+    type State = Interaction;
+
+    fn update(
+        &self,
+        interaction: &mut Interaction,
+        event: Event,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Option<canvas::Action<Message>> {
+        if let Event::Mouse(mouse::Event::ButtonReleased(_)) = event {
+            *interaction = Interaction::None;
+        }
+
+        let cursor_position = cursor.position_in(bounds)?;
+
+        if let Event::Mouse(mouse_event) = event {
+            match mouse_event {
+                mouse::Event::ButtonPressed(mouse::Button::Left) => {
+                    *interaction = Interaction::Zoomin {
+                        last_position: cursor_position,
+                    };
+                }
+                mouse::Event::CursorMoved { .. } => {
+                    if let Interaction::Zoomin {
+                        ref mut last_position,
+                    } = *interaction
+                    {
+                        let difference_y = last_position.y - cursor_position.y;
+
+                        if difference_y.abs() > 1.0 {
+                            *last_position = cursor_position;
+
+                            let message = Message::YScaling(difference_y * 0.4, 0.0, false);
+
+                            return Some(canvas::Action::publish(message).and_capture());
+                        }
+                    }
+                }
+                mouse::Event::WheelScrolled { delta } => match delta {
+                    mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
+                        let message = Message::YScaling(
+                            y,
+                            {
+                                if let Some(cursor_to_center) =
+                                    cursor.position_from(bounds.center())
+                                {
+                                    cursor_to_center.y
+                                } else {
+                                    0.0
+                                }
+                            },
+                            true,
+                        );
+
+                        return Some(canvas::Action::publish(message).and_capture());
+                    }
+                },
+                _ => {}
+            }
+        }
+
+        None
+    }
+
+    fn draw(
+        &self,
+        _state: &Self::State,
+        renderer: &Renderer,
+        theme: &Theme,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> Vec<Geometry> {
+        let text_size = 12.0;
+
+        let palette = theme.extended_palette();
+
+        let labels = self.labels_cache.draw(renderer, bounds.size(), |frame| {
+            let region = self.visible_region(frame.size());
+
+            frame.fill_rectangle(
+                Point::new(0.0, 0.0),
+                Size::new(1.0, bounds.height),
+                if palette.is_dark {
+                    palette.background.weak.color.scale_alpha(0.4)
+                } else {
+                    palette.background.strong.color.scale_alpha(0.4)
+                },
+            );
+
+            let highest = self.y_to_price(region.y);
+            let lowest = self.y_to_price(region.y + region.height);
+
+            let range = highest - lowest;
+
+            let mut all_labels = linear::generate_labels(
+                bounds,
+                lowest,
+                highest,
+                text_size,
+                palette.background.base.text,
+                self.tick_size,
+                Some(self.decimals),
+            );
+
+            // Last price (priority 2)
+            if let Some(last_price) = self.last_price {
+                let (price, color) = match last_price {
+                    PriceInfoLabel::Up(price) => (price, palette.success.base.color),
+                    PriceInfoLabel::Down(price) => (price, palette.danger.base.color),
+                };
+
+                let candle_close_label = {
+                    let current_time = chrono::Utc::now().timestamp_millis();
+                    let next_kline_open =
+                        (current_time / i64::from(self.timeframe) + 1) * i64::from(self.timeframe);
+
+                    let remaining_seconds = (next_kline_open - current_time) / 1000;
+                    let hours = remaining_seconds / 3600;
+                    let minutes = (remaining_seconds % 3600) / 60;
+                    let seconds = remaining_seconds % 60;
+
+                    let time_format = if hours > 0 {
+                        format!("{hours:02}:{minutes:02}:{seconds:02}")
+                    } else {
+                        format!("{minutes:02}:{seconds:02}")
+                    };
+
+                    Label {
+                        content: time_format,
+                        background_color: Some(palette.background.strong.color),
+                        text_color: if palette.is_dark {
+                            Color::BLACK.scale_alpha(0.8)
+                        } else {
+                            Color::WHITE.scale_alpha(0.8)
+                        },
+                        text_size: 11.0,
+                    }
+                };
+
+                let price_label = Label {
+                    content: format!("{:.*}", self.decimals, price),
+                    background_color: Some(color),
+                    text_color: if palette.is_dark {
+                        Color::BLACK
+                    } else {
+                        Color::WHITE
+                    },
+                    text_size: 12.0,
+                };
+
+                let y_position = bounds.height - ((price - lowest) / range * bounds.height);
+
+                all_labels.push(AxisLabel::Y(
+                    calc_label_rect(y_position, 2, text_size, bounds),
+                    price_label,
+                    Some(candle_close_label),
+                ));
+            }
+
+            // Crosshair price (priority 3)
+            if self.crosshair {
+                if let Some(crosshair_pos) = cursor.position_in(self.chart_bounds) {
+                    let rounded_price = round_to_tick(
+                        lowest + (range * (bounds.height - crosshair_pos.y) / bounds.height), 
+                        self.tick_size
+                    );
+                    let y_position =
+                        bounds.height - ((rounded_price - lowest) / range * bounds.height);
+
+                    let label = Label {
+                        content: format!("{:.*}", self.decimals, rounded_price),
+                        background_color: Some(palette.secondary.base.color),
+                        text_color: palette.secondary.base.text,
+                        text_size: 12.0,
+                    };
+
+                    all_labels.push(
+                        AxisLabel::Y(
+                            calc_label_rect(y_position, 1, text_size, bounds),
+                            label, 
+                            None
+                        )
+                    );
+                }
+            }
+
+            AxisLabel::filter_and_draw(&all_labels, frame);
+        });
+
+        vec![labels]
+    }
+
+    fn mouse_interaction(
+        &self,
+        interaction: &Interaction,
+        bounds: Rectangle,
+        cursor: mouse::Cursor,
+    ) -> mouse::Interaction {
+        match interaction {
+            Interaction::Zoomin { .. } => mouse::Interaction::ResizingVertically,
+            Interaction::Panning { .. } => mouse::Interaction::None,
+            Interaction::None if cursor.is_over(bounds) => mouse::Interaction::ResizingVertically,
+            _ => mouse::Interaction::default(),
+        }
+    }
+}
+
+// other helpers
+#[derive(Debug, Clone, Copy)]
+pub enum PriceInfoLabel {
+    Up(f32),
+    Down(f32),
+}

+ 125 - 0
src/charts/scales/linear.rs

@@ -0,0 +1,125 @@
+use super::{
+    AxisLabel, Label, 
+    super::abbr_large_numbers,
+    calc_label_rect,
+};
+
+fn calc_optimal_ticks(
+    highest: f32, 
+    lowest: f32, 
+    labels_can_fit: i32, 
+    tick_size: f32
+) -> (f32, f32) {
+    let range = highest - lowest;
+    let labels = labels_can_fit.max(1) as f32;
+
+    let base = 10.0f32.powf(range.log10().floor());
+
+    let step = if range / (0.1 * base) <= labels {
+        0.1 * base
+    } else if range / (0.2 * base) <= labels {
+        0.2 * base
+    } else if range / (0.5 * base) <= labels {
+        0.5 * base
+    } else if range / base <= labels {
+        base
+    } else if range / (2.0 * base) <= labels {
+        2.0 * base
+    } else {
+        (range / labels).min(5.0 * base)
+    };
+
+    let rounded_highest = (highest / step).ceil() * step;
+    let rounded_highest = (rounded_highest / tick_size).round() * tick_size;
+    let rounded_highest = rounded_highest.min(highest + step);
+
+    (step, rounded_highest)
+}
+
+pub fn generate_labels(
+    bounds: iced::Rectangle,
+    lowest: f32,
+    highest: f32,
+    text_size: f32,
+    text_color: iced::Color,
+    tick_size: f32,
+    decimals: Option<usize>,
+) -> Vec<AxisLabel> {
+    let labels_can_fit = (bounds.height / (text_size * 3.0)) as i32;
+
+    if labels_can_fit <= 1 {
+        let rounded = (highest / tick_size).round() * tick_size;
+        let label = Label {
+            content: if let Some(decimals) = decimals {
+                format!("{:.*}", decimals, rounded)
+            } else {
+                abbr_large_numbers(rounded)
+            },
+            background_color: None,
+            text_color,
+            text_size,
+        };
+
+        return vec![AxisLabel::Y(
+            calc_label_rect(0.0, 1, text_size, bounds),
+            label,
+            None,
+        )];
+    }
+
+    let (step, max) = calc_optimal_ticks(highest, lowest, labels_can_fit, tick_size);
+    
+    let mut labels = Vec::with_capacity((labels_can_fit + 2) as usize);
+
+    let mut value = max;
+    while value >= lowest {
+        let label = Label {
+            content: {
+                if let Some(decimals) = decimals {
+                    format!("{:.*}", decimals, value)
+                } else {
+                    abbr_large_numbers(value)
+                }
+            },
+            background_color: None,
+            text_color,
+            text_size,
+        };
+
+        let label_pos = bounds.height - ((value - lowest) / (highest - lowest) * bounds.height);
+
+        labels.push(AxisLabel::Y(
+            calc_label_rect(label_pos, 1, text_size, bounds),
+            label,
+            None,
+        ));
+
+        value -= step;
+    }
+
+    labels
+}
+
+#[test]
+fn test_generate_labels() {
+    let bounds = iced::Rectangle {
+        x: 0.0,
+        y: 0.0,
+        width: 100.0,
+        height: 10.0,
+    };
+    let lowest = 0.0;
+    let highest = 100.0;
+    let text_size = 12.0;
+    let text_color = iced::Color::BLACK;
+    let tick_size = 1.0;
+    let decimals = Some(2);
+
+    let labels = generate_labels(bounds, lowest, highest, text_size, text_color, tick_size, decimals);
+
+    for label in labels {
+        if let AxisLabel::Y(_, label, _) = label {
+            println!("{}", label.content);
+        }
+    }
+}

+ 91 - 0
src/charts/scales/timeseries.rs

@@ -0,0 +1,91 @@
+const M1_TIME_STEPS: [i64; 9] = [
+    1000 * 60 * 720, // 12 hour
+    1000 * 60 * 180, // 3 hour
+    1000 * 60 * 60,  // 1 hour
+    1000 * 60 * 30,  // 30 min
+    1000 * 60 * 15,  // 15 min
+    1000 * 60 * 10,  // 10 min
+    1000 * 60 * 5,   // 5 min
+    1000 * 60 * 2,   // 2 min
+    60 * 1000,       // 1 min
+];
+
+const M3_TIME_STEPS: [i64; 9] = [
+    1000 * 60 * 1440, // 24 hour
+    1000 * 60 * 720,  // 12 hour
+    1000 * 60 * 180,  // 6 hour
+    1000 * 60 * 120,  // 2 hour
+    1000 * 60 * 60,   // 1 hour
+    1000 * 60 * 30,   // 30 min
+    1000 * 60 * 15,   // 15 min
+    1000 * 60 * 9,    // 9 min
+    1000 * 60 * 3,    // 3 min
+];
+
+const M5_TIME_STEPS: [i64; 9] = [
+    1000 * 60 * 1440, // 24 hour
+    1000 * 60 * 720,  // 12 hour
+    1000 * 60 * 480,  // 8 hour
+    1000 * 60 * 240,  // 4 hour
+    1000 * 60 * 120,  // 2 hour
+    1000 * 60 * 60,   // 1 hour
+    1000 * 60 * 30,   // 30 min
+    1000 * 60 * 15,   // 15 min
+    1000 * 60 * 5,    // 5 min
+];
+
+const HOURLY_TIME_STEPS: [i64; 8] = [
+    1000 * 60 * 5760, // 96 hour
+    1000 * 60 * 2880, // 48 hour
+    1000 * 60 * 1440, // 24 hour
+    1000 * 60 * 720,  // 12 hour
+    1000 * 60 * 480,  // 8 hour
+    1000 * 60 * 240,  // 4 hour
+    1000 * 60 * 120,  // 2 hour
+    1000 * 60 * 60,   // 1 hour
+];
+
+const MS_TIME_STEPS: [i64; 8] = [
+    1000 * 30,
+    1000 * 10,
+    1000 * 5,
+    1000 * 2,
+    1000,
+    500,
+    200,
+    100,
+];
+
+pub fn calc_time_step(earliest: i64, latest: i64, labels_can_fit: i32, timeframe: u32) -> (i64, i64) {
+    let timeframe_in_min = timeframe / 60000;
+
+    let time_steps: &[i64] = match timeframe_in_min {
+        0_u32..1_u32 => &MS_TIME_STEPS,
+        1..=30 => match timeframe_in_min {
+            1 => &M1_TIME_STEPS,
+            3 => &M3_TIME_STEPS,
+            5 => &M5_TIME_STEPS,
+            15 => &M5_TIME_STEPS[..7],
+            30 => &M5_TIME_STEPS[..6],
+            _ => &HOURLY_TIME_STEPS,
+        },
+        31.. => &HOURLY_TIME_STEPS,
+    };
+
+    let duration = latest - earliest;
+    let mut selected_step = time_steps[0];
+
+    for &step in time_steps {
+        if duration / step >= i64::from(labels_can_fit) {
+            selected_step = step;
+            break;
+        }
+        if step <= duration {
+            selected_step = step;
+        }
+    }
+
+    let rounded_earliest = (earliest / selected_step) * selected_step;
+
+    (selected_step, rounded_earliest)
+}

+ 9 - 3
src/data_providers.rs

@@ -50,9 +50,15 @@ pub enum StreamError {
 
 #[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
 pub struct TickerInfo {
+    pub ticker: Ticker,
     #[serde(rename = "tickSize")]
-    pub tick_size: f32,
-    pub market_type: MarketType,
+    pub min_ticksize: f32,
+}
+
+impl TickerInfo {
+    pub fn get_market_type(&self) -> MarketType {
+        self.ticker.market_type
+    }
 }
 
 #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
@@ -217,7 +223,7 @@ impl TickMultiplier {
     ///
     /// Usually used for price steps in chart scales
     pub fn multiply_with_min_tick_size(&self, ticker_info: TickerInfo) -> f32 {
-        let min_tick_size = ticker_info.tick_size;
+        let min_tick_size = ticker_info.min_ticksize;
 
         let multiplier = if let Some(m) = Decimal::from_f32(f32::from(self.0)) {
             m

+ 14 - 19
src/data_providers/binance.rs

@@ -765,16 +765,15 @@ pub async fn fetch_ticksize(market_type: MarketType) -> Result<HashMap<Ticker, O
     let re = Regex::new(r"^[a-zA-Z0-9]+$").unwrap();
 
     for symbol in symbols {
-        let ticker = symbol["symbol"]
+        let symbol_str = symbol["symbol"]
             .as_str()
-            .ok_or_else(|| StreamError::ParseError("Missing symbol".to_string()))?
-            .to_string();
+            .ok_or_else(|| StreamError::ParseError("Missing symbol".to_string()))?;
 
-        if !re.is_match(&ticker) {
+        if !re.is_match(&symbol_str) {
             continue;
         }
         
-        if !ticker.ends_with("USDT") {
+        if !symbol_str.ends_with("USDT") {
             continue;
         }
 
@@ -787,15 +786,17 @@ pub async fn fetch_ticksize(market_type: MarketType) -> Result<HashMap<Ticker, O
             .find(|x| x["filterType"].as_str().unwrap_or_default() == "PRICE_FILTER");
 
         if let Some(price_filter) = price_filter {
-            let tick_size = price_filter["tickSize"]
+            let min_ticksize = price_filter["tickSize"]
                 .as_str()
                 .ok_or_else(|| StreamError::ParseError("tickSize not found".to_string()))?
                 .parse::<f32>()
                 .map_err(|e| StreamError::ParseError(format!("Failed to parse tickSize: {e}")))?;
 
-            ticker_info_map.insert(Ticker::new(ticker, market_type), Some(TickerInfo { tick_size, market_type }));
+            let ticker = Ticker::new(symbol_str, market_type);
+
+            ticker_info_map.insert(Ticker::new(symbol_str, market_type), Some(TickerInfo { min_ticksize, ticker }));
         } else {
-            ticker_info_map.insert(Ticker::new(ticker, market_type), None);
+            ticker_info_map.insert(Ticker::new(symbol_str, market_type), None);
         }
     }
 
@@ -1097,21 +1098,15 @@ pub async fn fetch_historical_oi(
         let interval_ms = period.to_milliseconds() as i64;
         let num_intervals = ((end - start) / interval_ms).min(500);
 
-        if num_intervals < 3 {
-            let new_start = start - (interval_ms * 5);
-            let new_end = end + (interval_ms * 5);
-            let num_intervals = ((new_end - new_start) / interval_ms).min(1000);
-            
-            url.push_str(&format!(
-                "&startTime={new_start}&endTime={new_end}&limit={num_intervals}"
-            ));
-        } else {
+        if num_intervals > 1 {
             url.push_str(&format!(
                 "&startTime={start}&endTime={end}&limit={num_intervals}"
             ));
-        }     
+        } else {
+            url.push_str("&limit=200");
+        }
     } else {
-        url.push_str(&format!("&limit={}", 200));
+        url.push_str("&limit=200");
     }
 
     let response = reqwest::get(&url)

+ 26 - 7
src/data_providers/bybit.rs

@@ -429,8 +429,8 @@ pub fn connect_kline_stream(
                     }
                 }
                 State::Connected(websocket) => match websocket.read_frame().await {
-                    Ok(msg) => {
-                        if msg.opcode == OpCode::Text {
+                    Ok(msg) => match msg.opcode {
+                        OpCode::Text => {
                             if let Ok(StreamData::Kline(ticker, de_kline_vec)) =
                                 feed_de(&msg.payload[..], None, market_type)
                             {
@@ -459,6 +459,13 @@ pub fn connect_kline_stream(
                                 }
                             }
                         }
+                        OpCode::Close => {
+                            state = State::Disconnected;
+                            let _ = output
+                                .send(Event::Disconnected("Connection closed".to_string()))
+                                .await;
+                        }
+                        _ => {}
                     }
                     Err(e) => {
                         state = State::Disconnected;
@@ -511,9 +518,15 @@ pub async fn fetch_historical_oi(
         let interval_ms = period.to_milliseconds() as i64;
         let num_intervals = ((end - start) / interval_ms).min(200);
 
-        url.push_str(&format!("&startTime={start}&endTime={end}&limit={num_intervals}"));
+        if num_intervals > 1 {
+            url.push_str(&format!(
+                "&startTime={start}&endTime={end}&limit={num_intervals}"
+            ));
+        } else {
+            url.push_str("&limit=200");
+        }
     } else {
-        url.push_str(&format!("&limit={}", 200));
+        url.push_str("&limit=200");
     }
 
     let response = reqwest::get(&url)
@@ -549,7 +562,7 @@ pub async fn fetch_historical_oi(
             StreamError::ParseError(format!("Failed to parse open interest: {e}"))
         })?;
 
-    let open_interest = bybit_oi
+    let open_interest: Vec<OpenInterest> = bybit_oi
         .into_iter()
         .map(|x| OpenInterest {
             time: x.timestamp,
@@ -557,6 +570,10 @@ pub async fn fetch_historical_oi(
         })
         .collect();
 
+    if open_interest.is_empty() {
+        log::warn!("No open interest data found for {}, from url: {}", ticker_str, url);
+    }
+
     Ok(open_interest)
 }
 
@@ -680,13 +697,15 @@ pub async fn fetch_ticksize(market_type: MarketType) -> Result<HashMap<Ticker, O
             .as_object()
             .ok_or_else(|| StreamError::ParseError("Price filter not found".to_string()))?;
 
-        let tick_size = price_filter["tickSize"]
+        let min_ticksize = price_filter["tickSize"]
             .as_str()
             .ok_or_else(|| StreamError::ParseError("Tick size not found".to_string()))?
             .parse::<f32>()
             .map_err(|_| StreamError::ParseError("Failed to parse tick size".to_string()))?;
 
-        ticker_info_map.insert(Ticker::new(symbol, market_type), Some(TickerInfo { tick_size, market_type }));
+        let ticker = Ticker::new(symbol, market_type);
+
+        ticker_info_map.insert(ticker, Some(TickerInfo { min_ticksize, ticker }));
     }
 
     Ok(ticker_info_map)

+ 8 - 3
src/data_providers/fetcher.rs

@@ -33,7 +33,7 @@ impl RequestHandler {
         let request = FetchRequest::new(fetch);
         let id = Uuid::new_v4();
 
-        if let Some(r) = self.requests.values().find(|r| r.ends_same_with(&request)) {
+        if let Some(r) = self.requests.values().find(|r| r.same_with(&request)) {
             return match &r.status {
                 RequestStatus::Failed(error_msg) => Err(ReqError::Failed(error_msg.clone())),
                 RequestStatus::Completed(_) => Err(ReqError::Completed),
@@ -85,9 +85,14 @@ impl FetchRequest {
         }
     }
 
-    fn ends_same_with(&self, other: &FetchRequest) -> bool {
+    fn same_with(&self, other: &FetchRequest) -> bool {
         match (&self.fetch_type, &other.fetch_type) {
-            (FetchRange::Kline(_, e1), FetchRange::Kline(_, e2)) => e1 == e2,
+            (FetchRange::Kline(s1, e1), FetchRange::Kline(s2, e2)) => {
+                e1 == e2 && s1 == s2
+            },
+            (FetchRange::OpenInterest(s1, e1), FetchRange::OpenInterest(s2, e2)) => {
+                e1 == e2 && s1 == s2
+            },
             _ => false,
         }
     }

+ 22 - 4
src/layout.rs

@@ -281,6 +281,12 @@ impl Default for SerializableDashboard {
     }
 }
 
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct SerializableChartData {
+    pub crosshair: bool,
+    pub indicators_split: Option<f32>,
+}
+
 #[derive(Debug, Clone, Deserialize, Serialize)]
 pub enum SerializablePane {
     Split {
@@ -291,16 +297,19 @@ pub enum SerializablePane {
     },
     Starter,
     HeatmapChart {
+        layout: SerializableChartData,
         stream_type: Vec<StreamType>,
         settings: PaneSettings,
         indicators: Vec<HeatmapIndicator>,
     },
     FootprintChart {
+        layout: SerializableChartData,
         stream_type: Vec<StreamType>,
         settings: PaneSettings,
         indicators: Vec<FootprintIndicator>,
     },
     CandlestickChart {
+        layout: SerializableChartData,
         stream_type: Vec<StreamType>,
         settings: PaneSettings,
         indicators: Vec<CandlestickIndicator>,
@@ -317,17 +326,20 @@ impl From<&PaneState> for SerializablePane {
 
         match &pane.content {
             PaneContent::Starter => SerializablePane::Starter,
-            PaneContent::Heatmap(_, indicators) => SerializablePane::HeatmapChart {
+            PaneContent::Heatmap(chart, indicators) => SerializablePane::HeatmapChart {
+                layout: chart.get_chart_layout(),
                 stream_type: pane_stream,
                 settings: pane.settings,
                 indicators: indicators.clone(),
             },
-            PaneContent::Footprint(_, indicators) => SerializablePane::FootprintChart {
+            PaneContent::Footprint(chart, indicators) => SerializablePane::FootprintChart {
+                layout: chart.get_chart_layout(),
                 stream_type: pane_stream,
                 settings: pane.settings,
                 indicators: indicators.clone(),
             },
-            PaneContent::Candlestick(_, indicators) => SerializablePane::CandlestickChart {
+            PaneContent::Candlestick(chart, indicators) => SerializablePane::CandlestickChart {
+                layout: chart.get_chart_layout(),
                 stream_type: pane_stream,
                 settings: pane.settings,
                 indicators: indicators.clone(),
@@ -375,6 +387,7 @@ pub fn load_saved_state(file_path: &str) -> SavedState {
                         Configuration::Pane(PaneState::new(vec![], PaneSettings::default()))
                     }
                     SerializablePane::CandlestickChart {
+                        layout,
                         stream_type,
                         settings,
                         indicators,
@@ -384,9 +397,10 @@ pub fn load_saved_state(file_path: &str) -> SavedState {
                             Configuration::Pane(PaneState::from_config(
                                 PaneContent::Candlestick(
                                     CandlestickChart::new(
+                                        layout,
                                         vec![],
                                         timeframe,
-                                        ticker_info.tick_size,
+                                        ticker_info.min_ticksize,
                                         UserTimezone::default(),
                                         &indicators,
                                     ),
@@ -401,6 +415,7 @@ pub fn load_saved_state(file_path: &str) -> SavedState {
                         }
                     }
                     SerializablePane::FootprintChart {
+                        layout,
                         stream_type,
                         settings,
                         indicators,
@@ -413,6 +428,7 @@ pub fn load_saved_state(file_path: &str) -> SavedState {
                             Configuration::Pane(PaneState::from_config(
                                 PaneContent::Footprint(
                                     FootprintChart::new(
+                                        layout,
                                         timeframe,
                                         tick_size,
                                         vec![],
@@ -431,6 +447,7 @@ pub fn load_saved_state(file_path: &str) -> SavedState {
                         }
                     }
                     SerializablePane::HeatmapChart {
+                        layout,
                         stream_type,
                         settings,
                         indicators,
@@ -443,6 +460,7 @@ pub fn load_saved_state(file_path: &str) -> SavedState {
                             Configuration::Pane(PaneState::from_config(
                                 PaneContent::Heatmap(
                                     HeatmapChart::new(
+                                        layout,
                                         tick_size,
                                         100,
                                         UserTimezone::default(),

+ 1 - 0
src/main.rs

@@ -6,6 +6,7 @@ mod window;
 mod layout;
 mod logger;
 mod screen;
+mod widget;
 mod tooltip;
 mod tickers_table;
 mod data_providers;

+ 28 - 46
src/screen/dashboard.rs

@@ -395,8 +395,6 @@ impl Dashboard {
 
                         log::info!("{:?}", &self.pane_streams);
 
-                        let mut tasks = vec![];
-
                         // get fetch tasks for pane's content
                         if ["footprint", "candlestick", "heatmap"]
                             .contains(&content_str.as_str())
@@ -406,63 +404,44 @@ impl Dashboard {
                                     if ["candlestick", "footprint"]
                                         .contains(&content_str.as_str())
                                     {
-                                        tasks.push(
-                                            get_kline_fetch_task(
+                                        return get_kline_fetch_task(
                                             window, pane, *stream, None, None,
-                                            ).chain(
-                                            get_oi_fetch_task(
-                                            window, pane, *stream, None, None
-                                            ))
                                         );
                                     }
                                 }
                             }
                         }
-
-                        return Task::batch(tasks);
                     }
                     pane::Message::TimeframeSelected(timeframe, pane) => {
-                        let mut tasks = vec![];
-
                         self.notification_manager.clear(&window, &pane);
 
                         match self.set_pane_timeframe(main_window.id, window, pane, timeframe) {
                             Ok(stream_type) => {
                                 if let StreamType::Kline { .. } = stream_type {
-                                    tasks.push(get_kline_fetch_task(
+                                    let task = get_kline_fetch_task(
                                         window,
                                         pane,
                                         *stream_type,
                                         None,
                                         None,
-                                    ).chain(
-                                        get_oi_fetch_task(
-                                            window,
-                                            pane,
-                                            *stream_type,
-                                            None,
-                                            None,
-                                        ),
-                                    ));
+                                    );
 
                                     self.notification_manager.push(
                                         window,
                                         pane,
                                         Notification::Info(InfoType::FetchingKlines),
                                     );
+
+                                    return Task::done(Message::RefreshStreams)
+                                        .chain(task);
                                 }
                             }
                             Err(err) => {
-                                tasks.push(Task::perform(
-                                    async { err },
-                                    move |err: DashboardError| {
-                                        Message::ErrorOccurred(window, Some(pane), err)
-                                    },
-                                ));
+                                return Task::done(
+                                    Message::ErrorOccurred(window, Some(pane), err)
+                                );
                             }
                         }
-
-                        return Task::done(Message::RefreshStreams).chain(Task::batch(tasks));
                     }
                     pane::Message::TicksizeSelected(tick_multiply, pane) => {
                         self.notification_manager.clear(&window, &pane);
@@ -501,13 +480,11 @@ impl Dashboard {
                         }
                     }
                     Err(err) => {
-                        return Task::perform(async { err }, move |err: String| {
-                            Message::ErrorOccurred(
-                                window,
-                                Some(pane_id),
-                                DashboardError::Fetch(err),
-                            )
-                        })
+                        return Task::done(Message::ErrorOccurred(
+                            window, 
+                            Some(pane_id), 
+                            DashboardError::Fetch(err)
+                        ));
                     }
                 }
             }
@@ -528,13 +505,11 @@ impl Dashboard {
                             }
                         }
                         Err(err) => {
-                            return Task::perform(async { err }, move |err: String| {
-                                Message::ErrorOccurred(
-                                    window,
-                                    Some(pane_id),
-                                    DashboardError::Fetch(err),
-                                )
-                            })
+                            return Task::done(Message::ErrorOccurred(
+                                window,
+                                Some(pane_id),
+                                DashboardError::Fetch(err),
+                            ))
                         }
                     }
                 }
@@ -574,6 +549,7 @@ impl Dashboard {
                                         PaneContent::Candlestick(chart, indicators) => {
                                             let tick_size = chart.get_tick_size();
                                             *chart = CandlestickChart::new(
+                                                chart.get_chart_layout(),
                                                 klines.clone(),
                                                 timeframe,
                                                 tick_size,
@@ -585,6 +561,7 @@ impl Dashboard {
                                             let (raw_trades, tick_size) =
                                                 (chart.get_raw_trades(), chart.get_tick_size());
                                             *chart = FootprintChart::new(
+                                                chart.get_chart_layout(),
                                                 timeframe,
                                                 tick_size,
                                                 klines.clone(),
@@ -1160,8 +1137,13 @@ impl Dashboard {
                     *timeframe = new_timeframe;
                 }
 
-                match pane_state.content {
-                    PaneContent::Candlestick(_, _) | PaneContent::Footprint(_, _) => {
+                match &mut pane_state.content {
+                    PaneContent::Candlestick(chart, _) => {
+                        chart.set_loading_state(true);
+                        return Ok(stream_type);
+                    }
+                    PaneContent::Footprint(chart, _) => {
+                        chart.set_loading_state(true);
                         return Ok(stream_type);
                     }
                     _ => {}

+ 70 - 36
src/screen/dashboard/pane.rs

@@ -10,14 +10,9 @@ use crate::{
         self, candlestick::CandlestickChart, footprint::FootprintChart, heatmap::HeatmapChart, 
         indicators::{CandlestickIndicator, FootprintIndicator, HeatmapIndicator, Indicator}, 
         timeandsales::TimeAndSales
-    },
-    data_providers::{format_with_commas, Exchange, Kline, MarketType, OpenInterest, TickMultiplier, Ticker, TickerInfo, Timeframe},
-    screen::{
+    }, data_providers::{format_with_commas, Exchange, Kline, MarketType, OpenInterest, TickMultiplier, Ticker, TickerInfo, Timeframe}, layout::SerializableChartData, screen::{
         self, create_button, modal::{pane_menu, pane_notification}, DashboardError, InfoType, Notification, UserTimezone
-    },
-    style::{self, get_icon_text, Icon},
-    window::{self, Window},
-    StreamType,
+    }, style::{self, get_icon_text, Icon}, window::{self, Window}, StreamType
 };
 
 #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
@@ -88,7 +83,7 @@ impl PaneState {
             self.settings.tick_multiply = Some(multiplier);
             multiplier.multiply_with_min_tick_size(ticker_info)
         } else {
-            ticker_info.tick_size
+            ticker_info.min_ticksize
         }
     }
 
@@ -174,20 +169,23 @@ impl PaneState {
         &mut self, 
         ticker_info: TickerInfo,
         content_str: &str, 
-        timezone: UserTimezone
+        timezone: UserTimezone,
     ) -> Result<(), DashboardError> {
-        self.settings = PaneSettings::default();
-
         self.content = match content_str {
             "heatmap" => {
                 let tick_size = self.set_tickers_info(
                     Some(TickMultiplier(10)),
                     ticker_info,
                 );
+
                 let enabled_indicators = vec![HeatmapIndicator::Volume];
 
                 PaneContent::Heatmap(
                     HeatmapChart::new(
+                        SerializableChartData {
+                            crosshair: true,
+                            indicators_split: None,
+                        },
                         tick_size,
                         100,
                         timezone,
@@ -203,7 +201,7 @@ impl PaneState {
                 );
                 let timeframe = self.set_timeframe(Timeframe::M5);
                 let enabled_indicators = {
-                    if ticker_info.market_type == MarketType::LinearPerps {
+                    if ticker_info.get_market_type() == MarketType::LinearPerps {
                         vec![
                             FootprintIndicator::Volume,
                             FootprintIndicator::OpenInterest,
@@ -215,6 +213,10 @@ impl PaneState {
 
                 PaneContent::Footprint(
                     FootprintChart::new(
+                        SerializableChartData {
+                            crosshair: true,
+                            indicators_split: Some(0.8),
+                        },
                         timeframe,
                         tick_size,
                         vec![],
@@ -231,8 +233,9 @@ impl PaneState {
                     ticker_info,
                 );
                 let timeframe = self.set_timeframe(Timeframe::M15);
+
                 let enabled_indicators = {
-                    if ticker_info.market_type == MarketType::LinearPerps {
+                    if ticker_info.get_market_type() == MarketType::LinearPerps {
                         vec![
                             CandlestickIndicator::Volume,
                             CandlestickIndicator::OpenInterest,
@@ -244,6 +247,10 @@ impl PaneState {
 
                 PaneContent::Candlestick(
                     CandlestickChart::new(
+                        SerializableChartData {
+                            crosshair: true,
+                            indicators_split: Some(0.8),
+                        },
                         vec![],
                         timeframe,
                         tick_size,
@@ -263,7 +270,11 @@ impl PaneState {
         Ok(())
     }
 
-    pub fn insert_oi_vec(&mut self, req_id: Option<uuid::Uuid>, oi: Vec<OpenInterest>) {
+    pub fn insert_oi_vec(
+        &mut self, 
+        req_id: Option<uuid::Uuid>, 
+        oi: Vec<OpenInterest>
+    ) {
         match &mut self.content {
             PaneContent::Candlestick(chart, _) => {
                 chart.insert_open_interest(req_id, oi);
@@ -290,8 +301,10 @@ impl PaneState {
                     chart.insert_new_klines(id, klines);
                 } else {
                     let tick_size = chart.get_tick_size();
+                    let layout = chart.get_chart_layout();
 
                     *chart = CandlestickChart::new(
+                        layout,
                         klines.clone(), 
                         timeframe, 
                         tick_size, 
@@ -305,8 +318,10 @@ impl PaneState {
                     chart.insert_new_klines(id, klines);
                 } else {
                     let (raw_trades, tick_size) = (chart.get_raw_trades(), chart.get_tick_size());
+                    let layout = chart.get_chart_layout();
 
                     *chart = FootprintChart::new(
+                        layout,
                         timeframe,
                         tick_size,
                         klines.clone(),
@@ -368,7 +383,7 @@ impl PaneState {
                     button(text(
                         self.settings
                             .tick_multiply
-                            .unwrap_or(TickMultiplier(1))
+                            .unwrap_or(TickMultiplier(5))
                             .to_string(),
                     ))
                     .style(move |theme, status| {
@@ -383,8 +398,8 @@ impl PaneState {
                 stream_info_element = stream_info_element.push(
                     button(text(format!(
                         "{} - {}",
-                        self.settings.selected_timeframe.unwrap_or(Timeframe::M1),
-                        self.settings.tick_multiply.unwrap_or(TickMultiplier(1)),
+                        self.settings.selected_timeframe.unwrap_or(Timeframe::M5),
+                        self.settings.tick_multiply.unwrap_or(TickMultiplier(10)),
                     )))
                     .style(move |theme, status| {
                         style::button_modifier(theme, status, !is_stream_modifier)
@@ -399,7 +414,7 @@ impl PaneState {
                     button(text(
                         self.settings
                             .selected_timeframe
-                            .unwrap_or(Timeframe::M1)
+                            .unwrap_or(Timeframe::M15)
                             .to_string(),
                     ))
                     .style(move |theme, status| {
@@ -458,6 +473,13 @@ trait ChartView {
     ) -> Element<Message>;
 }
 
+#[derive(Debug, Clone, Copy)]
+enum StreamModifier {
+    CandlestickChart(Timeframe),
+    FootprintChart(Timeframe, TickMultiplier),
+    HeatmapChart(TickMultiplier),
+}
+
 fn handle_chart_view<'a, F>(
     underlay: Element<'a, Message>,
     state: &'a PaneState,
@@ -465,6 +487,7 @@ fn handle_chart_view<'a, F>(
     indicators: &'a [impl Indicator],
     settings_view: F,
     notifications: Option<&'a Vec<screen::Notification>>,
+    stream_modifier: StreamModifier,
 ) -> Element<'a, Message>
 where
     F: FnOnce() -> Element<'a, Message>,
@@ -478,7 +501,7 @@ where
             )
         )
     } else {
-        underlay.into()
+        underlay
     };
 
     match state.modal {
@@ -486,8 +509,7 @@ where
             base,
             stream_modifier_view(
                 pane,
-                state.settings.tick_multiply,
-                state.settings.selected_timeframe,
+                stream_modifier,
             ),
             Message::ToggleModal(pane, PaneModal::None),
             padding::left(36),
@@ -497,22 +519,21 @@ where
             base,
             indicators_view(
                 pane,
-                state.settings.ticker_info.map(|info| info.market_type),
+                state.settings.ticker_info
+                    .map(|info| info.get_market_type()),
                 indicators
             ),
             Message::ToggleModal(pane, PaneModal::None),
             padding::right(12).left(12),
             Alignment::End,
         ),
-        PaneModal::Settings => {
-            pane_menu(
-                base,
-                settings_view(),
-                Message::ToggleModal(pane, PaneModal::None),
-                padding::right(12).left(12),
-                Alignment::End,
-            )
-        },
+        PaneModal::Settings => pane_menu(
+            base,
+            settings_view(),
+            Message::ToggleModal(pane, PaneModal::None),
+            padding::right(12).left(12),
+            Alignment::End,
+        ),
         _ => base,
     }
 }
@@ -541,6 +562,9 @@ impl ChartView for HeatmapChart {
             indicators, 
             settings_view,
             notifications,
+            StreamModifier::HeatmapChart(
+                state.settings.tick_multiply.unwrap_or(TickMultiplier(10)),
+            ),
         )
     }
 }
@@ -568,6 +592,10 @@ impl ChartView for FootprintChart {
             indicators, 
             settings_view,
             notifications,
+            StreamModifier::FootprintChart(
+                state.settings.selected_timeframe.unwrap_or(Timeframe::M5),
+                state.settings.tick_multiply.unwrap_or(TickMultiplier(10)),
+            ),
         )
     }
 }
@@ -595,6 +623,9 @@ impl ChartView for CandlestickChart {
             indicators, 
             settings_view,
             notifications,
+            StreamModifier::CandlestickChart(
+                state.settings.selected_timeframe.unwrap_or(Timeframe::M15)
+            ),
         )
     }
 }
@@ -733,9 +764,14 @@ fn size_filter_view<'a>(
 
 fn stream_modifier_view<'a>(
     pane: pane_grid::Pane,
-    selected_ticksize: Option<TickMultiplier>,
-    selected_timeframe: Option<Timeframe>,
+    modifiers: StreamModifier,
 ) -> Element<'a, Message> {
+    let (selected_timeframe, selected_ticksize) = match modifiers {
+        StreamModifier::CandlestickChart(timeframe) => (Some(timeframe), None),
+        StreamModifier::FootprintChart(timeframe, ticksize) => (Some(timeframe), Some(ticksize)),
+        StreamModifier::HeatmapChart(ticksize) => (None, Some(ticksize)),
+    };
+
     let create_button = |content: String, msg: Option<Message>| {
         let btn = button(text(content))
             .width(Length::Fill)
@@ -886,9 +922,7 @@ fn view_chart<'a, C: ChartView, I: Indicator>(
     notifications: Option<&'a Vec<screen::Notification>>,
     indicators: &'a [I],
 ) -> Element<'a, Message> {
-    center(
-        content.view(pane, state, indicators, notifications)
-    ).into()
+    content.view(pane, state, indicators, notifications)
 }
 
 // Pane controls, title bar

+ 19 - 0
src/style.rs

@@ -72,6 +72,7 @@ pub fn custom_theme() -> Custom {
             primary: Color::from_rgb8(200, 200, 200),
             success: Color::from_rgb8(81, 205, 160),
             danger: Color::from_rgb8(192, 80, 77),
+            warning: Color::from_rgb8(238, 216, 139),
         },
     )
 }
@@ -682,4 +683,22 @@ pub fn scroll_bar(theme: &Theme, status: widget::scrollable::Status) -> widget::
         horizontal_rail: rail,
         gap: None,
     }
+}
+
+// custom widgets
+pub fn split_ruler(theme: &Theme) -> iced::widget::rule::Style {
+    let palette = theme.extended_palette();
+
+    iced::widget::rule::Style {
+        color: {
+            if palette.is_dark {
+                palette.background.weak.color.scale_alpha(0.2)
+            } else {
+                palette.background.strong.color.scale_alpha(0.2)
+            }
+        },
+        width: 1,
+        radius: iced::border::Radius::default(),
+        fill_mode: iced::widget::rule::FillMode::Full,
+    }
 }

+ 1 - 0
src/widget.rs

@@ -0,0 +1 @@
+pub mod hsplit;

+ 263 - 0
src/widget/hsplit.rs

@@ -0,0 +1,263 @@
+use iced::{
+    advanced::{
+        layout::{Limits, Node},
+        renderer::Style,
+        widget::{tree, Tree},
+        Clipboard, Layout, Shell, Widget,
+    }, mouse::{Cursor, Interaction}, 
+    widget::Rule, Element, Length, Rectangle, Renderer, Size, Theme, Vector
+};
+use std::fmt::{Debug, Formatter};
+
+use crate::style;
+
+const DRAG_SIZE: f32 = 1.0;
+
+struct State {
+    split_at: f32,
+    dragging: bool,
+    offset: f32,
+}
+
+pub struct HSplit<'a, Message, Theme, Renderer> {
+    children: [Element<'a, Message, Theme, Renderer>; 3],
+    starting_split_at: f32,
+    on_resize: Box<dyn Fn(f32) -> Message + 'a>,
+}
+
+impl<Message, Theme, Renderer> Debug for HSplit<'_, Message, Theme, Renderer> {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("HSplit").finish_non_exhaustive()
+    }
+}
+
+impl<'a, Message> HSplit<'a, Message, Theme, Renderer>
+where
+    Message: 'a,
+{
+    pub fn new(
+        top: impl Into<Element<'a, Message>>,
+        bottom: impl Into<Element<'a, Message>>,
+        on_resize: impl Fn(f32) -> Message + 'a,
+    ) -> Self {
+        Self {
+            children: [
+                top.into(), 
+                Rule::horizontal(DRAG_SIZE)
+                    .style(style::split_ruler)
+                    .into(),
+                bottom.into()
+            ],
+            starting_split_at: 0.8,
+            on_resize: Box::new(on_resize),
+        }
+    }
+
+    pub fn split(mut self, split_at: f32) -> Self {
+        self.starting_split_at = split_at;
+        self
+    }
+
+    fn new_state(&self) -> State {
+        State {
+            split_at: self.starting_split_at,
+            dragging: false,
+            offset: 0.0,
+        }
+    }
+}
+
+impl<Message> Widget<Message, Theme, Renderer> for HSplit<'_, Message, Theme, Renderer> {
+    fn children(&self) -> Vec<Tree> {
+        self.children.iter().map(Tree::new).collect()
+    }
+
+    fn size(&self) -> Size<Length> {
+        Size::new(Length::Fill, Length::Fill)
+    }
+
+    fn tag(&self) -> tree::Tag {
+        tree::Tag::of::<State>()
+    }
+
+    fn state(&self) -> tree::State {
+        tree::State::new(self.new_state())
+    }
+
+    fn layout(&self, tree: &mut Tree, renderer: &Renderer, limits: &Limits) -> Node {
+        let state = tree.state.downcast_ref::<State>();
+        let max_limits = limits.max();
+
+        let top_height = max_limits.height.mul_add(state.split_at, -(DRAG_SIZE * 0.5));
+        let top_limits = Limits::new(
+            Size::new(0.0, 0.0),
+            Size::new(max_limits.width, top_height),
+        );
+
+        let bottom_height = max_limits.height - top_height - DRAG_SIZE;
+        let bottom_limits = Limits::new(
+            Size::new(0.0, 0.0),
+            Size::new(max_limits.width, bottom_height),
+        );
+
+        let children = vec![
+            self.children[0]
+                .as_widget()
+                .layout(&mut tree.children[0], renderer, &top_limits),
+            self.children[1]
+                .as_widget()
+                .layout(
+                    &mut tree.children[1], 
+                    renderer, 
+                    &Limits::new(
+                        Size::new(max_limits.width, 1.0), 
+                        Size::new(max_limits.width, DRAG_SIZE)
+                    )
+                )
+                .translate(Vector::new(0.0, top_height)),
+            self.children[2]
+                .as_widget()
+                .layout(&mut tree.children[2], renderer, &bottom_limits)
+                .translate(Vector::new(0.0, top_height + DRAG_SIZE)),
+        ];
+
+        Node::with_children(max_limits, children)
+    }
+
+    fn update(
+        &mut self,
+        tree: &mut Tree,
+        event: iced::Event,
+        layout: Layout<'_>,
+        cursor: Cursor,
+        renderer: &Renderer,
+        clipboard: &mut dyn Clipboard,
+        shell: &mut Shell<'_, Message>,
+        viewport: &Rectangle,
+    ) {
+        let state = tree.state.downcast_mut::<State>();
+        let bounds = layout.bounds();
+
+        let dragger_bounds = match layout.children().nth(1) {
+            Some(dragger) => dragger.bounds().expand(4.0),
+            None => {
+                log::error!("Failed to find dragger bounds in HSplit layout");
+                return;
+            }
+        };
+
+        if let iced::Event::Mouse(event) = event {
+            match event {
+                iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => {
+                    if let Some(position) =
+                        cursor.position_in(dragger_bounds)
+                    {
+                        state.offset = DRAG_SIZE.mul_add(-0.5, position.y);
+                        state.dragging = true;
+                    }
+                }
+                iced::mouse::Event::CursorMoved { .. } if state.dragging => {
+                    if let Some(position) = cursor.position() {
+                        state.split_at = (DRAG_SIZE
+                            .mul_add(-0.5, position.y - bounds.position().y - state.offset)
+                            / (bounds.height - DRAG_SIZE))
+                            .clamp(0.0, 1.0);
+                    } else {
+                        state.dragging = false;
+                    }
+                }
+                iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) if state.dragging => {
+                    state.dragging = false;
+                    shell.publish((self.on_resize)(state.split_at));
+                }
+                _ => {}
+            }
+        }
+        
+        self.children
+            .iter_mut()
+            .zip(&mut tree.children)
+            .zip(layout.children())
+            .for_each(|((child, tree), layout)| {
+                child.as_widget_mut().update(
+                    tree,
+                    event.clone(),
+                    layout,
+                    cursor,
+                    renderer,
+                    clipboard,
+                    shell,
+                    viewport,
+                )
+            });
+    }
+
+    fn draw(
+        &self,
+        tree: &Tree,
+        renderer: &mut Renderer,
+        theme: &Theme,
+        style: &Style,
+        layout: Layout<'_>,
+        cursor: Cursor,
+        viewport: &Rectangle,
+    ) {
+        self.children
+            .iter()
+            .zip(&tree.children)
+            .zip(layout.children())
+            .filter(|(_, layout)| layout.bounds().intersects(viewport))
+            .for_each(|((child, tree), layout)| {
+                child
+                    .as_widget()
+                    .draw(tree, renderer, theme, style, layout, cursor, viewport);
+            });
+    }
+
+    fn mouse_interaction(
+        &self,
+        tree: &Tree,
+        layout: Layout<'_>,
+        cursor: Cursor,
+        viewport: &Rectangle,
+        renderer: &Renderer,
+    ) -> Interaction {
+        let state = tree.state.downcast_ref::<State>();
+
+        let dragger_bounds = match layout.children().nth(1) {
+            Some(dragger) => dragger.bounds().expand(4.0),
+            None => {
+                log::error!("Failed to find dragger bounds in HSplit layout");
+                return Interaction::default();
+            }
+        };
+
+        if state.dragging || cursor
+            .position_in(dragger_bounds)
+            .is_some()
+        {
+            Interaction::ResizingVertically
+        } else {
+            self.children
+                .iter()
+                .zip(&tree.children)
+                .zip(layout.children())
+                .find(|(_, layout)| cursor.position_in(layout.bounds()).is_some())
+                .map_or_else(Interaction::default, |((child, tree), layout)| {
+                    child
+                        .as_widget()
+                        .mouse_interaction(tree, layout, cursor, viewport, renderer)
+                })
+        }
+    }
+}
+
+impl<'a, Message> From<HSplit<'a, Message, Theme, Renderer>>
+    for Element<'a, Message, Theme, Renderer>
+where
+    Message: 'a,
+{
+    fn from(vsplit: HSplit<'a, Message, Theme, Renderer>) -> Self {
+        Self::new(vsplit)
+    }
+}

Неке датотеке нису приказане због велике количине промена