|
|
@@ -1,59 +1,44 @@
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
|
-use chrono::NaiveDateTime;
|
|
|
use iced::{
|
|
|
- alignment, color, mouse, widget::{button, canvas::{self, event::{self, Event}, stroke::Stroke, Cache, Canvas, Geometry, Path}}, window, Border, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme, Vector
|
|
|
+ alignment, mouse, widget::{button, canvas::{self, event::{self, Event}, stroke::Stroke, Canvas, Geometry, Path}}, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme
|
|
|
};
|
|
|
use iced::widget::{Column, Row, Container, Text};
|
|
|
use crate::data_providers::binance::market_data::{Kline, Trade};
|
|
|
|
|
|
-#[derive(Debug, Clone, Copy)]
|
|
|
-pub enum Message {
|
|
|
- Translated(Vector),
|
|
|
- Scaled(f32, Option<Vector>),
|
|
|
- ChartBounds(Rectangle),
|
|
|
- AutoscaleToggle,
|
|
|
- CrosshairToggle,
|
|
|
- CrosshairMoved(Point),
|
|
|
-}
|
|
|
+use super::{Chart, CommonChartData, Message, Interaction, AxisLabelXCanvas, AxisLabelYCanvas};
|
|
|
+use super::chart_button;
|
|
|
|
|
|
-#[derive(Debug)]
|
|
|
-pub struct Footprint {
|
|
|
- heatmap_cache: Cache,
|
|
|
- crosshair_cache: Cache,
|
|
|
- x_labels_cache: Cache,
|
|
|
- y_labels_cache: Cache,
|
|
|
- y_croshair_cache: Cache,
|
|
|
- x_crosshair_cache: Cache,
|
|
|
- translation: Vector,
|
|
|
- scaling: f32,
|
|
|
-
|
|
|
+pub struct FootprintChart {
|
|
|
+ chart: CommonChartData,
|
|
|
data_points: BTreeMap<i64, (HashMap<i64, (f32, f32)>, (f32, f32, f32, f32, f32, f32))>,
|
|
|
+ timeframe: u16,
|
|
|
+ tick_size: f32,
|
|
|
raw_trades: Vec<Trade>,
|
|
|
+}
|
|
|
|
|
|
- autoscale: bool,
|
|
|
- crosshair: bool,
|
|
|
- crosshair_position: Point,
|
|
|
- x_min_time: i64,
|
|
|
- x_max_time: i64,
|
|
|
- y_min_price: f32,
|
|
|
- y_max_price: f32,
|
|
|
- bounds: Rectangle,
|
|
|
+impl Chart for FootprintChart {
|
|
|
+ type DataPoint = BTreeMap<i64, (HashMap<i64, (f32, f32)>, (f32, f32, f32, f32, f32, f32))>;
|
|
|
|
|
|
- timeframe: u16,
|
|
|
- tick_size: f32,
|
|
|
+ fn get_common_data(&self) -> &CommonChartData {
|
|
|
+ &self.chart
|
|
|
+ }
|
|
|
+ fn get_common_data_mut(&mut self) -> &mut CommonChartData {
|
|
|
+ &mut self.chart
|
|
|
+ }
|
|
|
}
|
|
|
-impl Footprint {
|
|
|
+
|
|
|
+impl FootprintChart {
|
|
|
const MIN_SCALING: f32 = 0.4;
|
|
|
const MAX_SCALING: f32 = 3.6;
|
|
|
|
|
|
- pub fn new(timeframe: u16, tick_size: f32, klines_raw: Vec<(i64, f32, f32, f32, f32, f32, f32)>, raw_trades: Vec<Trade>) -> Footprint {
|
|
|
+ pub fn new(timeframe: u16, tick_size: f32, klines_raw: Vec<(i64, f32, f32, f32, f32, f32, f32)>, raw_trades: Vec<Trade>) -> Self {
|
|
|
let mut data_points = BTreeMap::new();
|
|
|
let aggregate_time = 1000 * 60 * timeframe as i64;
|
|
|
|
|
|
for kline in klines_raw {
|
|
|
let kline_raw = (kline.1, kline.2, kline.3, kline.4, kline.5, kline.6);
|
|
|
data_points.entry(kline.0).or_insert((HashMap::new(), kline_raw));
|
|
|
- }
|
|
|
+ };
|
|
|
for trade in &raw_trades {
|
|
|
let rounded_time = (trade.time / aggregate_time) * aggregate_time;
|
|
|
let price_level: i64 = (trade.price * (1.0 / tick_size)).round() as i64;
|
|
|
@@ -73,33 +58,13 @@ impl Footprint {
|
|
|
} else {
|
|
|
entry.0.insert(price_level, (trade.qty, 0.0));
|
|
|
}
|
|
|
- }
|
|
|
-
|
|
|
- Footprint {
|
|
|
- bounds: Rectangle::default(),
|
|
|
-
|
|
|
- heatmap_cache: canvas::Cache::default(),
|
|
|
- crosshair_cache: canvas::Cache::default(),
|
|
|
- x_labels_cache: canvas::Cache::default(),
|
|
|
- y_labels_cache: canvas::Cache::default(),
|
|
|
- y_croshair_cache: canvas::Cache::default(),
|
|
|
- x_crosshair_cache: canvas::Cache::default(),
|
|
|
-
|
|
|
- translation: Vector::default(),
|
|
|
- scaling: 1.0,
|
|
|
- autoscale: true,
|
|
|
-
|
|
|
- crosshair: false,
|
|
|
- crosshair_position: Point::new(0.0, 0.0),
|
|
|
+ };
|
|
|
|
|
|
- x_min_time: 0,
|
|
|
- x_max_time: 0,
|
|
|
- y_min_price: 0.0,
|
|
|
- y_max_price: 0.0,
|
|
|
-
|
|
|
+ FootprintChart {
|
|
|
+ chart: CommonChartData::default(),
|
|
|
+ data_points,
|
|
|
timeframe,
|
|
|
tick_size,
|
|
|
- data_points,
|
|
|
raw_trades,
|
|
|
}
|
|
|
}
|
|
|
@@ -128,10 +93,26 @@ impl Footprint {
|
|
|
|
|
|
self.raw_trades.push(trade);
|
|
|
}
|
|
|
-
|
|
|
- self.render_start();
|
|
|
}
|
|
|
|
|
|
+ pub fn update_latest_kline(&mut self, kline: &Kline) {
|
|
|
+ if let Some((_, kline_value)) = self.data_points.get_mut(&(kline.time as i64)) {
|
|
|
+ kline_value.0 = kline.open;
|
|
|
+ kline_value.1 = kline.high;
|
|
|
+ kline_value.2 = kline.low;
|
|
|
+ kline_value.3 = kline.close;
|
|
|
+ kline_value.4 = kline.taker_buy_base_asset_volume;
|
|
|
+
|
|
|
+ if kline_value.4 != -1.0 {
|
|
|
+ kline_value.5 = kline.volume - kline.taker_buy_base_asset_volume;
|
|
|
+ } else {
|
|
|
+ kline_value.5 = kline.volume;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ self.render_start();
|
|
|
+ }
|
|
|
+
|
|
|
pub fn change_tick_size(&mut self, new_tick_size: f32) {
|
|
|
let mut new_data_points = BTreeMap::new();
|
|
|
let aggregate_time = 1000 * 60 * self.timeframe as i64;
|
|
|
@@ -165,31 +146,45 @@ impl Footprint {
|
|
|
self.tick_size = new_tick_size;
|
|
|
}
|
|
|
|
|
|
- pub fn update_latest_kline(&mut self, kline: &Kline) {
|
|
|
- if let Some((_, kline_value)) = self.data_points.get_mut(&(kline.time as i64)) {
|
|
|
- kline_value.0 = kline.open;
|
|
|
- kline_value.1 = kline.high;
|
|
|
- kline_value.2 = kline.low;
|
|
|
- kline_value.3 = kline.close;
|
|
|
- kline_value.4 = kline.taker_buy_base_asset_volume;
|
|
|
-
|
|
|
- if kline_value.4 != -1.0 {
|
|
|
- kline_value.5 = kline.volume - kline.taker_buy_base_asset_volume;
|
|
|
- } else {
|
|
|
- kline_value.5 = kline.volume;
|
|
|
- }
|
|
|
+ pub fn render_start(&mut self) {
|
|
|
+ let (latest, earliest, highest, lowest) = self.calculate_range();
|
|
|
+ if highest <= 0.0 || lowest <= 0.0 {
|
|
|
+ return;
|
|
|
}
|
|
|
- }
|
|
|
+
|
|
|
+ let chart_state = &mut self.chart;
|
|
|
+
|
|
|
+ if earliest != chart_state.x_min_time || latest != chart_state.x_max_time {
|
|
|
+ chart_state.x_min_time = earliest;
|
|
|
+ chart_state.x_max_time = latest;
|
|
|
+
|
|
|
+ chart_state.x_labels_cache.clear();
|
|
|
+ chart_state.x_crosshair_cache.clear();
|
|
|
+ };
|
|
|
|
|
|
- pub fn render_start(&mut self) {
|
|
|
+ if lowest != chart_state.y_min_price || highest != chart_state.y_max_price {
|
|
|
+ chart_state.y_min_price = lowest;
|
|
|
+ chart_state.y_max_price = highest;
|
|
|
+
|
|
|
+ chart_state.y_labels_cache.clear();
|
|
|
+ chart_state.y_crosshair_cache.clear();
|
|
|
+ };
|
|
|
+
|
|
|
+ chart_state.crosshair_cache.clear();
|
|
|
+ chart_state.main_cache.clear();
|
|
|
+ }
|
|
|
+
|
|
|
+ fn calculate_range(&self) -> (i64, i64, f32, f32) {
|
|
|
+ let chart = self.get_common_data();
|
|
|
+
|
|
|
let timestamp_latest = self.data_points.keys().last().unwrap_or(&0);
|
|
|
|
|
|
- let latest: i64 = *timestamp_latest - ((self.translation.x*1000.0)*(self.timeframe as f32)) as i64;
|
|
|
- let earliest: i64 = latest - ((640000.0*self.timeframe as f32) / (self.scaling / (self.bounds.width/800.0))) as i64;
|
|
|
+ let latest: i64 = *timestamp_latest - ((chart.translation.x*1000.0)*(self.timeframe as f32)) as i64;
|
|
|
+ let earliest: i64 = latest - ((640000.0*self.timeframe as f32) / (chart.scaling / (chart.bounds.width/800.0))) as i64;
|
|
|
|
|
|
let mut highest: f32 = 0.0;
|
|
|
let mut lowest: f32 = std::f32::MAX;
|
|
|
-
|
|
|
+
|
|
|
for (_, (_, kline)) in self.data_points.range(earliest..=latest) {
|
|
|
if kline.1 > highest {
|
|
|
highest = kline.1;
|
|
|
@@ -198,74 +193,63 @@ impl Footprint {
|
|
|
lowest = kline.2;
|
|
|
}
|
|
|
}
|
|
|
- if highest == 0.0 || lowest == std::f32::MAX || lowest == 0.0 {
|
|
|
- return;
|
|
|
- }
|
|
|
+
|
|
|
highest = highest + (highest - lowest) * 0.05;
|
|
|
lowest = lowest - (highest - lowest) * 0.05;
|
|
|
-
|
|
|
- if earliest != self.x_min_time || latest != self.x_max_time {
|
|
|
- self.x_labels_cache.clear();
|
|
|
- self.x_crosshair_cache.clear();
|
|
|
- }
|
|
|
- if lowest != self.y_min_price || highest != self.y_max_price {
|
|
|
- self.y_labels_cache.clear();
|
|
|
- self.y_croshair_cache.clear();
|
|
|
- }
|
|
|
-
|
|
|
- self.x_min_time = earliest;
|
|
|
- self.x_max_time = latest;
|
|
|
- self.y_min_price = lowest;
|
|
|
- self.y_max_price = highest;
|
|
|
-
|
|
|
- self.crosshair_cache.clear();
|
|
|
|
|
|
- self.heatmap_cache.clear();
|
|
|
+ (latest, earliest, highest, lowest)
|
|
|
}
|
|
|
|
|
|
pub fn update(&mut self, message: &Message) {
|
|
|
match message {
|
|
|
Message::Translated(translation) => {
|
|
|
- if self.autoscale {
|
|
|
- self.translation.x = translation.x;
|
|
|
+ let chart = self.get_common_data_mut();
|
|
|
+
|
|
|
+ if chart.autoscale {
|
|
|
+ chart.translation.x = translation.x;
|
|
|
} else {
|
|
|
- self.translation = *translation;
|
|
|
+ chart.translation = *translation;
|
|
|
}
|
|
|
- self.crosshair_position = Point::new(0.0, 0.0);
|
|
|
+ chart.crosshair_position = Point::new(0.0, 0.0);
|
|
|
|
|
|
self.render_start();
|
|
|
- }
|
|
|
+ },
|
|
|
Message::Scaled(scaling, translation) => {
|
|
|
- self.scaling = *scaling;
|
|
|
+ let chart = self.get_common_data_mut();
|
|
|
+
|
|
|
+ chart.scaling = *scaling;
|
|
|
|
|
|
if let Some(translation) = translation {
|
|
|
- if self.autoscale {
|
|
|
- self.translation.x = translation.x;
|
|
|
+ if chart.autoscale {
|
|
|
+ chart.translation.x = translation.x;
|
|
|
} else {
|
|
|
- self.translation = *translation;
|
|
|
+ chart.translation = *translation;
|
|
|
}
|
|
|
}
|
|
|
- self.crosshair_position = Point::new(0.0, 0.0);
|
|
|
+ chart.crosshair_position = Point::new(0.0, 0.0);
|
|
|
|
|
|
self.render_start();
|
|
|
- }
|
|
|
+ },
|
|
|
Message::ChartBounds(bounds) => {
|
|
|
- self.bounds = *bounds;
|
|
|
- }
|
|
|
+ self.chart.bounds = *bounds;
|
|
|
+ },
|
|
|
Message::AutoscaleToggle => {
|
|
|
- self.autoscale = !self.autoscale;
|
|
|
- }
|
|
|
+ self.chart.autoscale = !self.chart.autoscale;
|
|
|
+ },
|
|
|
Message::CrosshairToggle => {
|
|
|
- self.crosshair = !self.crosshair;
|
|
|
- }
|
|
|
+ self.chart.crosshair = !self.chart.crosshair;
|
|
|
+ },
|
|
|
Message::CrosshairMoved(position) => {
|
|
|
- self.crosshair_position = *position;
|
|
|
- if self.crosshair {
|
|
|
- self.crosshair_cache.clear();
|
|
|
- self.y_croshair_cache.clear();
|
|
|
- self.x_crosshair_cache.clear();
|
|
|
+ let chart = self.get_common_data_mut();
|
|
|
+
|
|
|
+ chart.crosshair_position = *position;
|
|
|
+ if chart.crosshair {
|
|
|
+ chart.crosshair_cache.clear();
|
|
|
+ chart.y_crosshair_cache.clear();
|
|
|
+ chart.x_crosshair_cache.clear();
|
|
|
}
|
|
|
- }
|
|
|
+ },
|
|
|
+ _ => {}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -274,27 +258,29 @@ impl Footprint {
|
|
|
.width(Length::FillPortion(10))
|
|
|
.height(Length::FillPortion(10));
|
|
|
|
|
|
+ let chart_state = self.get_common_data();
|
|
|
+
|
|
|
let axis_labels_x = Canvas::new(
|
|
|
AxisLabelXCanvas {
|
|
|
- labels_cache: &self.x_labels_cache,
|
|
|
- min: self.x_min_time,
|
|
|
- max: self.x_max_time,
|
|
|
- crosshair_cache: &self.x_crosshair_cache,
|
|
|
- crosshair_position: self.crosshair_position,
|
|
|
- crosshair: self.crosshair,
|
|
|
- timeframe: self.timeframe
|
|
|
+ labels_cache: &chart_state.x_labels_cache,
|
|
|
+ min: chart_state.x_min_time,
|
|
|
+ max: chart_state.x_max_time,
|
|
|
+ crosshair_cache: &chart_state.x_crosshair_cache,
|
|
|
+ crosshair_position: chart_state.crosshair_position,
|
|
|
+ crosshair: chart_state.crosshair,
|
|
|
+ timeframe: Some(self.timeframe)
|
|
|
})
|
|
|
.width(Length::FillPortion(10))
|
|
|
.height(Length::Fixed(26.0));
|
|
|
|
|
|
let axis_labels_y = Canvas::new(
|
|
|
AxisLabelYCanvas {
|
|
|
- labels_cache: &self.y_labels_cache,
|
|
|
- y_croshair_cache: &self.y_croshair_cache,
|
|
|
- min: self.y_min_price,
|
|
|
- max: self.y_max_price,
|
|
|
- crosshair_position: self.crosshair_position,
|
|
|
- crosshair: self.crosshair
|
|
|
+ labels_cache: &chart_state.y_labels_cache,
|
|
|
+ y_croshair_cache: &chart_state.y_crosshair_cache,
|
|
|
+ min: chart_state.y_min_price,
|
|
|
+ max: chart_state.y_max_price,
|
|
|
+ crosshair_position: chart_state.crosshair_position,
|
|
|
+ crosshair: chart_state.crosshair
|
|
|
})
|
|
|
.width(Length::Fixed(60.0))
|
|
|
.height(Length::FillPortion(10));
|
|
|
@@ -307,7 +293,7 @@ impl Footprint {
|
|
|
.width(Length::Fill)
|
|
|
.height(Length::Fill)
|
|
|
.on_press(Message::AutoscaleToggle)
|
|
|
- .style(|_theme: &Theme, _status: iced::widget::button::Status| chart_button(_theme, _status, self.autoscale));
|
|
|
+ .style(|_theme: &Theme, _status: iced::widget::button::Status| chart_button(_theme, _status, chart_state.autoscale));
|
|
|
let crosshair_button = button(
|
|
|
Text::new("+")
|
|
|
.size(12)
|
|
|
@@ -316,7 +302,7 @@ impl Footprint {
|
|
|
.width(Length::Fill)
|
|
|
.height(Length::Fill)
|
|
|
.on_press(Message::CrosshairToggle)
|
|
|
- .style(|_theme: &Theme, _status: iced::widget::button::Status| chart_button(_theme, _status, self.crosshair));
|
|
|
+ .style(|_theme: &Theme, _status: iced::widget::button::Status| chart_button(_theme, _status, chart_state.crosshair));
|
|
|
|
|
|
let chart_controls = Container::new(
|
|
|
Row::new()
|
|
|
@@ -344,39 +330,7 @@ impl Footprint {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-fn chart_button(_theme: &Theme, _status: button::Status, is_active: bool) -> button::Style {
|
|
|
- button::Style {
|
|
|
- background: Some(Color::from_rgba8(20, 20, 20, 1.0).into()),
|
|
|
- border: Border {
|
|
|
- color: {
|
|
|
- if is_active {
|
|
|
- Color::from_rgba8(50, 50, 50, 1.0)
|
|
|
- } else {
|
|
|
- Color::from_rgba8(20, 20, 20, 1.0)
|
|
|
- }
|
|
|
- },
|
|
|
- width: 1.0,
|
|
|
- radius: 2.0.into(),
|
|
|
- },
|
|
|
- text_color: Color::WHITE,
|
|
|
- ..button::Style::default()
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-#[derive(Debug, Clone, Copy)]
|
|
|
-pub enum Interaction {
|
|
|
- None,
|
|
|
- Drawing,
|
|
|
- Erasing,
|
|
|
- Panning { translation: Vector, start: Point },
|
|
|
-}
|
|
|
-
|
|
|
-impl Default for Interaction {
|
|
|
- fn default() -> Self {
|
|
|
- Self::None
|
|
|
- }
|
|
|
-}
|
|
|
-impl canvas::Program<Message> for Footprint {
|
|
|
+impl canvas::Program<Message> for FootprintChart {
|
|
|
type State = Interaction;
|
|
|
|
|
|
fn update(
|
|
|
@@ -385,8 +339,10 @@ impl canvas::Program<Message> for Footprint {
|
|
|
event: Event,
|
|
|
bounds: Rectangle,
|
|
|
cursor: mouse::Cursor,
|
|
|
- ) -> (event::Status, Option<Message>) {
|
|
|
- if bounds != self.bounds {
|
|
|
+ ) -> (event::Status, Option<Message>) {
|
|
|
+ let chart_state = self.get_common_data();
|
|
|
+
|
|
|
+ if bounds != chart_state.bounds {
|
|
|
return (event::Status::Ignored, Some(Message::ChartBounds(bounds)));
|
|
|
}
|
|
|
|
|
|
@@ -396,7 +352,7 @@ impl canvas::Program<Message> for Footprint {
|
|
|
|
|
|
let Some(cursor_position) = cursor.position_in(bounds) else {
|
|
|
return (event::Status::Ignored,
|
|
|
- if self.crosshair {
|
|
|
+ if chart_state.crosshair {
|
|
|
Some(Message::CrosshairMoved(Point::new(0.0, 0.0)))
|
|
|
} else {
|
|
|
None
|
|
|
@@ -414,7 +370,7 @@ impl canvas::Program<Message> for Footprint {
|
|
|
}
|
|
|
mouse::Button::Left => {
|
|
|
*interaction = Interaction::Panning {
|
|
|
- translation: self.translation,
|
|
|
+ translation: chart_state.translation,
|
|
|
start: cursor_position,
|
|
|
};
|
|
|
None
|
|
|
@@ -432,11 +388,11 @@ impl canvas::Program<Message> for Footprint {
|
|
|
Some(Message::Translated(
|
|
|
translation
|
|
|
+ (cursor_position - start)
|
|
|
- * (1.0 / self.scaling),
|
|
|
+ * (1.0 / chart_state.scaling),
|
|
|
))
|
|
|
}
|
|
|
Interaction::None =>
|
|
|
- if self.crosshair && cursor.is_over(bounds) {
|
|
|
+ if chart_state.crosshair && cursor.is_over(bounds) {
|
|
|
Some(Message::CrosshairMoved(cursor_position))
|
|
|
} else {
|
|
|
None
|
|
|
@@ -453,12 +409,12 @@ impl canvas::Program<Message> for Footprint {
|
|
|
mouse::Event::WheelScrolled { delta } => match delta {
|
|
|
mouse::ScrollDelta::Lines { y, .. }
|
|
|
| mouse::ScrollDelta::Pixels { y, .. } => {
|
|
|
- if y < 0.0 && self.scaling > Self::MIN_SCALING
|
|
|
- || y > 0.0 && self.scaling < Self::MAX_SCALING
|
|
|
+ if y < 0.0 && chart_state.scaling > Self::MIN_SCALING
|
|
|
+ || y > 0.0 && chart_state.scaling < Self::MAX_SCALING
|
|
|
{
|
|
|
//let old_scaling = self.scaling;
|
|
|
|
|
|
- let scaling = (self.scaling * (1.0 + y / 30.0))
|
|
|
+ let scaling = (chart_state.scaling * (1.0 + y / 30.0))
|
|
|
.clamp(
|
|
|
Self::MIN_SCALING, // 0.1
|
|
|
Self::MAX_SCALING, // 2.0
|
|
|
@@ -508,19 +464,17 @@ impl canvas::Program<Message> for Footprint {
|
|
|
bounds: Rectangle,
|
|
|
cursor: mouse::Cursor,
|
|
|
) -> Vec<Geometry> {
|
|
|
- let (latest, earliest) = (self.x_max_time, self.x_min_time);
|
|
|
- let (lowest, highest) = (self.y_min_price, self.y_max_price);
|
|
|
+ let chart = self.get_common_data();
|
|
|
|
|
|
- let y_range: f32 = highest - lowest;
|
|
|
+ let (latest, earliest) = (chart.x_max_time, chart.x_min_time);
|
|
|
+ let (lowest, highest) = (chart.y_min_price, chart.y_max_price);
|
|
|
|
|
|
- if y_range == 0.0 {
|
|
|
- return vec![];
|
|
|
- }
|
|
|
+ let y_range: f32 = highest - lowest;
|
|
|
|
|
|
let volume_area_height: f32 = bounds.height / 8.0;
|
|
|
let heatmap_area_height: f32 = bounds.height - volume_area_height;
|
|
|
|
|
|
- let heatmap = self.heatmap_cache.draw(renderer, bounds.size(), |frame| {
|
|
|
+ let heatmap = chart.main_cache.draw(renderer, bounds.size(), |frame| {
|
|
|
let mut max_trade_qty: f32 = 0.0;
|
|
|
let mut max_volume: f32 = 0.0;
|
|
|
|
|
|
@@ -553,8 +507,8 @@ impl canvas::Program<Message> for Footprint {
|
|
|
frame.stroke(&wick, Stroke::default().with_color(wick_color).with_width(1.0));
|
|
|
|
|
|
let body = Path::rectangle(
|
|
|
- Point::new(x_position - self.scaling, y_open.min(y_close)),
|
|
|
- Size::new(2.0 * self.scaling, (y_open - y_close).abs())
|
|
|
+ Point::new(x_position - chart.scaling, y_open.min(y_close)),
|
|
|
+ Size::new(2.0 * chart.scaling, (y_open - y_close).abs())
|
|
|
);
|
|
|
frame.fill(&body, body_color);
|
|
|
|
|
|
@@ -563,17 +517,17 @@ impl canvas::Program<Message> for Footprint {
|
|
|
let y_position = heatmap_area_height - ((price - lowest) / y_range * heatmap_area_height);
|
|
|
|
|
|
if trade.1.0 > 0.0 {
|
|
|
- let bar_width = (trade.1.0 / max_trade_qty) * bounds.width / 28.0 * self.scaling;
|
|
|
+ let bar_width = (trade.1.0 / max_trade_qty) * bounds.width / 28.0 * chart.scaling;
|
|
|
let bar = Path::rectangle(
|
|
|
- Point::new(x_position + (3.0 * self.scaling), y_position),
|
|
|
+ Point::new(x_position + (3.0 * chart.scaling), y_position),
|
|
|
Size::new(bar_width, 1.0)
|
|
|
);
|
|
|
frame.fill(&bar, Color::from_rgba8(81, 205, 160, 1.0));
|
|
|
}
|
|
|
if trade.1.1 > 0.0 {
|
|
|
- let bar_width = -(trade.1.1 / max_trade_qty) * bounds.width / 28.0 * self.scaling;
|
|
|
+ let bar_width = -(trade.1.1 / max_trade_qty) * bounds.width / 28.0 * chart.scaling;
|
|
|
let bar = Path::rectangle(
|
|
|
- Point::new(x_position - (3.0 * self.scaling), y_position),
|
|
|
+ Point::new(x_position - (3.0 * chart.scaling), y_position),
|
|
|
Size::new(bar_width, 1.0)
|
|
|
);
|
|
|
frame.fill(&bar, Color::from_rgba8(192, 80, 77, 1.0));
|
|
|
@@ -585,8 +539,8 @@ impl canvas::Program<Message> for Footprint {
|
|
|
let buy_bar_height = (kline.4 / max_volume) * volume_area_height;
|
|
|
let sell_bar_height = (kline.5 / max_volume) * volume_area_height;
|
|
|
|
|
|
- let sell_bar_width = 8.0 * self.scaling;
|
|
|
- let sell_bar_x_position = x_position - (5.0*self.scaling) - sell_bar_width;
|
|
|
+ let sell_bar_width = 8.0 * chart.scaling;
|
|
|
+ let sell_bar_x_position = x_position - (5.0*chart.scaling) - sell_bar_width;
|
|
|
let sell_bar = Path::rectangle(
|
|
|
Point::new(sell_bar_x_position, bounds.height - sell_bar_height),
|
|
|
Size::new(sell_bar_width, sell_bar_height)
|
|
|
@@ -594,15 +548,15 @@ impl canvas::Program<Message> for Footprint {
|
|
|
frame.fill(&sell_bar, Color::from_rgb8(192, 80, 77));
|
|
|
|
|
|
let buy_bar = Path::rectangle(
|
|
|
- Point::new(x_position + (5.0*self.scaling), bounds.height - buy_bar_height),
|
|
|
- Size::new(8.0 * self.scaling, buy_bar_height)
|
|
|
+ Point::new(x_position + (5.0*chart.scaling), bounds.height - buy_bar_height),
|
|
|
+ Size::new(8.0 * chart.scaling, buy_bar_height)
|
|
|
);
|
|
|
frame.fill(&buy_bar, Color::from_rgb8(81, 205, 160));
|
|
|
} else {
|
|
|
let bar_height = (kline.5 / max_volume) * volume_area_height;
|
|
|
let bar = Path::rectangle(
|
|
|
- Point::new(x_position - (3.0*self.scaling), bounds.height - bar_height),
|
|
|
- Size::new(6.0 * self.scaling, bar_height)
|
|
|
+ Point::new(x_position - (3.0*chart.scaling), bounds.height - bar_height),
|
|
|
+ Size::new(6.0 * chart.scaling, bar_height)
|
|
|
);
|
|
|
let color = if kline.3 >= kline.0 { Color::from_rgba8(81, 205, 160, 0.8) } else { Color::from_rgba8(192, 80, 77, 0.8) };
|
|
|
|
|
|
@@ -626,8 +580,8 @@ impl canvas::Program<Message> for Footprint {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
- if self.crosshair {
|
|
|
- let crosshair = self.crosshair_cache.draw(renderer, bounds.size(), |frame| {
|
|
|
+ if chart.crosshair {
|
|
|
+ let crosshair = chart.crosshair_cache.draw(renderer, bounds.size(), |frame| {
|
|
|
if let Some(cursor_position) = cursor.position_in(bounds) {
|
|
|
let line = Path::line(
|
|
|
Point::new(0.0, cursor_position.y),
|
|
|
@@ -692,7 +646,7 @@ impl canvas::Program<Message> for Footprint {
|
|
|
Interaction::Erasing => mouse::Interaction::Crosshair,
|
|
|
Interaction::Panning { .. } => mouse::Interaction::Grabbing,
|
|
|
Interaction::None if cursor.is_over(bounds) => {
|
|
|
- if self.crosshair {
|
|
|
+ if self.chart.crosshair {
|
|
|
mouse::Interaction::Crosshair
|
|
|
} else {
|
|
|
mouse::Interaction::default()
|
|
|
@@ -701,344 +655,4 @@ impl canvas::Program<Message> for Footprint {
|
|
|
Interaction::None => { mouse::Interaction::default() }
|
|
|
}
|
|
|
}
|
|
|
-}
|
|
|
-
|
|
|
-const PRICE_STEPS: [f32; 15] = [
|
|
|
- 1000.0,
|
|
|
- 500.0,
|
|
|
- 200.0,
|
|
|
- 100.0,
|
|
|
- 50.0,
|
|
|
- 20.0,
|
|
|
- 10.0,
|
|
|
- 5.0,
|
|
|
- 2.0,
|
|
|
- 1.0,
|
|
|
- 0.5,
|
|
|
- 0.2,
|
|
|
- 0.1,
|
|
|
- 0.05,
|
|
|
- 0.01,
|
|
|
-];
|
|
|
-fn calculate_price_step(highest: f32, lowest: f32, labels_can_fit: i32) -> (f32, f32) {
|
|
|
- let range = highest - lowest;
|
|
|
- let mut step = 1000.0;
|
|
|
-
|
|
|
- for &s in PRICE_STEPS.iter().rev() {
|
|
|
- if range / s <= labels_can_fit as f32 {
|
|
|
- step = s;
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
- let rounded_lowest = (lowest / step).floor() * step;
|
|
|
-
|
|
|
- (step, rounded_lowest)
|
|
|
-}
|
|
|
-
|
|
|
-const M1_TIME_STEPS: [i64; 5] = [
|
|
|
- 1000 * 60 * 30, // 30 minutes
|
|
|
- 1000 * 60 * 15, // 15 minutes
|
|
|
- 1000 * 60 * 5, // 5 minutes
|
|
|
- 1000 * 60 * 2, // 2 minutes
|
|
|
- 60 * 1000, // 1 minute
|
|
|
-];
|
|
|
-const M3_TIME_STEPS: [i64; 5] = [
|
|
|
- 1000 * 60 * 60, // 1 hour
|
|
|
- 1000 * 60 * 30, // 30 minutes
|
|
|
- 1000 * 60 * 15, // 15 minutes
|
|
|
- 1000 * 60 * 9, // 9 minutes
|
|
|
- 1000 * 60 * 3, // 3 minutes
|
|
|
-];
|
|
|
-const M5_TIME_STEPS: [i64; 5] = [
|
|
|
- 1000 * 60 * 60, // 1 hour
|
|
|
- 1000 * 60 * 30, // 30 minutes
|
|
|
- 1000 * 60 * 15, // 15 minutes
|
|
|
- 1000 * 60 * 5, // 5 minutes
|
|
|
- 1000 * 60 * 2, // 2 minutes
|
|
|
-];
|
|
|
-const M15_TIME_STEPS: [i64; 5] = [
|
|
|
- 1000 * 60 * 240, // 4 hour
|
|
|
- 1000 * 60 * 120, // 2 hour
|
|
|
- 1000 * 60 * 60, // 1 hour
|
|
|
- 1000 * 60 * 30, // 30 minutes
|
|
|
- 1000 * 60 * 15, // 15 minutes
|
|
|
-];
|
|
|
-const M30_TIME_STEPS: [i64; 5] = [
|
|
|
- 1000 * 60 * 480, // 8 hour
|
|
|
- 1000 * 60 * 240, // 4 hour
|
|
|
- 1000 * 60 * 120, // 2 hour
|
|
|
- 1000 * 60 * 60, // 1 hour
|
|
|
- 1000 * 60 * 30, // 30 minutes
|
|
|
-];
|
|
|
-
|
|
|
-fn calculate_time_step(earliest: i64, latest: i64, labels_can_fit: i32, timeframe: u16) -> (i64, i64) {
|
|
|
- let duration = latest - earliest;
|
|
|
-
|
|
|
- let time_steps = match timeframe {
|
|
|
- 1 => &M1_TIME_STEPS,
|
|
|
- 3 => &M3_TIME_STEPS,
|
|
|
- 5 => &M5_TIME_STEPS,
|
|
|
- 15 => &M15_TIME_STEPS,
|
|
|
- 30 => &M30_TIME_STEPS,
|
|
|
- _ => &M1_TIME_STEPS,
|
|
|
- };
|
|
|
-
|
|
|
- let mut selected_step = time_steps[0];
|
|
|
- for &step in time_steps.iter() {
|
|
|
- if duration / step >= labels_can_fit as i64 {
|
|
|
- selected_step = step;
|
|
|
- break;
|
|
|
- }
|
|
|
- if step <= duration {
|
|
|
- selected_step = step;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- let rounded_earliest = (earliest / selected_step) * selected_step;
|
|
|
-
|
|
|
- (selected_step, rounded_earliest)
|
|
|
-}
|
|
|
-pub struct AxisLabelXCanvas<'a> {
|
|
|
- labels_cache: &'a Cache,
|
|
|
- crosshair_cache: &'a Cache,
|
|
|
- crosshair_position: Point,
|
|
|
- crosshair: bool,
|
|
|
- min: i64,
|
|
|
- max: i64,
|
|
|
- timeframe: u16,
|
|
|
-}
|
|
|
-impl canvas::Program<Message> for AxisLabelXCanvas<'_> {
|
|
|
- type State = Interaction;
|
|
|
-
|
|
|
- fn update(
|
|
|
- &self,
|
|
|
- _interaction: &mut Interaction,
|
|
|
- _event: Event,
|
|
|
- _bounds: Rectangle,
|
|
|
- _cursor: mouse::Cursor,
|
|
|
- ) -> (event::Status, Option<Message>) {
|
|
|
- (event::Status::Ignored, None)
|
|
|
- }
|
|
|
-
|
|
|
- fn draw(
|
|
|
- &self,
|
|
|
- _state: &Self::State,
|
|
|
- renderer: &Renderer,
|
|
|
- _theme: &Theme,
|
|
|
- bounds: Rectangle,
|
|
|
- _cursor: mouse::Cursor,
|
|
|
- ) -> Vec<Geometry> {
|
|
|
- if self.max == 0 {
|
|
|
- return vec![];
|
|
|
- }
|
|
|
- let latest_in_millis = self.max;
|
|
|
- let earliest_in_millis = self.min;
|
|
|
-
|
|
|
- let x_labels_can_fit = (bounds.width / 120.0) as i32;
|
|
|
- let (time_step, rounded_earliest) = calculate_time_step(self.min, self.max, x_labels_can_fit, self.timeframe);
|
|
|
-
|
|
|
- let labels = self.labels_cache.draw(renderer, bounds.size(), |frame| {
|
|
|
- frame.with_save(|frame| {
|
|
|
- let mut time: i64 = rounded_earliest;
|
|
|
- let latest_time: i64 = latest_in_millis;
|
|
|
-
|
|
|
- while time <= latest_time {
|
|
|
- let x_position = ((time - earliest_in_millis) as f64 / (latest_in_millis - earliest_in_millis) as f64) * bounds.width as f64;
|
|
|
-
|
|
|
- if x_position >= 0.0 && x_position <= bounds.width as f64 {
|
|
|
- let text_size = 12.0;
|
|
|
- let time_as_datetime = NaiveDateTime::from_timestamp(time / 1000, 0);
|
|
|
- let label = canvas::Text {
|
|
|
- content: time_as_datetime.format("%H:%M").to_string(),
|
|
|
- position: Point::new(x_position as f32 - (text_size*4.0/3.0), bounds.height - 20.0),
|
|
|
- size: iced::Pixels(text_size),
|
|
|
- color: Color::from_rgba8(200, 200, 200, 1.0),
|
|
|
- ..canvas::Text::default()
|
|
|
- };
|
|
|
-
|
|
|
- label.draw_with(|path, color| {
|
|
|
- frame.fill(&path, color);
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- time += time_step;
|
|
|
- }
|
|
|
-
|
|
|
- let line = Path::line(
|
|
|
- Point::new(0.0, bounds.height - 30.0),
|
|
|
- Point::new(bounds.width, bounds.height - 30.0)
|
|
|
- );
|
|
|
- frame.stroke(&line, Stroke::default().with_color(Color::from_rgba8(81, 81, 81, 0.2)).with_width(1.0));
|
|
|
- });
|
|
|
- });
|
|
|
- let crosshair = self.crosshair_cache.draw(renderer, bounds.size(), |frame| {
|
|
|
- if self.crosshair && self.crosshair_position.x > 0.0 {
|
|
|
- let crosshair_ratio = self.crosshair_position.x as f64 / bounds.width as f64;
|
|
|
- let crosshair_millis = earliest_in_millis as f64 + crosshair_ratio * (latest_in_millis - earliest_in_millis) as f64;
|
|
|
- let crosshair_time = NaiveDateTime::from_timestamp((crosshair_millis / 1000.0) as i64, 0);
|
|
|
-
|
|
|
- let crosshair_timestamp = crosshair_time.timestamp();
|
|
|
- let rounded_timestamp = (crosshair_timestamp as f64 / (self.timeframe as f64 * 60.0)).round() as i64 * self.timeframe as i64 * 60;
|
|
|
- let rounded_time = NaiveDateTime::from_timestamp(rounded_timestamp, 0);
|
|
|
-
|
|
|
- let snap_ratio = (rounded_timestamp as f64 * 1000.0 - earliest_in_millis as f64) / (latest_in_millis as f64 - earliest_in_millis as f64);
|
|
|
- let snap_x = snap_ratio * bounds.width as f64;
|
|
|
-
|
|
|
- let text_size = 12.0;
|
|
|
- let text_content = rounded_time.format("%H:%M").to_string();
|
|
|
- let growth_amount = 6.0;
|
|
|
- let rectangle_position = Point::new(snap_x as f32 - 14.0 - growth_amount, bounds.height - 20.0);
|
|
|
- let text_position = Point::new(snap_x as f32 - 14.0, bounds.height - 20.0);
|
|
|
-
|
|
|
- let text_background = canvas::Path::rectangle(rectangle_position, Size::new(text_content.len() as f32 * text_size/2.0 + 2.0 * growth_amount + 1.0, text_size + text_size/2.0));
|
|
|
- frame.fill(&text_background, Color::from_rgba8(200, 200, 200, 1.0));
|
|
|
-
|
|
|
- let crosshair_label = canvas::Text {
|
|
|
- content: text_content,
|
|
|
- position: text_position,
|
|
|
- size: iced::Pixels(text_size),
|
|
|
- color: Color::from_rgba8(0, 0, 0, 1.0),
|
|
|
- ..canvas::Text::default()
|
|
|
- };
|
|
|
-
|
|
|
- crosshair_label.draw_with(|path, color| {
|
|
|
- frame.fill(&path, color);
|
|
|
- });
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- vec![labels, crosshair]
|
|
|
- }
|
|
|
-
|
|
|
- fn mouse_interaction(
|
|
|
- &self,
|
|
|
- interaction: &Interaction,
|
|
|
- bounds: Rectangle,
|
|
|
- cursor: mouse::Cursor,
|
|
|
- ) -> mouse::Interaction {
|
|
|
- match interaction {
|
|
|
- Interaction::Drawing => mouse::Interaction::Crosshair,
|
|
|
- Interaction::Erasing => mouse::Interaction::Crosshair,
|
|
|
- Interaction::Panning { .. } => mouse::Interaction::ResizingHorizontally,
|
|
|
- Interaction::None if cursor.is_over(bounds) => {
|
|
|
- mouse::Interaction::ResizingHorizontally
|
|
|
- }
|
|
|
- Interaction::None => mouse::Interaction::default(),
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-pub struct AxisLabelYCanvas<'a> {
|
|
|
- labels_cache: &'a Cache,
|
|
|
- y_croshair_cache: &'a Cache,
|
|
|
- min: f32,
|
|
|
- max: f32,
|
|
|
- crosshair_position: Point,
|
|
|
- crosshair: bool,
|
|
|
-}
|
|
|
-impl canvas::Program<Message> for AxisLabelYCanvas<'_> {
|
|
|
- type State = Interaction;
|
|
|
-
|
|
|
- fn update(
|
|
|
- &self,
|
|
|
- _interaction: &mut Interaction,
|
|
|
- _event: Event,
|
|
|
- _bounds: Rectangle,
|
|
|
- _cursor: mouse::Cursor,
|
|
|
- ) -> (event::Status, Option<Message>) {
|
|
|
- (event::Status::Ignored, None)
|
|
|
- }
|
|
|
-
|
|
|
- fn draw(
|
|
|
- &self,
|
|
|
- _state: &Self::State,
|
|
|
- renderer: &Renderer,
|
|
|
- _theme: &Theme,
|
|
|
- bounds: Rectangle,
|
|
|
- _cursor: mouse::Cursor,
|
|
|
- ) -> Vec<Geometry> {
|
|
|
- if self.max == 0.0 {
|
|
|
- return vec![];
|
|
|
- }
|
|
|
-
|
|
|
- let y_labels_can_fit = (bounds.height / 32.0) as i32;
|
|
|
- let (step, rounded_lowest) = calculate_price_step(self.max, self.min, y_labels_can_fit);
|
|
|
-
|
|
|
- let volume_area_height = bounds.height / 8.0;
|
|
|
- let candlesticks_area_height = bounds.height - volume_area_height;
|
|
|
-
|
|
|
- let labels = self.labels_cache.draw(renderer, bounds.size(), |frame| {
|
|
|
- frame.with_save(|frame| {
|
|
|
- let y_range = self.max - self.min;
|
|
|
- let mut y = rounded_lowest;
|
|
|
-
|
|
|
- while y <= self.max {
|
|
|
- let y_position = candlesticks_area_height - ((y - self.min) / y_range * candlesticks_area_height);
|
|
|
-
|
|
|
- let text_size = 12.0;
|
|
|
- let decimal_places = if step < 0.5 { 2 } else { usize::from(step < 1.0) };
|
|
|
- let label_content = format!("{y:.decimal_places$}");
|
|
|
- let label = canvas::Text {
|
|
|
- content: label_content,
|
|
|
- position: Point::new(10.0, y_position - text_size / 2.0),
|
|
|
- size: iced::Pixels(text_size),
|
|
|
- color: Color::from_rgba8(200, 200, 200, 1.0),
|
|
|
- ..canvas::Text::default()
|
|
|
- };
|
|
|
-
|
|
|
- label.draw_with(|path, color| {
|
|
|
- frame.fill(&path, color);
|
|
|
- });
|
|
|
-
|
|
|
- y += step;
|
|
|
- }
|
|
|
- });
|
|
|
- });
|
|
|
- let crosshair = self.y_croshair_cache.draw(renderer, bounds.size(), |frame| {
|
|
|
- if self.crosshair && self.crosshair_position.y > 0.0 {
|
|
|
- let text_size = 12.0;
|
|
|
- let y_range = self.max - self.min;
|
|
|
- let decimal_places = if step < 1.0 { 2 } else { 1 };
|
|
|
- let label_content = format!("{:.*}", decimal_places, self.min + (y_range * (candlesticks_area_height - self.crosshair_position.y) / candlesticks_area_height));
|
|
|
-
|
|
|
- let growth_amount = 3.0;
|
|
|
- let rectangle_position = Point::new(8.0 - growth_amount, self.crosshair_position.y - text_size / 2.0 - 3.0);
|
|
|
- let text_position = Point::new(8.0, self.crosshair_position.y - text_size / 2.0 - 3.0);
|
|
|
-
|
|
|
- let text_background = canvas::Path::rectangle(rectangle_position, Size::new(label_content.len() as f32 * text_size / 2.0 + 2.0 * growth_amount + 4.0, text_size + text_size / 1.8));
|
|
|
- frame.fill(&text_background, Color::from_rgba8(200, 200, 200, 1.0));
|
|
|
-
|
|
|
- let label = canvas::Text {
|
|
|
- content: label_content,
|
|
|
- position: text_position,
|
|
|
- size: iced::Pixels(text_size),
|
|
|
- color: Color::from_rgba8(0, 0, 0, 1.0),
|
|
|
- ..canvas::Text::default()
|
|
|
- };
|
|
|
-
|
|
|
- label.draw_with(|path, color| {
|
|
|
- frame.fill(&path, color);
|
|
|
- });
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- vec![labels, crosshair]
|
|
|
- }
|
|
|
-
|
|
|
- fn mouse_interaction(
|
|
|
- &self,
|
|
|
- interaction: &Interaction,
|
|
|
- bounds: Rectangle,
|
|
|
- cursor: mouse::Cursor,
|
|
|
- ) -> mouse::Interaction {
|
|
|
- match interaction {
|
|
|
- Interaction::Drawing => mouse::Interaction::Crosshair,
|
|
|
- Interaction::Erasing => mouse::Interaction::Crosshair,
|
|
|
- Interaction::Panning { .. } => mouse::Interaction::ResizingVertically,
|
|
|
- Interaction::None if cursor.is_over(bounds) => {
|
|
|
- mouse::Interaction::ResizingVertically
|
|
|
- }
|
|
|
- Interaction::None => mouse::Interaction::default(),
|
|
|
- }
|
|
|
- }
|
|
|
}
|