| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- use std::collections::BTreeMap;
- use iced::widget::{container, row, Canvas};
- use iced::{Element, Length};
- 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
- };
- use crate::data_providers::format_with_commas;
- pub fn create_indicator_elem<'a>(
- chart_state: &'a CommonChartData,
- cache: &'a Caches,
- data: &'a BTreeMap<i64, (f32, f32)>,
- earliest: i64,
- latest: i64,
- ) -> Element<'a, Message> {
- let indi_chart = Canvas::new(VolumeIndicator {
- indicator_cache: &cache.main,
- crosshair_cache: &cache.crosshair,
- crosshair: chart_state.crosshair,
- max: chart_state.latest_x,
- scaling: chart_state.scaling,
- translation_x: chart_state.translation.x,
- timeframe: chart_state.timeframe as u32,
- cell_width: chart_state.cell_width,
- data_points: data,
- chart_bounds: chart_state.bounds,
- })
- .height(Length::Fill)
- .width(Length::Fill);
- let max_volume = data
- .range(earliest..=latest)
- .map(|(_, (buy, sell))| buy.max(*sell))
- .max_by(|a, b| a.partial_cmp(b).unwrap())
- .unwrap_or(0.0);
- let indi_labels = Canvas::new(VolumeLabels {
- label_cache: &cache.y_labels,
- max: max_volume,
- min: 0.0,
- crosshair: chart_state.crosshair,
- chart_bounds: chart_state.bounds,
- })
- .height(Length::Fill)
- .width(Length::Fixed(60.0 + (chart_state.decimals as f32 * 2.0)));
- row![
- indi_chart,
- container(indi_labels),
- ].into()
- }
- pub struct VolumeIndicator<'a> {
- pub indicator_cache: &'a Cache,
- pub crosshair_cache: &'a Cache,
- pub crosshair: bool,
- pub max: i64,
- pub scaling: f32,
- pub translation_x: f32,
- pub timeframe: u32,
- pub cell_width: f32,
- pub data_points: &'a BTreeMap<i64, (f32, f32)>,
- pub chart_bounds: Rectangle,
- }
- impl VolumeIndicator<'_> {
- 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
- }
- fn time_to_x(&self, time: i64) -> f32 {
- let time_per_cell = self.timeframe;
- let x = (time - self.max) as f32 / time_per_cell as f32;
- x * self.cell_width
- }
- }
- impl canvas::Program<Message> for VolumeIndicator<'_> {
- type State = Interaction;
- fn update(
- &self,
- interaction: &mut Interaction,
- event: Event,
- bounds: Rectangle,
- cursor: mouse::Cursor,
- ) -> Option<canvas::Action<Message>> {
- match event {
- Event::Mouse(mouse::Event::CursorMoved { .. }) => {
- let message = match *interaction {
- Interaction::None => {
- if self.crosshair && cursor.is_over(bounds) {
- Some(Message::CrosshairMoved)
- } else {
- None
- }
- }
- _ => None,
- };
- let action =
- message.map_or(canvas::Action::request_redraw(), canvas::Action::publish);
- Some(match interaction {
- Interaction::None => action,
- _ => action.and_capture(),
- })
- }
- _ => None,
- }
- }
- fn draw(
- &self,
- _state: &Self::State,
- renderer: &Renderer,
- theme: &Theme,
- bounds: Rectangle,
- cursor: mouse::Cursor,
- ) -> Vec<Geometry> {
- if self.data_points.is_empty() {
- return vec![];
- }
- let center = Vector::new(bounds.width / 2.0, bounds.height / 2.0);
- let palette = theme.extended_palette();
- let indicator = self.indicator_cache.draw(renderer, bounds.size(), |frame| {
- frame.translate(center);
- frame.scale(self.scaling);
- frame.translate(Vector::new(
- self.translation_x,
- (-bounds.height / self.scaling) / 2.0,
- ));
- 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),
- );
- let mut max_volume: f32 = 0.0;
- self.data_points
- .range(earliest..=latest)
- .for_each(|(_, (buy_volume, sell_volume))| {
- max_volume = max_volume.max(buy_volume.max(*sell_volume));
- });
- self.data_points.range(earliest..=latest).for_each(
- |(timestamp, (buy_volume, sell_volume))| {
- let x_position = self.time_to_x(*timestamp);
- if max_volume > 0.0 {
- if *buy_volume != -1.0 {
- let buy_bar_height =
- (buy_volume / max_volume) * (bounds.height / self.scaling);
- let sell_bar_height =
- (sell_volume / max_volume) * (bounds.height / self.scaling);
- let bar_width = (self.cell_width / 2.0) * 0.9;
- frame.fill_rectangle(
- Point::new(
- x_position - bar_width,
- (region.y + region.height) - sell_bar_height,
- ),
- Size::new(bar_width, sell_bar_height),
- palette.danger.base.color,
- );
- frame.fill_rectangle(
- Point::new(x_position, (region.y + region.height) - buy_bar_height),
- Size::new(bar_width, buy_bar_height),
- palette.success.base.color,
- );
- } else {
- let bar_height =
- (sell_volume / max_volume) * (bounds.height / self.scaling);
- let bar_width = self.cell_width * 0.9;
- frame.fill_rectangle(
- Point::new(
- x_position - (bar_width / 2.0),
- (bounds.height / self.scaling) - bar_height,
- ),
- Size::new(bar_width, bar_height),
- palette.secondary.strong.color,
- );
- }
- }
- },
- );
- });
- if self.crosshair {
- let crosshair = self.crosshair_cache.draw(renderer, bounds.size(), |frame| {
- let dashed_line = Stroke::with_color(
- Stroke {
- width: 1.0,
- line_dash: LineDash {
- segments: &[4.0, 4.0],
- offset: 8,
- },
- ..Default::default()
- },
- palette.secondary.strong.color
- .scale_alpha(
- if palette.is_dark {
- 0.6
- } else {
- 1.0
- },
- ),
- );
- if let Some(cursor_position) = cursor.position_in(self.chart_bounds) {
- let region = self.visible_region(frame.size());
- // Vertical time line
- let earliest = self.x_to_time(region.x) as f64;
- let latest = self.x_to_time(region.x + region.width) as f64;
- let crosshair_ratio = f64::from(cursor_position.x / bounds.width);
- let crosshair_millis = earliest + crosshair_ratio * (latest - earliest);
- let rounded_timestamp =
- (crosshair_millis / (self.timeframe as f64)).round() as i64 * self.timeframe as i64;
- let snap_ratio = ((rounded_timestamp as f64 - earliest) / (latest - earliest)) as f32;
- frame.stroke(
- &Path::line(
- Point::new(snap_ratio * bounds.width, 0.0),
- Point::new(snap_ratio * bounds.width, bounds.height),
- ),
- dashed_line,
- );
- if let Some((_, (buy_v, sell_v))) = self
- .data_points
- .iter()
- .find(|(time, _)| **time == rounded_timestamp)
- {
- let mut tooltip_bg_height = 28.0;
- let tooltip_text: String = if *buy_v != -1.0 {
- format!(
- "Buy Volume: {}\nSell Volume: {}",
- format_with_commas(*buy_v),
- format_with_commas(*sell_v),
- )
- } else {
- tooltip_bg_height = 14.0;
- format!(
- "Volume: {}",
- format_with_commas(*sell_v),
- )
- };
- let text = canvas::Text {
- content: tooltip_text,
- position: Point::new(8.0, 2.0),
- size: iced::Pixels(10.0),
- color: palette.background.base.text,
- ..canvas::Text::default()
- };
- frame.fill_text(text);
- frame.fill_rectangle(
- Point::new(4.0, 0.0),
- Size::new(140.0, tooltip_bg_height),
- palette.background.base.color,
- );
- }
- } else if let Some(cursor_position) = cursor.position_in(bounds) {
- // Horizontal price line
- let highest = self.max as f32;
- let lowest = 0.0;
- let crosshair_ratio = cursor_position.y / bounds.height;
- let crosshair_price = highest + crosshair_ratio * (lowest - highest);
- let rounded_price = round_to_tick(crosshair_price, 1.0);
- let snap_ratio = (rounded_price - highest) / (lowest - highest);
- frame.stroke(
- &Path::line(
- Point::new(0.0, snap_ratio * bounds.height),
- Point::new(bounds.width, snap_ratio * bounds.height),
- ),
- dashed_line,
- );
- }
- });
- vec![indicator, crosshair]
- } else {
- vec![indicator]
- }
- }
- fn mouse_interaction(
- &self,
- interaction: &Interaction,
- bounds: Rectangle,
- cursor: mouse::Cursor,
- ) -> mouse::Interaction {
- match interaction {
- Interaction::Panning { .. } => mouse::Interaction::Grabbing,
- Interaction::Zoomin { .. } => mouse::Interaction::ZoomIn,
- Interaction::None if cursor.is_over(bounds) => {
- if self.crosshair {
- mouse::Interaction::Crosshair
- } else {
- mouse::Interaction::default()
- }
- }
- _ => 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(),
- }
- }
- }
|