egui_charts/chart/coords/
mod.rs1use crate::scales::PriceMap;
7use egui::{Pos2, Rect};
8
9#[derive(Clone, Debug)]
17pub struct ChartMapping {
18 pub numeric_positions: Option<crate::model::NumericPositions>,
20 pub rect: Rect,
22 pub bar_spacing: f32,
24 pub start_idx: usize,
26 pub base_idx: usize,
28 pub right_offset: f64,
30 pub price: PriceMap,
33}
34
35impl ChartMapping {
36 pub fn new(
38 rect: Rect,
39 bar_spacing: f32,
40 start_idx: usize,
41 base_idx: usize,
42 right_offset: impl Into<f64>,
43 price: PriceMap,
44 ) -> Self {
45 Self {
46 numeric_positions: None,
47 rect,
48 bar_spacing,
49 start_idx,
50 base_idx,
51 right_offset: right_offset.into(),
52 price,
53 }
54 }
55
56 #[inline]
64 pub fn idx_to_x(&self, bar_idx: usize) -> f32 {
65 self.index_to_x(bar_idx as f64)
66 }
67
68 pub fn index_to_x(&self, index: f64) -> f32 {
70 let position = self
71 .numeric_positions
72 .as_ref()
73 .map_or(index, |positions| positions.position(index));
74 let delta_from_right = (self.base_idx as f64 - position) + self.right_offset;
75 (f64::from(self.rect.min.x) + f64::from(self.rect.width())
76 - (delta_from_right + 0.5) * f64::from(self.bar_spacing)
77 - 1.0) as f32
78 }
79
80 #[inline]
85 pub fn x_to_idx(&self, x: f32) -> usize {
86 if self.bar_spacing.abs() < f32::EPSILON {
87 return self.base_idx;
88 }
89 self.x_to_idx_f64(x).round() as usize
90 }
91
92 #[inline]
99 pub fn x_to_idx_f32(&self, x: f32) -> f32 {
100 self.x_to_idx_f64(x) as f32
101 }
102
103 #[inline]
106 pub fn x_to_idx_f64(&self, x: f32) -> f64 {
107 if self.bar_spacing.abs() < f32::EPSILON {
108 return self.base_idx as f64 + self.right_offset;
109 }
110 let relative_x = f64::from(x) - f64::from(self.rect.min.x);
111 let delta_from_right =
112 (f64::from(self.rect.width()) - relative_x - 1.0) / f64::from(self.bar_spacing) - 0.5;
113 let position = self.base_idx as f64 + (self.right_offset - delta_from_right);
114 self.numeric_positions
115 .as_ref()
116 .map_or(position, |positions| positions.index(position))
117 }
118
119 #[inline]
121 pub fn is_x_visible(&self, x: f32) -> bool {
122 x >= self.rect.min.x && x <= self.rect.max.x
123 }
124
125 #[inline]
135 pub fn local_idx_at_x(&self, x: f32, visible_len: usize) -> Option<usize> {
136 if visible_len == 0 || self.bar_spacing.abs() < f32::EPSILON {
137 return None;
138 }
139 let global_idx = self.x_to_idx_f64(x).round() as isize;
140
141 let first = self.start_idx as isize;
142 let last = (self.start_idx + visible_len - 1) as isize;
143 if global_idx < first || global_idx > last {
144 return None;
145 }
146 Some((global_idx as usize) - self.start_idx)
147 }
148
149 #[inline]
151 pub fn bar_width(&self) -> f32 {
152 (self.bar_spacing * 0.6).max(1.0)
153 }
154
155 #[inline]
161 pub fn price_to_y(&self, price: f64) -> f32 {
162 self.price.price_to_y(price)
163 }
164
165 #[inline]
167 pub fn y_to_price(&self, y: f32) -> f64 {
168 self.price.y_to_price(y)
169 }
170
171 #[inline]
173 pub fn is_y_visible(&self, y: f32) -> bool {
174 y >= self.rect.min.y && y <= self.rect.max.y
175 }
176
177 #[inline]
183 pub fn to_screen(&self, bar_idx: usize, price: f64) -> Pos2 {
184 Pos2::new(self.idx_to_x(bar_idx), self.price_to_y(price))
185 }
186
187 #[inline]
189 pub fn from_screen(&self, pos: Pos2) -> (usize, f64) {
190 (self.x_to_idx(pos.x), self.y_to_price(pos.y))
191 }
192
193 #[inline]
195 pub fn is_visible(&self, pos: Pos2) -> bool {
196 self.rect.contains(pos)
197 }
198
199 pub fn with_rect(&self, rect: Rect) -> Self {
208 Self {
209 rect,
210 ..self.clone()
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::scales::{PriceRange, PriceTransform};
219
220 fn mapping(
222 rect: Rect,
223 bar_spacing: f32,
224 start_idx: usize,
225 base_idx: usize,
226 right_offset: f32,
227 min: f64,
228 max: f64,
229 ) -> ChartMapping {
230 let range = PriceRange::new(min, max);
231 let price = PriceMap::new(
232 PriceTransform::default(),
233 range,
234 rect.min.y,
235 rect.max.y,
236 false,
237 );
238 ChartMapping::new(rect, bar_spacing, start_idx, base_idx, right_offset, price)
239 }
240
241 #[test]
242 fn test_idx_to_x_roundtrip() {
243 let mapping = mapping(
244 Rect::from_min_size(Pos2::new(100.0, 50.0), egui::Vec2::new(800.0, 400.0)),
245 10.0, 50, 100, 5.0, 100.0,
250 200.0,
251 );
252
253 assert_eq!(mapping.idx_to_x(75), 594.0);
255 assert_eq!(mapping.x_to_idx(594.0), 75);
256 for idx in [50, 75, 100, 105] {
258 let x = mapping.idx_to_x(idx);
259 let recovered = mapping.x_to_idx(x);
260 assert_eq!(recovered, idx, "idx {idx} -> x {x} -> idx {recovered}");
261 }
262 for (right_offset, index) in [(-0.005, 999_999), (-500_000.005, 499_999)] {
263 let large = ChartMapping {
264 base_idx: 999_999,
265 bar_spacing: 200.0,
266 right_offset,
267 ..mapping.clone()
268 };
269 assert_eq!(large.idx_to_x(index), 800.0);
270 assert_eq!(large.x_to_idx(800.0), index);
271 assert_eq!(large.x_to_idx(901.0), index + 1);
272 }
273 }
274
275 #[test]
276 fn test_price_to_y_finite_on_flat_range() {
277 let mapping = mapping(
280 Rect::from_min_size(Pos2::new(100.0, 50.0), egui::Vec2::new(800.0, 400.0)),
281 10.0,
282 50,
283 100,
284 5.0,
285 150.0,
286 150.0, );
288 let y = mapping.price_to_y(150.0);
289 assert!(y.is_finite());
290 assert!((mapping.rect.min.y..=mapping.rect.max.y).contains(&y));
291 }
292
293 #[test]
294 fn test_inverse_transforms_on_degenerate_inputs() {
295 let degenerate = mapping(
298 Rect::from_min_size(Pos2::new(0.0, 0.0), egui::Vec2::new(0.0, 0.0)),
299 0.0, 0,
301 0,
302 0.0,
303 100.0,
304 200.0,
305 );
306 let _ = degenerate.x_to_idx(10.0);
308 let idx_f = degenerate.x_to_idx_f32(10.0);
309 assert!(idx_f.is_finite());
310 let price = degenerate.y_to_price(10.0);
311 assert!(price.is_finite());
312 assert_eq!(price, 100.0);
313 }
314
315 #[test]
316 fn test_local_idx_at_x_edges_and_empty() {
317 let mapping = mapping(
318 Rect::from_min_size(Pos2::new(100.0, 50.0), egui::Vec2::new(800.0, 400.0)),
319 10.0,
320 50,
321 55,
322 0.0,
323 100.0,
324 200.0,
325 );
326 let visible_len = 6;
327
328 assert_eq!(mapping.local_idx_at_x(400.0, 0), None);
330
331 assert_eq!(
333 mapping.local_idx_at_x(mapping.rect.max.x + 50.0, visible_len),
334 None
335 );
336
337 assert_eq!(
339 mapping.local_idx_at_x(mapping.rect.min.x - 50.0, visible_len),
340 None
341 );
342
343 for local in 0..visible_len {
344 let x = mapping.idx_to_x(mapping.start_idx + local);
345 assert_eq!(
346 mapping.local_idx_at_x(x, visible_len),
347 Some(local),
348 "local {local}"
349 );
350 }
351 let degenerate = ChartMapping {
353 bar_spacing: 0.0,
354 ..mapping
355 };
356 assert_eq!(degenerate.local_idx_at_x(400.0, visible_len), None);
357 }
358
359 #[test]
360 fn test_price_to_y_roundtrip() {
361 let mapping = mapping(
362 Rect::from_min_size(Pos2::new(100.0, 50.0), egui::Vec2::new(800.0, 400.0)),
363 10.0,
364 50, 100, 5.0,
367 100.0,
368 200.0,
369 );
370
371 assert_eq!(mapping.price_to_y(175.0), 150.0);
373 assert_eq!(mapping.y_to_price(150.0), 175.0);
374 for price in [100.0, 125.0, 150.0, 175.0, 200.0] {
376 let y = mapping.price_to_y(price);
377 let recovered = mapping.y_to_price(y);
378 assert!(
379 (recovered - price).abs() < 0.001,
380 "price {price} -> y {y} -> price {recovered}"
381 );
382 }
383 }
384}