use egui::{Pos2, Rect};
#[derive(Clone, Copy, Debug)]
pub struct ChartMapping {
pub rect: Rect,
pub bar_spacing: f32,
pub start_idx: usize,
pub base_idx: usize,
pub right_offset: f32,
pub price_min: f64,
pub price_max: f64,
}
impl ChartMapping {
pub fn new(
rect: Rect,
bar_spacing: f32,
start_idx: usize,
base_idx: usize,
right_offset: f32,
price_min: f64,
price_max: f64,
) -> Self {
Self {
rect,
bar_spacing,
start_idx,
base_idx,
right_offset,
price_min,
price_max,
}
}
pub fn x_only(
rect: Rect,
bar_spacing: f32,
start_idx: usize,
base_idx: usize,
right_offset: f32,
) -> Self {
Self {
rect,
bar_spacing,
start_idx,
base_idx,
right_offset,
price_min: 0.0,
price_max: 1.0,
}
}
#[inline]
pub fn idx_to_x(&self, bar_idx: usize) -> f32 {
let delta_from_right = self.base_idx as f32 + self.right_offset - bar_idx as f32;
let relative_x = self.rect.width() - (delta_from_right + 0.5) * self.bar_spacing - 1.0;
self.rect.min.x + relative_x
}
#[inline]
pub fn x_to_idx(&self, x: f32) -> usize {
if self.bar_spacing.abs() < f32::EPSILON {
return self.base_idx;
}
let relative_x = x - self.rect.min.x;
let delta_from_right = (self.rect.width() - relative_x - 1.0) / self.bar_spacing - 0.5;
let bar_idx = self.base_idx as f32 + self.right_offset - delta_from_right;
bar_idx.round() as usize
}
#[inline]
pub fn x_to_idx_f32(&self, x: f32) -> f32 {
if self.bar_spacing.abs() < f32::EPSILON {
return self.base_idx as f32 + self.right_offset;
}
let relative_x = x - self.rect.min.x;
let delta_from_right = (self.rect.width() - relative_x - 1.0) / self.bar_spacing - 0.5;
self.base_idx as f32 + self.right_offset - delta_from_right
}
#[inline]
pub fn is_x_visible(&self, x: f32) -> bool {
x >= self.rect.min.x && x <= self.rect.max.x
}
#[inline]
pub fn local_idx_at_x(&self, x: f32, visible_len: usize) -> Option<usize> {
if visible_len == 0 || self.bar_spacing.abs() < f32::EPSILON {
return None;
}
let global_idx = self.x_to_idx_f32(x).round() as isize;
let first = self.start_idx as isize;
let last = (self.start_idx + visible_len - 1) as isize;
if global_idx < first || global_idx > last {
return None;
}
Some((global_idx as usize) - self.start_idx)
}
#[inline]
pub fn bar_width(&self) -> f32 {
(self.bar_spacing * 0.6).max(1.0)
}
#[inline]
pub fn price_to_y(&self, price: f64) -> f32 {
let price_range = self.price_max - self.price_min;
if price_range.abs() < f64::EPSILON {
return self.rect.center().y;
}
let ratio = (price - self.price_min) / price_range;
self.rect.max.y - (ratio as f32 * self.rect.height())
}
#[inline]
pub fn y_to_price(&self, y: f32) -> f64 {
let height = self.rect.height();
if height.abs() < f32::EPSILON {
return self.price_min;
}
let price_range = self.price_max - self.price_min;
let ratio = (self.rect.max.y - y) / height;
self.price_min + (ratio as f64 * price_range)
}
#[inline]
pub fn is_y_visible(&self, y: f32) -> bool {
y >= self.rect.min.y && y <= self.rect.max.y
}
#[inline]
pub fn to_screen(&self, bar_idx: usize, price: f64) -> Pos2 {
Pos2::new(self.idx_to_x(bar_idx), self.price_to_y(price))
}
#[inline]
pub fn from_screen(&self, pos: Pos2) -> (usize, f64) {
(self.x_to_idx(pos.x), self.y_to_price(pos.y))
}
#[inline]
pub fn is_visible(&self, pos: Pos2) -> bool {
self.rect.contains(pos)
}
pub fn with_price_range(self, price_min: f64, price_max: f64) -> Self {
Self {
price_min,
price_max,
..self
}
}
pub fn with_rect(self, rect: Rect) -> Self {
Self { rect, ..self }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_idx_to_x_roundtrip() {
let mapping = ChartMapping::new(
Rect::from_min_size(Pos2::new(100.0, 50.0), egui::Vec2::new(800.0, 400.0)),
10.0, 50, 100, 5.0, 100.0,
200.0,
);
for idx in [50, 75, 100, 105] {
let x = mapping.idx_to_x(idx);
let recovered = mapping.x_to_idx(x);
assert_eq!(
recovered, idx,
"idx {} -> x {} -> idx {}",
idx, x, recovered
);
}
}
#[test]
fn test_price_to_y_finite_on_flat_range() {
let mapping = ChartMapping::new(
Rect::from_min_size(Pos2::new(100.0, 50.0), egui::Vec2::new(800.0, 400.0)),
10.0,
50,
100,
5.0,
150.0,
150.0, );
let y = mapping.price_to_y(150.0);
assert!(y.is_finite());
assert!((mapping.rect.min.y..=mapping.rect.max.y).contains(&y));
}
#[test]
fn test_inverse_transforms_on_degenerate_inputs() {
let degenerate = ChartMapping::new(
Rect::from_min_size(Pos2::new(0.0, 0.0), egui::Vec2::new(0.0, 0.0)),
0.0, 0,
0,
0.0,
100.0,
200.0,
);
let _ = degenerate.x_to_idx(10.0);
let idx_f = degenerate.x_to_idx_f32(10.0);
assert!(idx_f.is_finite());
let price = degenerate.y_to_price(10.0);
assert!(price.is_finite());
assert_eq!(price, 100.0);
}
#[test]
fn test_local_idx_at_x_resolves_bar_under_cursor() {
let mapping = ChartMapping::new(
Rect::from_min_size(Pos2::new(100.0, 50.0), egui::Vec2::new(800.0, 400.0)),
10.0, 50, 55, 0.0, 100.0,
200.0,
);
let visible_len = 6;
for local in 0..visible_len {
let global = mapping.start_idx + local;
let x = mapping.idx_to_x(global);
assert_eq!(
mapping.local_idx_at_x(x, visible_len),
Some(local),
"x {x} for global {global} should map to local {local}"
);
}
}
#[test]
fn test_local_idx_at_x_edges_and_empty() {
let mapping = ChartMapping::new(
Rect::from_min_size(Pos2::new(100.0, 50.0), egui::Vec2::new(800.0, 400.0)),
10.0,
50,
55,
0.0,
100.0,
200.0,
);
let visible_len = 6;
assert_eq!(mapping.local_idx_at_x(400.0, 0), None);
assert_eq!(
mapping.local_idx_at_x(mapping.rect.max.x + 50.0, visible_len),
None
);
assert_eq!(
mapping.local_idx_at_x(mapping.rect.min.x - 50.0, visible_len),
None
);
let first_x = mapping.idx_to_x(mapping.start_idx);
let last_x = mapping.idx_to_x(mapping.start_idx + visible_len - 1);
assert_eq!(mapping.local_idx_at_x(first_x, visible_len), Some(0));
assert_eq!(
mapping.local_idx_at_x(last_x, visible_len),
Some(visible_len - 1)
);
}
#[test]
fn test_local_idx_at_x_degenerate_spacing() {
let mapping = ChartMapping::new(
Rect::from_min_size(Pos2::new(0.0, 0.0), egui::Vec2::new(800.0, 400.0)),
0.0,
0,
5,
0.0,
100.0,
200.0,
);
assert_eq!(mapping.local_idx_at_x(400.0, 6), None);
}
#[test]
fn test_price_to_y_roundtrip() {
let mapping = ChartMapping::new(
Rect::from_min_size(Pos2::new(100.0, 50.0), egui::Vec2::new(800.0, 400.0)),
10.0,
50, 100, 5.0,
100.0,
200.0,
);
for price in [100.0, 125.0, 150.0, 175.0, 200.0] {
let y = mapping.price_to_y(price);
let recovered = mapping.y_to_price(y);
assert!(
(recovered - price).abs() < 0.001,
"price {} -> y {} -> price {}",
price,
y,
recovered
);
}
}
}