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//! Numeric scales retain row indices and space them by their encoded key differences.
7//!
8//! [`LogicalRange`] represents a floating-point range of bar indices, which
9//! can be converted to a strict integer range for data-array slicing.
10
11#[cfg(feature = "widget")]
12use chrono::{DateTime, Utc};
13use std::sync::Arc;
14
15/// A snapshot of numeric sample positions, shared by the chart's coordinate mappings.
16///
17/// Obtained from the chart's time scale or rendering mapping. Logical indices still address
18/// rows; their positions are proportional to the encoded numeric keys. One spacing unit is
19/// the mean key gap, or one numeric unit for a single point.
20#[derive(Debug, Clone)]
21pub struct NumericPositions {
22    // Two edge slots retain both the first step and the later cadence of key interpolation.
23    points: Arc<[f64]>,
24    #[cfg(feature = "widget")]
25    pub(crate) unit: f64,
26    #[cfg(feature = "widget")]
27    origin: DateTime<Utc>,
28}
29
30impl NumericPositions {
31    #[cfg(feature = "widget")]
32    pub(crate) fn new(points: Vec<f64>, unit: f64, origin: DateTime<Utc>) -> Self {
33        Self {
34            points: points.into(),
35            unit,
36            origin,
37        }
38    }
39
40    #[cfg(feature = "widget")]
41    pub(crate) fn index_at(&self, time: DateTime<Utc>) -> f64 {
42        let elapsed = time - self.origin;
43        let seconds = elapsed.num_seconds() as f64 + f64::from(elapsed.subsec_nanos()) / 1e9;
44        self.index(seconds / self.unit)
45    }
46
47    pub(crate) fn position(&self, index: f64) -> f64 {
48        if self.points.len() < 2 || !index.is_finite() {
49            return index;
50        }
51        let at = (index + 2.0)
52            .floor()
53            .clamp(0.0, (self.points.len() - 2) as f64) as usize;
54        self.points[at] + (index + 2.0 - at as f64) * (self.points[at + 1] - self.points[at])
55    }
56
57    pub(crate) fn index(&self, position: f64) -> f64 {
58        if self.points.len() < 2 || !position.is_finite() {
59            return position;
60        }
61        let right = self
62            .points
63            .partition_point(|&point| point < position)
64            .clamp(1, self.points.len() - 1);
65        let gap = self.points[right] - self.points[right - 1];
66        right as f64 - 3.0
67            + if gap > 0.0 {
68                (position - self.points[right - 1]) / gap
69            } else {
70                0.0
71            }
72    }
73}
74
75/// A floating-point range of bar indices on the horizontal axis.
76///
77/// `left` and `right` may extend beyond `[0, bar_count)` when the chart is
78/// scrolled past the data edges.  Use [`to_strict_range`](LogicalRange::to_strict_range)
79/// to clamp to valid array indices.
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub struct LogicalRange {
82    /// Leftmost visible bar index (may be negative when scrolled past the start).
83    pub left: f32,
84    /// Rightmost visible bar index (may exceed `bar_count` when scrolled into the future).
85    pub right: f32,
86}
87
88impl LogicalRange {
89    /// Construct a range from its left and right logical indices.
90    pub fn new(left: f32, right: f32) -> Self {
91        Self { left, right }
92    }
93
94    /// Convert to an end-exclusive integer range, including partially visible edge slots.
95    pub fn to_strict_range(&self) -> (usize, usize) {
96        let start = self.left.floor().max(0.0) as usize;
97        let end = (f64::from(self.right).ceil() + 1.0).max(0.0) as usize;
98        (start, end)
99    }
100
101    /// Length of this range
102    pub fn length(&self) -> f32 {
103        self.right - self.left
104    }
105}
106
107/// The minimum bar spacing used when the configured one is not a finite number.
108const DEFAULT_MIN_BAR_SPACING: f32 = 0.5;
109
110/// Keep spacing divisors positive without restricting subpixel views of large histories.
111const MIN_BAR_SPACING: f32 = 1e-6;
112
113/// Time-scale coordinate engine -- pure logic, no UI dependencies.
114///
115/// Manages the horizontal mapping between logical bar indices and pixel
116/// positions.  The chart widget reads from `TimeScale` to lay out candles and
117/// writes back scroll/zoom deltas from user interaction.
118///
119/// # Coordinate model
120///
121/// The rightmost data point (the latest bar) is the *anchor*.  The
122/// `right_offset` field shifts the anchor away from the right edge of the
123/// chart to leave whitespace for price labels and visual breathing room.
124///
125/// ```text
126///   bar index:  0  1  2  3  4  5  6  7  8  ·  ·
127///               │  │  │  │  │  │  │  │  │        │
128///               ◄──────── visible ──────────►     │
129///               ◄──────────── width ─────────────►
130///                                          ↑
131///                                     right_offset
132/// ```
133#[derive(Debug, Clone)]
134pub struct TimeScale {
135    /// Numeric positions, or ordinal spacing when absent.
136    numeric_positions: Option<NumericPositions>,
137    #[cfg(feature = "widget")]
138    numeric_revision: Option<(u64, usize, Option<chrono::DateTime<chrono::Utc>>)>,
139    /// Bar spacing in pixels; retain fractional precision when fitting session gaps.
140    bar_spacing: f64,
141    /// Right offset in bars from the edge
142    right_offset: f64,
143    /// Configured right offset in bars, restored at the live edge
144    live_right_offset: f32,
145    /// Configured right offset in pixels, restored at the live edge; has priority over bars
146    live_right_offset_pixels: Option<f32>,
147    /// Width of the chart area in pixels
148    width: f32,
149    /// Total number of bars in dataset
150    bar_cnt: usize,
151    /// Min bar spacing constraint
152    min_bar_spacing: f32,
153    /// Max bar spacing constraint (0 = unlimited)
154    max_bar_spacing: f32,
155    /// Prevent scrolling past left edge
156    fix_left_edge: bool,
157    /// Prevent scrolling past right edge
158    fix_right_edge: bool,
159}
160
161impl TimeScale {
162    /// Create new TimeScale with default settings
163    pub fn new() -> Self {
164        Self {
165            numeric_positions: None,
166            #[cfg(feature = "widget")]
167            numeric_revision: None,
168            bar_spacing: 8.0,
169            right_offset: 5.0, // Default: 5 bars of whitespace on the right
170            live_right_offset: 2.5,
171            live_right_offset_pixels: None,
172            width: 800.0,
173            bar_cnt: 0,
174            min_bar_spacing: DEFAULT_MIN_BAR_SPACING,
175            max_bar_spacing: 0.0, // unlimited
176            fix_left_edge: false,
177            fix_right_edge: false, // Allow scrolling past latest bar
178        }
179    }
180
181    /// Apply options coming from `TimeScaleOptions`
182    /// Note: this does not own `TimeScaleOptions` to avoid a dependency cycle.
183    /// Non-finite spacing retains the current spacing, clamped to the new bounds.
184    /// Non-finite right offsets retain their previous values.
185    pub fn apply_options(
186        &mut self,
187        bar_spacing: f32,
188        min_bar_spacing: f32,
189        max_bar_spacing: f32,
190        fix_left_edge: bool,
191        fix_right_edge: bool,
192        right_offset_bars: f32,
193        right_offset_pixels: Option<f32>,
194    ) {
195        // Update constraints first so clamping of spacing uses new bounds
196        self.min_bar_spacing = min_bar_spacing;
197        self.max_bar_spacing = max_bar_spacing;
198        self.fix_left_edge = fix_left_edge;
199        self.fix_right_edge = fix_right_edge;
200
201        // Apply spacing with clamping
202        self.set_bar_spacing(bar_spacing);
203
204        if right_offset_bars.is_finite() {
205            self.live_right_offset = right_offset_bars;
206        }
207        if right_offset_pixels.is_none_or(f32::is_finite) {
208            self.live_right_offset_pixels = right_offset_pixels;
209        }
210        self.set_right_offset(self.live_edge_offset());
211    }
212
213    /// The configured right offset in bars at the current spacing; the pixel
214    /// option has priority over bars.
215    pub(crate) fn live_edge_offset(&self) -> f32 {
216        self.live_right_offset_pixels
217            .map_or(self.live_right_offset as f64, |px| {
218                px as f64 / self.bar_spacing
219            }) as f32
220    }
221
222    /// Set the width of the chart area (must be called when chart resizes).
223    /// Non-finite values are ignored.
224    pub fn set_width(&mut self, width: f32) {
225        if !width.is_finite() {
226            return;
227        }
228        self.width = width;
229        // CRITICAL: Changing width changes visible_bars calculation in constraints
230        self.apply_constraints();
231    }
232
233    /// Set the total number of bars in dataset
234    pub fn set_bar_cnt(&mut self, count: usize) {
235        self.bar_cnt = count;
236        // CRITICAL: Changing bar_cnt affects max_offset calculation in constraints
237        self.apply_constraints();
238    }
239
240    /// Numeric sample positions used by this scale, or `None` for ordinal time charts.
241    pub fn numeric_positions(&self) -> Option<&NumericPositions> {
242        self.numeric_positions.as_ref()
243    }
244
245    #[cfg(feature = "widget")]
246    pub(crate) fn set_numeric_positions(&mut self, positions: Option<NumericPositions>) {
247        self.numeric_positions = positions;
248        self.numeric_revision = None;
249        self.apply_constraints();
250    }
251
252    #[cfg(feature = "widget")]
253    pub(crate) fn refresh_numeric_positions(&mut self, bars: &[crate::model::Bar], revision: u64) {
254        let key = (revision, bars.len(), bars.last().map(|bar| bar.time));
255        if self.numeric_positions.is_some() && self.numeric_revision != Some(key) {
256            self.numeric_positions = Some(crate::model::numeric_positions(bars));
257            self.numeric_revision = Some(key);
258        }
259    }
260
261    pub(crate) fn position(&self, index: f64) -> f64 {
262        self.numeric_positions
263            .as_ref()
264            .map_or(index, |positions| positions.position(index))
265    }
266
267    fn index(&self, position: f64) -> f64 {
268        self.numeric_positions
269            .as_ref()
270            .map_or(position, |positions| positions.index(position))
271    }
272
273    /// Set bar spacing (with clamping to constraints). Non-finite values are ignored.
274    pub fn set_bar_spacing(&mut self, spacing: f32) {
275        self.bar_spacing = self.clamp_spacing(spacing as f64);
276        // CRITICAL: Changing bar_spacing changes visible_bars calculation,
277        // which affects edge constraints. Must re-apply constraints!
278        self.apply_constraints();
279    }
280
281    /// Update min bar spacing, floored at 0.000001 pixels. Non-finite values are ignored.
282    pub fn set_min_bar_spacing(&mut self, min: f32) {
283        if !min.is_finite() {
284            return;
285        }
286        self.min_bar_spacing = min.max(0.0);
287        // Re-apply spacing to respect new constraints
288        self.bar_spacing = self.clamp_spacing(self.bar_spacing);
289        self.apply_constraints();
290    }
291
292    /// Update max bar spacing (0 = unlimited), with positive limits floored at 0.000001 pixels.
293    /// Non-finite values are ignored.
294    pub fn set_max_bar_spacing(&mut self, max: f32) {
295        if !max.is_finite() {
296            return;
297        }
298        self.max_bar_spacing = max.max(0.0);
299        // Re-apply spacing to respect new constraints
300        self.bar_spacing = self.clamp_spacing(self.bar_spacing);
301        self.apply_constraints();
302    }
303
304    /// Configure whether left edge is fixed
305    pub fn set_fix_left_edge(&mut self, fix: bool) {
306        self.fix_left_edge = fix;
307        self.apply_constraints();
308    }
309
310    /// Configure whether right edge is fixed
311    pub fn set_fix_right_edge(&mut self, fix: bool) {
312        self.fix_right_edge = fix;
313        self.apply_constraints();
314    }
315
316    /// Set right offset (with constraint checking). Non-finite values are ignored.
317    pub fn set_right_offset(&mut self, offset: impl Into<f64>) {
318        let offset = offset.into();
319        if !offset.is_finite() {
320            return;
321        }
322        self.right_offset = offset;
323        self.apply_constraints();
324    }
325
326    /// Jump to latest bar position
327    /// Restores the configured right offset, as [`scroll_to_realtime`](Self::scroll_to_realtime) does.
328    pub fn jump_to_latest(&mut self) {
329        self.scroll_to_realtime();
330    }
331
332    /// Get current bar spacing
333    pub fn bar_spacing(&self) -> f32 {
334        self.bar_spacing as f32
335    }
336
337    /// Get current right offset. [`Self::right_offset_precise`] retains sub-bar precision.
338    pub fn right_offset(&self) -> f32 {
339        self.right_offset as f32
340    }
341
342    /// The scroll offset without narrowing fractional movement far into a large history.
343    pub fn right_offset_precise(&self) -> f64 {
344        self.right_offset
345    }
346
347    /// Within 1.5 slots of the live edge, including future whitespace.
348    #[cfg(feature = "widget")]
349    pub(crate) fn is_near_live_edge(&self) -> bool {
350        self.right_offset >= -1.5
351    }
352
353    /// Get current width
354    pub fn width(&self) -> f32 {
355        self.width
356    }
357
358    /// Get base index (last data point - coord anchor)
359    pub fn base_idx(&self) -> usize {
360        self.bar_cnt.saturating_sub(1)
361    }
362
363    /// Calculate visible logical range
364    pub fn visible_logical_range(&self) -> LogicalRange {
365        let base_idx = self.base_idx() as f64;
366        let bars_len = self.width as f64 / self.bar_spacing;
367        let right_border = base_idx + self.right_offset;
368        let left_border = right_border - bars_len + 1.0;
369
370        LogicalRange::new(
371            self.index(left_border) as f32,
372            self.index(right_border) as f32,
373        )
374    }
375
376    /// Convert logical index to x coord
377    ///
378    /// IMPORTANT: rect_width must be the actual width of the rect being drawn in,
379    /// NOT self.width (which may differ from the chart area's rect).
380    pub fn idx_to_coord(&self, index: usize, rect_min_x: f32, rect_width: f32) -> f32 {
381        self.idx_to_coord_precise(index as f64, rect_min_x, rect_width)
382    }
383
384    /// Convert fractional bar index to x coord (preserves sub-bar precision)
385    /// Used for drawings which store fractional bar indices for precise positioning
386    ///
387    /// IMPORTANT: rect_width must be the actual width of the rect being drawn in,
388    /// NOT self.width (which may differ from the chart area's rect).
389    pub fn idx_to_coord_precise(
390        &self,
391        index: impl Into<f64>,
392        rect_min_x: f32,
393        rect_width: f32,
394    ) -> f32 {
395        let delta_from_right =
396            (self.base_idx() as f64 - self.position(index.into())) + self.right_offset;
397        (f64::from(rect_min_x) + f64::from(rect_width)
398            - (delta_from_right + 0.5) * self.bar_spacing
399            - 1.0) as f32
400    }
401
402    /// Convert x coord to logical index
403    ///
404    /// IMPORTANT: rect_width must be the actual width of the rect being used,
405    /// NOT self.width (which may differ from the chart area's rect).
406    pub fn coord_to_idx(&self, x: f32, rect_min_x: f32, rect_width: f32) -> f32 {
407        self.coord_to_idx_precise(x, rect_min_x, rect_width) as f32
408    }
409
410    /// Convert x to a fractional logical index without narrowing a large history to `f32`.
411    /// `rect_width` is the actual width of the chart rect, as in [`Self::coord_to_idx`].
412    pub fn coord_to_idx_precise(&self, x: f32, rect_min_x: f32, rect_width: f32) -> f64 {
413        let relative_x = f64::from(x) - f64::from(rect_min_x);
414        let delta_from_right = (f64::from(rect_width) - relative_x - 1.0) / self.bar_spacing - 0.5;
415        self.index(self.base_idx() as f64 + (self.right_offset - delta_from_right))
416    }
417
418    /// Fit fractional edges, centring spans that exceed the configured or fallback spacing cap.
419    /// A fit that would leave the last bar left of the plot shows the newest bars when the span
420    /// exceeds the plot at the minimum spacing, and keeps the last bar's slot in view otherwise.
421    #[cfg(feature = "widget")]
422    pub(crate) fn fit_logical_range(&mut self, left: f64, right: f64, default_max: f64) {
423        let (left, right) = (self.position(left), self.position(right));
424        let (min, max) = self.spacing_bounds();
425        let max = if max > 0.0 { max } else { default_max.max(min) };
426        let spacing = (self.width as f64 - 1.0) / (right - left);
427        self.bar_spacing = self.clamp_spacing(spacing.min(max));
428        let slots = (self.width as f64 - 1.0) / self.bar_spacing;
429        let right = if spacing > max {
430            (left + right) / 2.0 + slots / 2.0
431        } else {
432            right
433        };
434        let last = self.base_idx() as f64;
435        let right = if right - slots <= last {
436            right
437        } else if spacing < min {
438            last + 0.5
439        } else {
440            // A slot wider than the plot centres the bar instead.
441            last + (slots - 0.5).max(slots / 2.0)
442        };
443        self.right_offset = right - last - 0.5;
444        self.apply_constraints();
445    }
446
447    /// Scroll by number of bars (negative = left, positive = right).
448    /// Non-finite deltas are ignored.
449    pub fn scroll_bars(&mut self, bars: f32) {
450        if !bars.is_finite() {
451            return;
452        }
453        self.set_right_offset(self.right_offset - f64::from(bars));
454    }
455
456    /// Scroll by pixels. Non-finite deltas are ignored.
457    pub fn scroll_pixels(&mut self, pixels: f32) {
458        if !pixels.is_finite() {
459            return;
460        }
461        self.set_right_offset(self.right_offset - pixels as f64 / self.bar_spacing);
462    }
463
464    /// Zoom at a specific point
465    ///
466    /// IMPORTANT: rect_width must be the actual width of the chart rect being zoomed.
467    /// Non-finite deltas, anchors and widths are ignored.
468    pub fn zoom(&mut self, delta: f32, anchor_x: f32, _rect_min_x: f32, rect_width: f32) {
469        if !delta.is_finite() || !anchor_x.is_finite() || !rect_width.is_finite() {
470            return;
471        }
472        let old_spacing = self.bar_spacing;
473        let anchor_distance = f64::from(rect_width) - f64::from(anchor_x) - 1.0;
474
475        // Calculate new bar spacing
476        let zoom_scale = delta as f64;
477        let new_spacing = old_spacing + zoom_scale * (old_spacing / 10.0);
478
479        self.bar_spacing = self.clamp_spacing(new_spacing);
480
481        // The absolute bar index cancels: only the change in distance from the right matters.
482        let right_offset = self.right_offset
483            + (anchor_distance / self.bar_spacing - anchor_distance / old_spacing);
484
485        // Debug logging - check for width mismatch
486        if (rect_width - self.width).abs() > 0.1 {
487            log::warn!(
488                "[ZOOM WIDTH MISMATCH] rect_width={:.1}, self.width={:.1}",
489                rect_width,
490                self.width
491            );
492        }
493
494        // Apply constraints after setting right_offset
495        let before_constraint = right_offset;
496        self.set_right_offset(right_offset);
497
498        // Log if constraints modified right_offset
499        if (self.right_offset - before_constraint).abs() > 0.01 {
500            log::debug!(
501                "[ZOOM CONSTRAINT] right_offset changed: {:.3} -> {:.3} (delta={:.3})",
502                before_constraint,
503                self.right_offset,
504                self.right_offset - before_constraint
505            );
506        }
507    }
508
509    /// Fit all data in view, then restore the configured right offset.
510    ///
511    /// The bars and the configured offset in bars share the width, or the bars share the width
512    /// less a configured pixel offset, so every bar stays in view.
513    pub fn fit_content(&mut self) {
514        if self.bar_cnt > 0 {
515            let (pixels, bars) = self
516                .live_right_offset_pixels
517                .map_or((0.0, self.live_right_offset), |px| (px, 0.0));
518            self.bar_spacing = self.clamp_spacing(
519                (self.width as f64 - pixels as f64) / (self.bar_cnt as f64 + bars as f64).max(1.0),
520            );
521            self.scroll_to_realtime();
522        }
523    }
524
525    /// Scroll to real-time (latest data)
526    /// Restores the right offset given to [`apply_options`](Self::apply_options)
527    /// (2.5 bars before it is called), the pixel offset converted at the
528    /// current bar spacing.
529    pub fn scroll_to_realtime(&mut self) {
530        self.set_right_offset(self.live_edge_offset());
531    }
532
533    /// Apply edge constraints
534    fn apply_constraints(&mut self) {
535        if !(self.width > 0.0 && self.bar_spacing > 0.0) || self.bar_cnt == 0 {
536            return;
537        }
538
539        // Calculate min and max right offset bounds
540        let min_right = self.calculate_min_right_offset();
541        let max_right = self.calculate_max_right_offset();
542
543        // Ensure well-ordered bounds (min <= max)
544        let (min_right_offset, max_right_offset) = if let Some(min_val) = min_right {
545            if min_val <= max_right {
546                (min_val, max_right)
547            } else {
548                (max_right, min_val)
549            }
550        } else {
551            (f64::NEG_INFINITY, max_right)
552        };
553
554        // Clamp right_offset between bounds
555        self.right_offset = self.right_offset.clamp(min_right_offset, max_right_offset);
556
557        // Near-edge stabilization for fixed right edge
558        if self.fix_right_edge && self.right_offset.abs() < 1e-6 {
559            self.right_offset = 0.0;
560        }
561    }
562
563    /// Calculate min allowed right_offset (most negative value).
564    /// This limits how far we can scroll to the LEFT (showing older data): with
565    /// `fix_left_edge` to the first bar at the left edge, also when every bar fits;
566    /// without it to 100 bars past the first.
567    fn calculate_min_right_offset(&self) -> Option<f64> {
568        if self.bar_cnt == 0 || !(self.width > 0.0 && self.bar_spacing > 0.0) {
569            return None;
570        }
571
572        if self.fix_left_edge {
573            // The offset that puts the left border of `visible_logical_range` at bar 0.
574            Some(self.width as f64 / self.bar_spacing - self.bar_cnt as f64)
575        } else {
576            Some(-(self.bar_cnt as f64 + 100.0))
577        }
578    }
579
580    /// Calculate max allowed right_offset (most positive value).
581    /// This limits how far we can scroll to the RIGHT (into the future).
582    fn calculate_max_right_offset(&self) -> f64 {
583        if self.bar_cnt == 0 {
584            return 0.0;
585        }
586
587        if self.fix_right_edge {
588            0.0
589        } else {
590            // Allow scrolling into future whitespace
591            // Use a generous bound based on visible bars at minimum zoom
592
593            self.width as f64 / self.spacing_bounds().0
594        }
595    }
596
597    /// The spacing bounds in a form `clamp` accepts: a NaN or infinite minimum is the default, a NaN
598    /// or infinite maximum is unlimited (0), and a minimum above a set maximum is that maximum.
599    /// Both the minimum and a positive maximum have a small positive floor.
600    fn spacing_bounds(&self) -> (f64, f64) {
601        let max = if self.max_bar_spacing.is_finite() && self.max_bar_spacing > 0.0 {
602            self.max_bar_spacing.max(MIN_BAR_SPACING) as f64
603        } else {
604            0.0
605        };
606        let min = if self.min_bar_spacing.is_finite() {
607            self.min_bar_spacing.max(MIN_BAR_SPACING) as f64
608        } else {
609            DEFAULT_MIN_BAR_SPACING as f64
610        };
611        (if max > 0.0 { min.min(max) } else { min }, max)
612    }
613
614    fn clamp_spacing(&self, spacing: f64) -> f64 {
615        let spacing = if spacing.is_finite() {
616            spacing
617        } else {
618            self.bar_spacing
619        };
620        match self.spacing_bounds() {
621            (min, max) if max > 0.0 => spacing.clamp(min, max),
622            (min, _) => spacing.max(min),
623        }
624    }
625
626    /// Get number of visible candles
627    pub fn visible_candles(&self) -> usize {
628        (self.width as f64 / self.bar_spacing).floor() as usize
629    }
630}
631
632impl Default for TimeScale {
633    fn default() -> Self {
634        Self::new()
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    /// At 200 points per bar a one-point pan is 0.005 bars, even with a million-bar anchor.
643    #[test]
644    fn large_history_keeps_small_pans_and_zoom_anchors() {
645        for (offset, index) in [(0.0, 999_999), (-500_000.0, 499_999)] {
646            let mut scale = TimeScale::new();
647            scale.set_bar_cnt(1_000_000);
648            scale.set_width(800.0);
649            scale.set_bar_spacing(200.0);
650            scale.set_right_offset(offset);
651            assert_eq!(scale.idx_to_coord(index, 0.0, 800.0), 699.0);
652            scale.scroll_pixels(1.0);
653            assert_eq!(scale.idx_to_coord(index, 0.0, 800.0), 700.0);
654            let anchor = scale.idx_to_coord(index - 2, 0.0, 800.0);
655            for delta in [1.0, -1.0, 2.0] {
656                scale.zoom(delta, anchor, 0.0, 800.0);
657                assert!((scale.idx_to_coord(index - 2, 0.0, 800.0) - anchor).abs() < 1e-3);
658            }
659        }
660    }
661
662    /// Returning to the live edge, by following it or by jumping back, restores
663    /// the configured offset: 20 bars, or 60 px, which is 10 bars at a spacing
664    /// of 6 and 5 bars once zoomed to 12.
665    #[test]
666    fn live_edge_keeps_the_configured_right_offset() {
667        type Back = fn(&mut TimeScale);
668        for (bars, pixels, spacing, expected) in [
669            (20.0, None, 6.0, 20.0),
670            (20.0, Some(60.0), 6.0, 10.0),
671            (20.0, Some(60.0), 12.0, 5.0),
672        ] {
673            let mut ts = TimeScale::new();
674            ts.set_bar_cnt(100);
675            ts.apply_options(6.0, 0.5, 0.0, false, false, bars, pixels);
676            ts.set_bar_spacing(spacing);
677            let backs: [(&str, Back); 2] = [
678                ("follow", TimeScale::scroll_to_realtime),
679                ("jump", TimeScale::jump_to_latest),
680            ];
681            for (name, back) in backs {
682                ts.scroll_bars(30.0);
683                back(&mut ts);
684                assert_eq!(
685                    ts.right_offset(),
686                    expected,
687                    "{pixels:?} at {spacing}, {name}"
688                );
689            }
690        }
691    }
692
693    /// Scrolling far into the past stops with the first bar at the left edge while
694    /// `fix_left_edge` is set: 100 bars fit 800 px at a spacing of 8, so the right offset stops
695    /// at 100 - 1,000 bars, and at 100 - 50 bars when every bar fits, leaving the empty space on
696    /// the right. Unset, the view goes on to 100 bars past the first.
697    #[test]
698    fn fix_left_edge_stops_the_view_at_the_first_bar() {
699        for (fix, bars, expected) in [
700            (true, 1000, -900.0),
701            (false, 1000, -1100.0),
702            (true, 50, 50.0),
703            (false, 50, -150.0),
704        ] {
705            let mut ts = TimeScale::new();
706            ts.set_width(800.0);
707            ts.set_bar_cnt(bars);
708            ts.apply_options(8.0, 0.5, 0.0, fix, false, 2.5, None);
709            ts.scroll_bars(5000.0);
710            assert_eq!(ts.right_offset(), expected, "fix {fix}, {bars} bars");
711        }
712    }
713
714    #[test]
715    fn non_finite_inputs_keep_the_time_scale_state() {
716        type Change = fn(&mut TimeScale, f32);
717        let changes: [(&str, Change); 11] = [
718            ("width", TimeScale::set_width),
719            ("spacing", TimeScale::set_bar_spacing),
720            ("minimum spacing", TimeScale::set_min_bar_spacing),
721            ("maximum spacing", TimeScale::set_max_bar_spacing),
722            ("offset", |ts, value| ts.set_right_offset(value)),
723            ("bars", TimeScale::scroll_bars),
724            ("pixels", TimeScale::scroll_pixels),
725            ("zoom delta", |ts, value| ts.zoom(value, 400.0, 0.0, 800.0)),
726            ("zoom anchor", |ts, value| ts.zoom(1.0, value, 0.0, 800.0)),
727            ("zoom width", |ts, value| ts.zoom(1.0, 400.0, 0.0, value)),
728            ("options", |ts, value| {
729                ts.apply_options(value, 0.5, 50.0, true, false, value, Some(value));
730            }),
731        ];
732        for count in [0, 100] {
733            for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
734                for (name, change) in changes {
735                    let mut ts = TimeScale::new();
736                    ts.set_bar_cnt(count);
737                    ts.apply_options(8.0, 0.5, 50.0, true, false, 7.0, Some(80.0));
738                    change(&mut ts, invalid);
739                    assert_eq!(ts.width(), 800.0, "{name}, {invalid}");
740                    assert_eq!(ts.bar_spacing(), 8.0, "{name}, {invalid}");
741                    assert_eq!(ts.right_offset(), 10.0, "{name}, {invalid}");
742                    ts.set_bar_cnt(100);
743                    ts.scroll_bars(1.0);
744                    ts.scroll_to_realtime();
745                    assert_eq!(ts.right_offset(), 10.0, "{name}, {invalid}");
746                    ts.set_bar_spacing(0.0);
747                    assert_eq!(ts.bar_spacing(), 0.5, "{name}, {invalid}");
748                    ts.set_bar_spacing(100.0);
749                    assert_eq!(ts.bar_spacing(), 50.0, "{name}, {invalid}");
750                    ts.apply_options(8.0, 0.5, 50.0, true, false, invalid, None);
751                    assert_eq!(ts.right_offset(), 7.0, "bar offset, {invalid}");
752                    assert_eq!(ts.visible_logical_range(), LogicalRange::new(7.0, 106.0));
753                }
754            }
755        }
756    }
757
758    #[test]
759    fn inconsistent_spacing_bounds_clamp_without_panicking() {
760        // Zoom-outs stop at the effective minimum and leave zooming and scrolling usable.
761        type Setup = fn(&mut TimeScale);
762        let rows: [(&str, Setup, (f32, f32)); 10] = [
763            (
764                "zero minimum",
765                |ts| ts.set_min_bar_spacing(0.0),
766                (8.0, 1e-6),
767            ),
768            (
769                "negative minimum option",
770                |ts| ts.apply_options(6.0, -1.0, 0.0, false, false, 2.0, None),
771                (6.0, 1e-6),
772            ),
773            (
774                "maximum below floor",
775                |ts| ts.set_max_bar_spacing(1e-8),
776                (1e-6, 1e-6),
777            ),
778            (
779                "NaN minimum setter",
780                |ts| ts.set_min_bar_spacing(f32::NAN),
781                (8.0, 0.5),
782            ),
783            (
784                "minimum above maximum",
785                |ts| ts.apply_options(6.0, 10.0, 5.0, false, false, 2.0, None),
786                (5.0, 5.0),
787            ),
788            (
789                "NaN minimum",
790                |ts| ts.apply_options(6.0, f32::NAN, 0.0, false, false, 2.0, None),
791                (6.0, 0.5),
792            ),
793            (
794                "minimum set above maximum",
795                |ts| {
796                    ts.set_max_bar_spacing(5.0);
797                    ts.set_min_bar_spacing(10.0);
798                },
799                (5.0, 5.0),
800            ),
801            (
802                "NaN spacing with a raised minimum",
803                |ts| ts.apply_options(f32::NAN, 10.0, 0.0, false, false, 2.0, None),
804                (10.0, 10.0),
805            ),
806            (
807                "NaN spacing with a lowered maximum",
808                |ts| ts.apply_options(f32::NAN, 0.5, 5.0, false, false, 2.0, None),
809                (5.0, 0.5),
810            ),
811            (
812                "NaN spacing with a lowered maximum and fixed left edge",
813                |ts| {
814                    ts.set_bar_spacing(6.0);
815                    ts.apply_options(f32::NAN, 0.5, 4.0, true, false, 2.0, None);
816                },
817                (4.0, 0.5),
818            ),
819        ];
820        for (name, setup, (set, zoomed)) in rows {
821            let mut ts = TimeScale::new();
822            ts.set_bar_cnt(100);
823            setup(&mut ts);
824            assert_eq!(ts.bar_spacing(), set, "{name}");
825            for fix_left in [false, true] {
826                ts.set_fix_left_edge(fix_left);
827                for delta in [-10.0, -9.999_999, -100.0] {
828                    for _ in 0..100 {
829                        ts.zoom(delta, 799.0, 0.0, 800.0);
830                        assert_eq!(ts.bar_spacing(), zoomed, "{name}");
831                        let range = ts.visible_logical_range();
832                        assert!(range.left.is_finite() && range.right.is_finite(), "{name}");
833                        assert!(ts.right_offset().is_finite(), "{name}");
834                        assert!(ts.right_offset_precise().is_finite(), "{name}");
835                    }
836                }
837                // At the fixed left edge, width / spacing - bar count is the offset.
838                if fix_left {
839                    ts.scroll_bars(f32::MAX);
840                    let expected = 800.0 / f64::from(zoomed) - 100.0;
841                    assert!(
842                        (ts.right_offset_precise() - expected).abs() < 1e-6,
843                        "{name}"
844                    );
845                }
846                ts.zoom(5.0, 400.0, 0.0, 800.0);
847                if set > zoomed {
848                    assert!(ts.bar_spacing() > zoomed, "{name}");
849                }
850                ts.set_bar_spacing(set);
851                ts.set_fix_left_edge(false);
852                ts.set_right_offset(0.0);
853                ts.scroll_pixels(10.0);
854                let expected = (-10.0 / f64::from(set)).max(-200.0);
855                assert!(
856                    (ts.right_offset_precise() - expected).abs() < 1e-6,
857                    "{name}"
858                );
859            }
860        }
861    }
862}