Skip to main content

egui_charts/model/
timescale.rs

1//! Time-scale coordinate system (model layer).
2//!
3//! [`TimeScale`] manages the mapping between logical bar indices and pixel
4//! coordinates on the horizontal axis.  It owns the bar spacing, scroll offset,
5//! and edge constraints that control which bars are visible.
6//!
7//! [`LogicalRange`] represents a floating-point range of bar indices, which
8//! can be converted to a strict integer range for data-array slicing.
9
10/// A floating-point range of bar indices on the horizontal axis.
11///
12/// `left` and `right` may extend beyond `[0, bar_count)` when the chart is
13/// scrolled past the data edges.  Use [`to_strict_range`](LogicalRange::to_strict_range)
14/// to clamp to valid array indices.
15#[derive(Debug, Clone, Copy)]
16pub struct LogicalRange {
17    /// Leftmost visible bar index (may be negative when scrolled past the start).
18    pub left: f32,
19    /// Rightmost visible bar index (may exceed `bar_count` when scrolled into the future).
20    pub right: f32,
21}
22
23impl LogicalRange {
24    pub fn new(left: f32, right: f32) -> Self {
25        Self { left, right }
26    }
27
28    /// Convert to strict integer range for data access
29    pub fn to_strict_range(&self) -> (usize, usize) {
30        let start = self.left.floor().max(0.0) as usize;
31        let end = self.right.ceil().max(0.0) as usize;
32        (start, end)
33    }
34
35    /// Length of this range
36    pub fn length(&self) -> f32 {
37        self.right - self.left
38    }
39}
40
41/// Time-scale coordinate engine -- pure logic, no UI dependencies.
42///
43/// Manages the horizontal mapping between logical bar indices and pixel
44/// positions.  The chart widget reads from `TimeScale` to lay out candles and
45/// writes back scroll/zoom deltas from user interaction.
46///
47/// # Coordinate model
48///
49/// The rightmost data point (the latest bar) is the *anchor*.  The
50/// `right_offset` field shifts the anchor away from the right edge of the
51/// chart to leave whitespace for price labels and visual breathing room.
52///
53/// ```text
54///   bar index:  0  1  2  3  4  5  6  7  8  ·  ·
55///               │  │  │  │  │  │  │  │  │        │
56///               ◄──────── visible ──────────►     │
57///               ◄──────────── width ─────────────►
58///                                          ↑
59///                                     right_offset
60/// ```
61#[derive(Debug, Clone)]
62pub struct TimeScale {
63    /// Bar spacing in pixels
64    bar_spacing: f32,
65    /// Right offset in bars from the edge
66    right_offset: f32,
67    /// Width of the chart area in pixels
68    width: f32,
69    /// Total number of bars in dataset
70    bar_cnt: usize,
71    /// Min bar spacing constraint
72    min_bar_spacing: f32,
73    /// Max bar spacing constraint (0 = unlimited)
74    max_bar_spacing: f32,
75    /// Prevent scrolling past left edge
76    fix_left_edge: bool,
77    /// Prevent scrolling past right edge
78    fix_right_edge: bool,
79}
80
81impl TimeScale {
82    /// Create new TimeScale with default settings
83    pub fn new() -> Self {
84        Self {
85            bar_spacing: 8.0,
86            right_offset: 5.0, // Default: 5 bars of whitespace on the right
87            width: 800.0,
88            bar_cnt: 0,
89            min_bar_spacing: 0.5,
90            max_bar_spacing: 0.0, // unlimited
91            fix_left_edge: true,
92            fix_right_edge: false, // Allow scrolling past latest bar
93        }
94    }
95
96    /// Apply options coming from `TimeScaleOptions`
97    /// Note: this does not own `TimeScaleOptions` to avoid a dependency cycle.
98    pub fn apply_options(
99        &mut self,
100        bar_spacing: f32,
101        min_bar_spacing: f32,
102        max_bar_spacing: f32,
103        fix_left_edge: bool,
104        fix_right_edge: bool,
105        right_offset_bars: f32,
106        right_offset_pixels: Option<f32>,
107    ) {
108        // Update constraints first so clamping of spacing uses new bounds
109        self.min_bar_spacing = min_bar_spacing;
110        self.max_bar_spacing = max_bar_spacing;
111        self.fix_left_edge = fix_left_edge;
112        self.fix_right_edge = fix_right_edge;
113
114        // Apply spacing with clamping
115        self.set_bar_spacing(bar_spacing);
116
117        // Pixels option has priority over bars
118        let right_offset = if let Some(px) = right_offset_pixels {
119            // Convert pixels to bars using current spacing
120            px / self.bar_spacing
121        } else {
122            right_offset_bars
123        };
124        self.set_right_offset(right_offset);
125    }
126
127    /// Set the width of the chart area (must be called when chart resizes)
128    pub fn set_width(&mut self, width: f32) {
129        self.width = width;
130        // CRITICAL: Changing width changes visible_bars calculation in constraints
131        self.apply_constraints();
132    }
133
134    /// Set the total number of bars in dataset
135    pub fn set_bar_cnt(&mut self, count: usize) {
136        self.bar_cnt = count;
137        // CRITICAL: Changing bar_cnt affects max_offset calculation in constraints
138        self.apply_constraints();
139    }
140
141    /// Set bar spacing (with clamping to constraints)
142    pub fn set_bar_spacing(&mut self, spacing: f32) {
143        let clamped = if self.max_bar_spacing > 0.0 {
144            spacing.clamp(self.min_bar_spacing, self.max_bar_spacing)
145        } else {
146            spacing.max(self.min_bar_spacing)
147        };
148        self.bar_spacing = clamped;
149        // CRITICAL: Changing bar_spacing changes visible_bars calculation,
150        // which affects edge constraints. Must re-apply constraints!
151        self.apply_constraints();
152    }
153
154    /// Update min bar spacing
155    pub fn set_min_bar_spacing(&mut self, min: f32) {
156        self.min_bar_spacing = min.max(0.0);
157        // Re-apply spacing to respect new constraints
158        self.set_bar_spacing(self.bar_spacing);
159    }
160
161    /// Update max bar spacing (0 = unlimited)
162    pub fn set_max_bar_spacing(&mut self, max: f32) {
163        self.max_bar_spacing = max.max(0.0);
164        // Re-apply spacing to respect new constraints
165        self.set_bar_spacing(self.bar_spacing);
166    }
167
168    /// Configure whether left edge is fixed
169    pub fn set_fix_left_edge(&mut self, fix: bool) {
170        self.fix_left_edge = fix;
171        self.apply_constraints();
172    }
173
174    /// Configure whether right edge is fixed
175    pub fn set_fix_right_edge(&mut self, fix: bool) {
176        self.fix_right_edge = fix;
177        self.apply_constraints();
178    }
179
180    /// Set right offset (with constraint checking)
181    pub fn set_right_offset(&mut self, offset: f32) {
182        self.right_offset = offset;
183        self.apply_constraints();
184    }
185
186    /// Jump to latest bar position
187    /// Resets right_offset to the default sticky offset (2.5 bars)
188    pub fn jump_to_latest(&mut self) {
189        const DEFAULT_RIGHT_OFFSET: f32 = 2.5;
190        self.right_offset = DEFAULT_RIGHT_OFFSET;
191    }
192
193    /// Get current bar spacing
194    pub fn bar_spacing(&self) -> f32 {
195        self.bar_spacing
196    }
197
198    /// Get current right offset
199    pub fn right_offset(&self) -> f32 {
200        self.right_offset
201    }
202
203    /// Get current width
204    pub fn width(&self) -> f32 {
205        self.width
206    }
207
208    /// Get base index (last data point - coord anchor)
209    pub fn base_idx(&self) -> usize {
210        self.bar_cnt.saturating_sub(1)
211    }
212
213    /// Calculate visible logical range
214    pub fn visible_logical_range(&self) -> LogicalRange {
215        let base_idx = self.base_idx() as f32;
216        let bars_len = self.width / self.bar_spacing;
217        let right_border = base_idx + self.right_offset;
218        let left_border = right_border - bars_len + 1.0;
219
220        LogicalRange::new(left_border, right_border)
221    }
222
223    /// Convert logical index to x coord
224    ///
225    /// IMPORTANT: rect_width must be the actual width of the rect being drawn in,
226    /// NOT self.width (which may differ from the chart area's rect).
227    pub fn idx_to_coord(&self, index: usize, rect_min_x: f32, rect_width: f32) -> f32 {
228        let base_idx = self.base_idx();
229        let delta_from_right = base_idx as f32 + self.right_offset - index as f32;
230        let relative_x = rect_width - (delta_from_right + 0.5) * self.bar_spacing - 1.0;
231        rect_min_x + relative_x
232    }
233
234    /// Convert fractional bar index to x coord (preserves sub-bar precision)
235    /// Used for drawings which store fractional bar indices for precise positioning
236    ///
237    /// IMPORTANT: rect_width must be the actual width of the rect being drawn in,
238    /// NOT self.width (which may differ from the chart area's rect).
239    pub fn idx_to_coord_precise(&self, index: f32, rect_min_x: f32, rect_width: f32) -> f32 {
240        let base_idx = self.base_idx() as f32;
241        let delta_from_right = base_idx + self.right_offset - index;
242        let relative_x = rect_width - (delta_from_right + 0.5) * self.bar_spacing - 1.0;
243        rect_min_x + relative_x
244    }
245
246    /// Convert x coord to logical index
247    ///
248    /// IMPORTANT: rect_width must be the actual width of the rect being used,
249    /// NOT self.width (which may differ from the chart area's rect).
250    pub fn coord_to_idx(&self, x: f32, rect_min_x: f32, rect_width: f32) -> f32 {
251        let base_idx = self.base_idx() as f32;
252        let relative_x = x - rect_min_x;
253        let delta_from_right = (rect_width - relative_x - 1.0) / self.bar_spacing - 0.5;
254        base_idx + self.right_offset - delta_from_right
255    }
256
257    /// Scroll by number of bars (negative = left, positive = right)
258    pub fn scroll_bars(&mut self, bars: f32) {
259        self.right_offset -= bars;
260        self.apply_constraints();
261    }
262
263    /// Scroll by pixels
264    pub fn scroll_pixels(&mut self, pixels: f32) {
265        let bars = pixels / self.bar_spacing;
266        self.scroll_bars(bars);
267    }
268
269    /// Zoom at a specific point
270    ///
271    /// IMPORTANT: rect_width must be the actual width of the chart rect being zoomed.
272    pub fn zoom(&mut self, delta: f32, anchor_x: f32, rect_min_x: f32, rect_width: f32) {
273        let old_spacing = self.bar_spacing;
274        let _old_right_offset = self.right_offset;
275
276        // Get the bar index at anchor point BEFORE zoom
277        let anchor_bar_idx = self.coord_to_idx(rect_min_x + anchor_x, rect_min_x, rect_width);
278
279        // Calculate new bar spacing
280        let zoom_scale = delta;
281        let new_spacing = old_spacing + zoom_scale * (old_spacing / 10.0);
282
283        // Clamp bar spacing to constraints
284        let clamped = if self.max_bar_spacing > 0.0 {
285            new_spacing.clamp(self.min_bar_spacing, self.max_bar_spacing)
286        } else {
287            new_spacing.max(self.min_bar_spacing)
288        };
289        self.bar_spacing = clamped;
290
291        // DIRECT CALCULATION: Calculate right_offset needed to keep anchor_bar_idx at anchor_x
292        // Formula derived from idx_to_coord_precise:
293        //   x = rect_min_x + rect_width - (base_idx + right_offset - bar_idx + 0.5) * bar_spacing - 1
294        // Solving for right_offset:
295        //   right_offset = bar_idx - base_idx + (rect_width - (x - rect_min_x) - 1) / bar_spacing - 0.5
296        let base_idx = self.base_idx() as f32;
297        let relative_anchor = anchor_x; // anchor_x is already relative to rect_min_x
298        let delta_from_right = (rect_width - relative_anchor - 1.0) / self.bar_spacing - 0.5;
299        let calculated_offset = anchor_bar_idx - base_idx + delta_from_right;
300        self.right_offset = calculated_offset;
301
302        // Debug logging - check for width mismatch
303        if (rect_width - self.width).abs() > 0.1 {
304            log::warn!(
305                "[ZOOM WIDTH MISMATCH] rect_width={:.1}, self.width={:.1}",
306                rect_width,
307                self.width
308            );
309        }
310
311        // Apply constraints after setting right_offset
312        let before_constraint = self.right_offset;
313        self.apply_constraints();
314
315        // Log if constraints modified right_offset
316        if (self.right_offset - before_constraint).abs() > 0.01 {
317            log::debug!(
318                "[ZOOM CONSTRAINT] right_offset changed: {:.3} -> {:.3} (delta={:.3})",
319                before_constraint,
320                self.right_offset,
321                self.right_offset - before_constraint
322            );
323        }
324    }
325
326    /// Fit all data in view
327    pub fn fit_content(&mut self) {
328        if self.bar_cnt > 0 {
329            let spacing = self.width / self.bar_cnt as f32;
330            self.set_bar_spacing(spacing);
331            self.right_offset = 0.0;
332        }
333    }
334
335    /// Scroll to real-time (latest data)
336    /// Maintains sticky offset - keeps ~2.5 bars of whitespace on the right
337    /// for price labels and visual breathing room
338    pub fn scroll_to_realtime(&mut self) {
339        const REALTIME_OFFSET: f32 = 2.5;
340        self.right_offset = REALTIME_OFFSET;
341    }
342
343    /// Apply edge constraints
344    fn apply_constraints(&mut self) {
345        if self.width <= 0.0 || self.bar_spacing <= 0.0 || self.bar_cnt == 0 {
346            return;
347        }
348
349        // Calculate min and max right offset bounds
350        let min_right = self.calculate_min_right_offset();
351        let max_right = self.calculate_max_right_offset();
352
353        // Ensure well-ordered bounds (min <= max)
354        let (min_right_offset, max_right_offset) = if let Some(min_val) = min_right {
355            if min_val <= max_right {
356                (min_val, max_right)
357            } else {
358                (max_right, min_val)
359            }
360        } else {
361            (f32::NEG_INFINITY, max_right)
362        };
363
364        // Clamp right_offset between bounds
365        self.right_offset = self.right_offset.clamp(min_right_offset, max_right_offset);
366
367        // Near-edge stabilization for fixed right edge
368        if self.fix_right_edge && self.right_offset.abs() < 1e-6 {
369            self.right_offset = 0.0;
370        }
371    }
372
373    /// Calculate min allowed right_offset (most negative value).
374    /// This limits how far we can scroll to the LEFT (showing older data).
375    /// Uses a permissive bound to allow viewing all historical data.
376    fn calculate_min_right_offset(&self) -> Option<f32> {
377        if self.bar_cnt == 0 || self.bar_spacing <= 0.0 || self.width <= 0.0 {
378            return None;
379        }
380
381        // Allow scrolling to view all data plus some buffer
382        Some(-(self.bar_cnt as f32 + 100.0))
383    }
384
385    /// Calculate max allowed right_offset (most positive value).
386    /// This limits how far we can scroll to the RIGHT (into the future).
387    fn calculate_max_right_offset(&self) -> f32 {
388        if self.bar_cnt == 0 {
389            return 0.0;
390        }
391
392        if self.fix_right_edge {
393            0.0
394        } else {
395            // Allow scrolling into future whitespace
396            // Use a generous bound based on visible bars at minimum zoom
397
398            self.width / self.min_bar_spacing
399        }
400    }
401
402    /// Get number of visible candles
403    pub fn visible_candles(&self) -> usize {
404        (self.width / self.bar_spacing).floor() as usize
405    }
406}
407
408impl Default for TimeScale {
409    fn default() -> Self {
410        Self::new()
411    }
412}