Skip to main content

egui_charts/chart/coords/
mod.rs

1//! Coordinate mapping between bar indices, prices and screen positions.
2//!
3//! Use [`ChartMapping`] to share the same mapping across rendering, hit testing
4//! and host overlays.
5
6use crate::scales::PriceMap;
7use egui::{Pos2, Rect};
8
9/// Unified coordinate mapping for charts and indicator panes.
10///
11/// Converts between:
12/// - Bar indices ↔ X screen coordinates
13/// - Prices ↔ Y screen coordinates
14///
15/// Share the mapping with overlays and hit tests to keep them aligned with the pane.
16#[derive(Clone, Debug)]
17pub struct ChartMapping {
18    /// Numeric row positions shared with the time scale; `None` keeps ordinal spacing.
19    pub numeric_positions: Option<crate::model::NumericPositions>,
20    /// The chart rectangle (rendering area)
21    pub rect: Rect,
22    /// Points per ordinal slot, or per mean numeric key gap.
23    pub bar_spacing: f32,
24    /// Index of the first visible bar
25    pub start_idx: usize,
26    /// Index of the final bar in the series, used as the horizontal anchor.
27    pub base_idx: usize,
28    /// Right-side offset for chart scrolling (in bar units)
29    pub right_offset: f64,
30    /// Price-to-y mapping of the pane; it carries its own vertical extent,
31    /// which need not match `rect`
32    pub price: PriceMap,
33}
34
35impl ChartMapping {
36    /// Create a new ChartMapping with all parameters.
37    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    // =========================================================================
57    // X-axis conversions (bar index ↔ screen X)
58    // =========================================================================
59
60    /// Convert bar index to X screen coordinate.
61    ///
62    /// This is the canonical formula used everywhere.
63    #[inline]
64    pub fn idx_to_x(&self, bar_idx: usize) -> f32 {
65        self.index_to_x(bar_idx as f64)
66    }
67
68    /// Convert a fractional row index to X, including numeric sample positions.
69    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    /// Convert X screen coordinate to bar index (inverse of idx_to_x).
81    ///
82    /// A degenerate `bar_spacing` (zero or sub-epsilon) has no inverse, so the
83    /// anchor index is returned rather than producing an infinite/NaN index.
84    #[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    /// Convert X screen coordinate to fractional bar index.
93    ///
94    /// Useful for precise positioning (e.g., crosshair snapping).
95    ///
96    /// A degenerate `bar_spacing` (zero or sub-epsilon) has no inverse, so the
97    /// anchor index is returned rather than producing an infinite/NaN index.
98    #[inline]
99    pub fn x_to_idx_f32(&self, x: f32) -> f32 {
100        self.x_to_idx_f64(x) as f32
101    }
102
103    /// Convert X to a fractional bar index, retaining sub-bar precision on large histories.
104    /// A degenerate spacing returns the anchor, as in [`Self::x_to_idx_f32`].
105    #[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    /// Check if an X coordinate is within the visible chart bounds.
120    #[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    /// Resolve the bar directly under a cursor X coordinate to a local index
126    /// into the visible data slice of length `visible_len`.
127    ///
128    /// Returns `None` when there is no data, or when the cursor falls before the
129    /// first bar or after the last bar (the right-side empty scroll margin), so
130    /// callers never index past the slice or read a stale neighbour. Resolution
131    /// uses [`Self::x_to_idx_f64`], the exact inverse of [`Self::idx_to_x`], so
132    /// the bar at its own centre x resolves to itself and the readout describes
133    /// the same bar the crosshair snaps to.
134    #[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    /// Calculate the bar width based on spacing.
150    #[inline]
151    pub fn bar_width(&self) -> f32 {
152        (self.bar_spacing * 0.6).max(1.0)
153    }
154
155    // =========================================================================
156    // Y-axis conversions (price ↔ screen Y)
157    // =========================================================================
158
159    /// Convert price to Y screen coordinate.
160    #[inline]
161    pub fn price_to_y(&self, price: f64) -> f32 {
162        self.price.price_to_y(price)
163    }
164
165    /// Convert Y screen coordinate to price (inverse of price_to_y).
166    #[inline]
167    pub fn y_to_price(&self, y: f32) -> f64 {
168        self.price.y_to_price(y)
169    }
170
171    /// Check if a Y coordinate is within the visible chart bounds.
172    #[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    // =========================================================================
178    // Combined conversions
179    // =========================================================================
180
181    /// Convert bar index and price to screen position.
182    #[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    /// Convert screen position to bar index and price.
188    #[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    /// Check if a screen position is within the visible chart bounds.
194    #[inline]
195    pub fn is_visible(&self, pos: Pos2) -> bool {
196        self.rect.contains(pos)
197    }
198
199    // =========================================================================
200    // Utility methods
201    // =========================================================================
202
203    /// Create a new ChartMapping with a different rect (for sub-regions).
204    ///
205    /// Only the x mapping and the bounds checks follow the new rect: `price`
206    /// still maps onto its own pane.
207    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    /// A mapping with a linear price axis over `min..max`.
221    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, // bar_spacing
246            50,   // start_idx
247            100,  // base_idx
248            5.0,  // right_offset
249            100.0,
250            200.0,
251        );
252
253        // x = 100 + 800 - (100 + 5 - 75 + 0.5) * 10 - 1 = 594.
254        assert_eq!(mapping.idx_to_x(75), 594.0);
255        assert_eq!(mapping.x_to_idx(594.0), 75);
256        // Test roundtrip for several indices
257        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        // A flat price window must map to a finite, in-rect y rather than
278        // dividing by zero and producing an off-screen NaN/inf.
279        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, // price_min == price_max
287        );
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        // Zero bar spacing and zero rect height have no inverse; the guards
296        // return finite, sensible fallbacks instead of NaN/inf.
297        let degenerate = mapping(
298            Rect::from_min_size(Pos2::new(0.0, 0.0), egui::Vec2::new(0.0, 0.0)),
299            0.0, // bar_spacing
300            0,
301            0,
302            0.0,
303            100.0,
304            200.0,
305        );
306        // No panic, finite results.
307        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        // Empty data never resolves a bar.
329        assert_eq!(mapping.local_idx_at_x(400.0, 0), None);
330
331        // Far to the right of the last bar (empty scroll margin) -> None.
332        assert_eq!(
333            mapping.local_idx_at_x(mapping.rect.max.x + 50.0, visible_len),
334            None
335        );
336
337        // Far to the left of the first bar -> None.
338        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        // Zero spacing has no inverse.
352        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,  // start_idx
365            100, // base_idx
366            5.0,
367            100.0,
368            200.0,
369        );
370
371        // 175 is three quarters of the way up a 400-point axis: 450 - 300 = 150.
372        assert_eq!(mapping.price_to_y(175.0), 150.0);
373        assert_eq!(mapping.y_to_price(150.0), 175.0);
374        // Test roundtrip for several prices
375        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}