Skip to main content

egui_charts/model/
chartstate.rs

1//! Chart state management (model layer, no rendering).
2//!
3//! [`ChartState`] is the back-end owner of bar data and coordinate systems.
4//! It tracks which bars are visible, manages price auto-scaling, and maintains
5//! a zoom-history stack for undo/redo.
6
7/// ChartState Backend - Pure state management (no rendering).
8/// Combines data with coordinate systems.
9use super::{BarData, TimeScale};
10
11/// A snapshot of zoom parameters, used for undo/redo of zoom operations.
12///
13/// The chart maintains a stack of these snapshots so the user can press
14/// "zoom out" to return to a previous view.
15#[derive(Debug, Clone, Copy)]
16pub struct ZoomState {
17    /// Pixels per bar at the time of the snapshot.
18    pub bar_spacing: f32,
19    /// Right offset in bars from the chart edge.
20    pub right_offset: f32,
21    /// Manual price range override, if any.
22    pub price_range: Option<(f64, f64)>,
23}
24
25/// Backend chart state -- owns bar data, time-scale coordinate system,
26/// price auto-scaling logic, and zoom history.
27///
28/// This is the pure-logic layer with no UI or rendering dependencies.  The UI
29/// layer reads from `ChartState` to determine what to draw and writes back
30/// interaction results (scroll, zoom, data updates).
31///
32/// # Example
33///
34/// ```
35/// use egui_charts::model::{BarData, ChartState};
36///
37/// let data = BarData::new();
38/// let state = ChartState::new(data);
39/// assert!(state.visible_data().is_empty());
40/// ```
41#[derive(Debug, Clone)]
42pub struct ChartState {
43    /// The OHLCV bar dataset.
44    data: BarData,
45    /// Time coordinate system (logical index <-> pixel mapping).
46    time_scale: TimeScale,
47    /// Whether to auto-calculate the visible price range from data.
48    price_auto_scale: bool,
49    /// Manual price range override `(min, max)`, active when auto-scale is off.
50    price_range: Option<(f64, f64)>,
51    /// Stack of previous zoom snapshots for the "zoom out" button.
52    zoom_history: Vec<ZoomState>,
53}
54
55impl ChartState {
56    /// Create new chart state with data
57    pub fn new(data: BarData) -> Self {
58        let mut time_scale = TimeScale::new();
59        time_scale.set_bar_cnt(data.len());
60
61        Self {
62            data,
63            time_scale,
64            price_auto_scale: true,
65            price_range: None,
66            zoom_history: Vec::new(),
67        }
68    }
69
70    /// Get reference to bar data
71    pub fn data(&self) -> &BarData {
72        &self.data
73    }
74
75    /// Get mutable reference to time scale
76    pub fn time_scale_mut(&mut self) -> &mut TimeScale {
77        &mut self.time_scale
78    }
79
80    /// Get reference to time scale
81    pub fn time_scale(&self) -> &TimeScale {
82        &self.time_scale
83    }
84
85    /// Update data (also updates bar count in time scale)
86    pub fn set_data(&mut self, data: BarData) {
87        self.time_scale.set_bar_cnt(data.len());
88        self.data = data;
89    }
90
91    /// Get visible data based on current time scale
92    pub fn visible_data(&self) -> &[crate::model::Bar] {
93        if self.data.bars.is_empty() {
94            return &[];
95        }
96
97        let logical_range = self.time_scale.visible_logical_range();
98        log::debug!(
99            "[visible_data] logical_range: left={}, right={}",
100            logical_range.left,
101            logical_range.right
102        );
103
104        let (mut start, mut end) = logical_range.to_strict_range();
105        log::debug!("[visible_data] BEFORE clamping: start={start}, end={end}");
106
107        // Clamp to valid data range
108        let data_len = self.data.len();
109        end = end.min(data_len);
110        start = start.min(end);
111
112        log::debug!("[visible_data] AFTER clamping: start={start}, end={end}, data_len={data_len}");
113
114        // Calculate how many bars should be visible
115        let expected_visible = self.time_scale.visible_candles();
116
117        // If we have too few bars visible (scrolled past beginning), extend the range
118        let actual_visible = end.saturating_sub(start);
119        log::debug!(
120            "[visible_data] actual_visible={actual_visible}, expected_visible={expected_visible}"
121        );
122
123        if actual_visible < expected_visible && start == 0 && data_len > 1 {
124            // We're at the beginning but don't have enough bars visible
125            // Extend end to show the expected number of bars
126            // Note: Only extend if we have more than 1 bar to avoid log spam during startup
127            let old_end = end;
128            end = expected_visible.min(data_len);
129            if end > old_end {
130                log::debug!(
131                    "[visible_data] EXTENDING RANGE: old_end={}, new_end={} (gained {} bars)",
132                    old_end,
133                    end,
134                    end - old_end
135                );
136            }
137        }
138
139        log::debug!(
140            "[visible_data] FINAL RESULT: returning bars[{}..{}] ({} bars)",
141            start,
142            end,
143            end - start
144        );
145
146        &self.data.bars[start..end]
147    }
148
149    /// Get visible data range indices
150    pub fn visible_range(&self) -> (usize, usize) {
151        if self.data.bars.is_empty() {
152            return (0, 0);
153        }
154
155        let logical_range = self.time_scale.visible_logical_range();
156        let (mut start, mut end) = logical_range.to_strict_range();
157
158        log::debug!("[visible_range] BEFORE clamping: start={start}, end={end}");
159
160        // Clamp to valid data range
161        let data_len = self.data.len();
162        end = end.min(data_len);
163        start = start.min(end);
164
165        // Calculate how many bars should be visible
166        let expected_visible = self.time_scale.visible_candles();
167
168        // If we have too few bars visible (scrolled past beginning), extend the range
169        let actual_visible = end.saturating_sub(start);
170
171        if actual_visible < expected_visible && start == 0 && data_len > 1 {
172            // We're at the beginning but don't have enough bars visible
173            // Extend end to show the expected number of bars
174            // Note: Only extend if we have more than 1 bar to avoid log spam during startup
175            let old_end = end;
176            end = expected_visible.min(data_len);
177            if end > old_end {
178                log::debug!(
179                    "[visible_range] EXTENDING RANGE: old_end={}, new_end={} (gained {} bars)",
180                    old_end,
181                    end,
182                    end - old_end
183                );
184            }
185        }
186
187        log::debug!(
188            "[visible_range] FINAL RESULT: ({}, {}) - {} bars",
189            start,
190            end,
191            end - start
192        );
193
194        (start, end)
195    }
196
197    /// Enable/disable price auto-scaling
198    pub fn set_price_auto_scale(&mut self, enabled: bool) {
199        self.price_auto_scale = enabled;
200        if enabled {
201            self.price_range = None;
202        }
203    }
204
205    /// Set manual price range
206    pub fn set_price_range(&mut self, min: f64, max: f64) {
207        self.price_auto_scale = false;
208        self.price_range = Some((min, max));
209    }
210
211    /// Get price range (auto-calculated or manual)
212    pub fn price_range(&self) -> (f64, f64) {
213        if let Some((min, max)) = self.price_range {
214            return (min, max);
215        }
216
217        // Auto-calculate from visible data
218        let visible = self.visible_data();
219        if visible.is_empty() {
220            return (0.0, 100.0);
221        }
222
223        let vmin = visible
224            .iter()
225            .map(|c| c.low)
226            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
227            .unwrap();
228        let vmax = visible
229            .iter()
230            .map(|c| c.high)
231            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
232            .unwrap();
233
234        // Apply 10% margin
235        let range = (vmax - vmin).max(1e-12);
236        let margin = range * 0.1;
237        (vmin - margin, vmax + margin)
238    }
239
240    /// Check if price auto-scaling is enabled
241    pub fn is_price_auto_scale(&self) -> bool {
242        self.price_auto_scale
243    }
244
245    /// Save current zoom state to history
246    pub fn push_zoom_state(&mut self) {
247        let curr_state = ZoomState {
248            bar_spacing: self.time_scale.bar_spacing(),
249            right_offset: self.time_scale.right_offset(),
250            price_range: self.price_range,
251        };
252        self.zoom_history.push(curr_state);
253    }
254
255    /// Restore previous zoom state (zoom out button)
256    pub fn pop_zoom_state(&mut self) -> bool {
257        if let Some(state) = self.zoom_history.pop() {
258            self.time_scale.set_bar_spacing(state.bar_spacing);
259            self.time_scale.set_right_offset(state.right_offset);
260            self.price_range = state.price_range;
261            self.price_auto_scale = state.price_range.is_none();
262            true
263        } else {
264            false
265        }
266    }
267
268    /// Clear zoom history
269    pub fn clear_zoom_history(&mut self) {
270        self.zoom_history.clear();
271    }
272
273    /// Check if there are zoom states to restore
274    pub fn has_zoom_history(&self) -> bool {
275        !self.zoom_history.is_empty()
276    }
277}