Skip to main content

egui_charts/widget/
builder.rs

1//! Chart construction, configuration, and data management methods.
2//!
3//! This module contains the builder-pattern constructors and the mutable
4//! configuration API for [`Chart`]. Use these methods to create a chart,
5//! set its initial state, and update it across frames.
6//!
7//! # Builder Pattern
8//!
9//! ```rust,ignore
10//! use egui_charts::widget::Chart;
11//! use egui_charts::model::BarData;
12//!
13//! let chart = Chart::new(data)
14//!     .visible_bars(120)
15//!     .start_idx(0)
16//!     .with_chart_options(options);
17//! ```
18//!
19//! # Persistent Chart (Recommended for Real-Time)
20//!
21//! ```rust,ignore
22//! // Store in your app struct, update each frame:
23//! self.chart.update_data(new_data);
24//! self.chart.set_chart_type(ChartType::Candles);
25//! self.chart.enable_tracking_mode();
26//! ```
27
28use crate::chart::cursor_modes::CursorModeState;
29use crate::config::{ChartConfig, ChartOptions};
30use crate::model::ChartType;
31use crate::model::{BarData, ChartState};
32use crate::validation::DataValidator;
33
34use super::Chart;
35use super::state::{BoxZoomState, KineticScrollState};
36
37impl Chart {
38    /// Creates a new chart with default configuration.
39    ///
40    /// Initializes the chart with the provided OHLCV data and sensible defaults:
41    /// candlestick chart type, default bar spacing, data validation enabled,
42    /// and tracking mode off.
43    ///
44    /// # Arguments
45    ///
46    /// * `data` -- The OHLCV bar data to display
47    ///
48    /// # Example
49    ///
50    /// ```rust,ignore
51    /// let chart = Chart::new(bar_data);
52    /// ```
53    pub fn new(data: BarData) -> Self {
54        let chart_options = ChartOptions::default();
55        let mut state = ChartState::new(data);
56
57        // Initialize TimeScale with defaults (apply all constraints)
58        {
59            let ts_opt = &chart_options.time_scale;
60            state.time_scale_mut().apply_options(
61                ts_opt.bar_spacing,
62                ts_opt.min_bar_spacing,
63                ts_opt.max_bar_spacing,
64                ts_opt.fix_left_edge,
65                ts_opt.fix_right_edge,
66                ts_opt.right_offset,
67                ts_opt.right_offset_pixels,
68            );
69        }
70
71        Self {
72            state,
73            config: ChartConfig::default(),
74            start_idx: 0,
75            chart_options,
76            kinetic_scroll: KineticScrollState::new(),
77            scroll_start_pos: None,
78            scroll_start_offset: None,
79            prev_width: None,
80            desired_visible_bars: None,
81            last_visible_bars: 0,
82            apply_visible_bars_once: false,
83            price_scale_drag_start: None,
84            pending_start_idx: None,
85            chart_type: ChartType::Candles,
86            renko_brick_size: 1.0,
87            kagi_reversal_amount: 1.0,
88            tracking_mode_active: false,
89            mouse_in_chart: false,
90            validator: Some(DataValidator::new()),
91            box_zoom: BoxZoomState::new(),
92            zoom_mode_active: false,
93            zoom_just_applied: false,
94            symbol: String::new(),
95            timeframe: String::new(),
96            cursor_modes: CursorModeState::new(),
97            last_rendered_price_range: (0.0, 0.0),
98            last_rendered_price_rect: egui::Rect::NOTHING,
99            last_rendered_volume_rect: egui::Rect::NOTHING,
100            last_rendered_indicator_panes: Vec::new(),
101            // Multi-chart sync
102            synced_crosshair_bar_idx: None,
103            last_hover_bar_idx: None,
104            // Marks (Widget API)
105            marks: Vec::new(),
106            timescale_marks: Vec::new(),
107        }
108    }
109
110    /// Creates a new chart with a custom visual configuration.
111    ///
112    /// Use this when you need to control colors, padding, grid visibility,
113    /// volume display, and other visual aspects from the start.
114    ///
115    /// # Arguments
116    ///
117    /// * `data` -- The OHLCV bar data to display
118    /// * `config` -- Custom visual configuration (colors, padding, feature flags)
119    pub fn with_config(data: BarData, config: ChartConfig) -> Self {
120        let chart_options = ChartOptions::default();
121        let mut state = ChartState::new(data);
122
123        // Initialize TimeScale with defaults (apply all constraints)
124        {
125            let ts_opt = &chart_options.time_scale;
126            state.time_scale_mut().apply_options(
127                ts_opt.bar_spacing,
128                ts_opt.min_bar_spacing,
129                ts_opt.max_bar_spacing,
130                ts_opt.fix_left_edge,
131                ts_opt.fix_right_edge,
132                ts_opt.right_offset,
133                ts_opt.right_offset_pixels,
134            );
135        }
136
137        Self {
138            state,
139            config,
140            start_idx: 0,
141            chart_options,
142            kinetic_scroll: KineticScrollState::new(),
143            scroll_start_pos: None,
144            scroll_start_offset: None,
145            prev_width: None,
146            desired_visible_bars: None,
147            last_visible_bars: 0,
148            apply_visible_bars_once: false,
149            price_scale_drag_start: None,
150            pending_start_idx: None,
151            chart_type: ChartType::Candles,
152            renko_brick_size: 1.0,
153            kagi_reversal_amount: 1.0,
154            tracking_mode_active: false,
155            mouse_in_chart: false,
156            validator: Some(DataValidator::new()),
157            box_zoom: BoxZoomState::new(),
158            zoom_mode_active: false,
159            zoom_just_applied: false,
160            symbol: String::new(),
161            timeframe: String::new(),
162            cursor_modes: CursorModeState::new(),
163            last_rendered_price_range: (0.0, 0.0),
164            last_rendered_price_rect: egui::Rect::NOTHING,
165            last_rendered_volume_rect: egui::Rect::NOTHING,
166            last_rendered_indicator_panes: Vec::new(),
167            // Multi-chart sync
168            synced_crosshair_bar_idx: None,
169            last_hover_bar_idx: None,
170            // Marks (Widget API)
171            marks: Vec::new(),
172            timescale_marks: Vec::new(),
173        }
174    }
175
176    /// Sets chart behavior options (builder pattern).
177    ///
178    /// Controls bar spacing, scroll/zoom constraints, right offset, and
179    /// time scale behavior. Applies the time scale options immediately.
180    pub fn with_chart_options(mut self, options: ChartOptions) -> Self {
181        {
182            let ts_opt = &options.time_scale;
183            self.state.time_scale_mut().apply_options(
184                ts_opt.bar_spacing,
185                ts_opt.min_bar_spacing,
186                ts_opt.max_bar_spacing,
187                ts_opt.fix_left_edge,
188                ts_opt.fix_right_edge,
189                ts_opt.right_offset,
190                ts_opt.right_offset_pixels,
191            );
192        }
193        self.chart_options = options;
194        self
195    }
196
197    /// Sets the desired number of visible bars (builder pattern).
198    ///
199    /// The actual bar spacing is computed during rendering to fit `count` bars
200    /// in the available width. Also sets the start index to show the latest
201    /// `count` bars by default.
202    pub fn visible_bars(mut self, count: usize) -> Self {
203        // Store the desired number so that we can compute the proper
204        // bar spacing during `show_internal` when we know the widget width.
205        self.desired_visible_bars = Some(count);
206        // Default to showing the latest `count` bars unless caller overrides later
207        self.start_idx = self.state.data().len().saturating_sub(count);
208        self
209    }
210
211    /// Sets the starting bar index for the visible range (builder pattern).
212    ///
213    /// Index `0` is the oldest bar in the data. Use this to start the chart
214    /// at a specific point in history rather than at the latest data.
215    pub fn start_idx(mut self, index: usize) -> Self {
216        self.start_idx = index;
217        self
218    }
219
220    /// Returns the number of bars currently visible in the chart area.
221    ///
222    /// This value is computed from the bar spacing and widget width during
223    /// rendering. Returns 100 as a fallback if the chart has not been rendered yet.
224    pub fn get_visible_bars(&self) -> usize {
225        // Return the last computed value (updated during `show_internal`).
226        // Falls back to width/bar_spacing semantics if `show_internal` has
227        // not been called yet.
228        if self.last_visible_bars == 0 {
229            100
230        } else {
231            self.last_visible_bars
232        }
233    }
234
235    /// Returns the current starting bar index of the visible range.
236    ///
237    /// Useful for syncing chart position with external app state or persisting
238    /// the user's scroll position across sessions.
239    pub fn get_start_idx(&self) -> usize {
240        self.start_idx
241    }
242
243    /// Sets a custom visual configuration (builder pattern).
244    ///
245    /// Controls colors, padding, grid visibility, volume display, and other
246    /// visual aspects. See [`ChartConfig`] for the full list of options.
247    pub fn config(mut self, config: ChartConfig) -> Self {
248        self.config = config;
249        self
250    }
251
252    /// Updates the chart's OHLCV data for live/streaming scenarios.
253    ///
254    /// Call this each time new bars arrive or the latest bar's close price changes.
255    /// If the chart is near the live edge and `shift_visible_range_on_new_bar` is
256    /// enabled in chart options, the viewport automatically scrolls to keep the
257    /// latest bar visible.
258    ///
259    /// Returns `true` if the chart auto-scrolled to follow the live edge, `false`
260    /// if the viewport position was unchanged (user is scrolled back in history).
261    ///
262    /// # Data Validation
263    ///
264    /// When validation is enabled (default), new bars are checked for anomalies
265    /// (duplicate timestamps, suspicious price changes). Validation warnings are
266    /// logged but do not reject data.
267    pub fn update_data(&mut self, data: BarData) -> bool {
268        // Validate new data if validator is enabled. Only validate when the tail
269        // actually advances; when we prepend older bars the last bar is unchanged
270        // and validating it against itself produces a false duplicate warning.
271        if let Some(ref validator) = self.validator
272            && let (Some(new_tail), Some(old_tail)) =
273                (data.bars.last(), self.state.data().bars.last())
274            && new_tail.time > old_tail.time
275        {
276            let result = validator.validate_new_bar(Some(old_tail), new_tail);
277            if result.is_error() {
278                // keep logging behaviour but do not reject
279            }
280        }
281
282        // Loosely detect "near live edge": within ~1.5 bars from the right edge.
283        // With fix_right_edge=true we clamp right_offset <= 0, so "near live" means
284        // not farther than 1.5 bars to the left of the edge: right_offset >= -1.5.
285        let near_live = self.state.time_scale().right_offset() >= -1.5;
286
287        // Detect if new bars were appended or tail candle changed
288        let prev_len = self.state.data().len();
289        let old_tail = self.state.data().bars.last().cloned();
290        let new_tail = data.bars.last().cloned();
291
292        // CRITICAL FIX: Only consider it "appended" if TAIL moved forward in time.
293        // When prepending older historical bars, the data length increases but
294        // the tail stays the same (or moves forward), so we check if the tail
295        // timestamp actually advanced.
296        let tail_time_advanced = old_tail
297            .as_ref()
298            .zip(new_tail.as_ref())
299            .map(|(o, n)| n.time > o.time)
300            .unwrap_or(false);
301        let appended = data.len() > prev_len && tail_time_advanced;
302
303        let tail_changed = old_tail
304            .zip(new_tail)
305            .map(|(o, n)| o.time != n.time || o.close != n.close)
306            .unwrap_or(false);
307
308        // Check if we should shift for whitespace replacement
309        let is_whitespace_replacement = tail_changed && !appended;
310        let should_shift_for_whitespace = is_whitespace_replacement
311            && self
312                .chart_options
313                .time_scale
314                .allow_shift_visible_range_on_whitespace_replacement;
315
316        // Update the data (this changes bar_cnt which shifts coords)
317        self.state.set_data(data);
318
319        // IMPORTANT: When older bars are prepended (length increased but tail unchanged),
320        // NO adjustment to right_offset is needed! Here's why:
321        // - base_idx increases by N (e.g., 199 -> 589 when adding 390 bars)
322        // - Bar indices also increase by N (e.g., old bar 49 -> new bar 439)
323        // - right_offset = right_border - base_idx
324        // - Since both increase by N, right_offset stays the same
325        // - The viewport automatically shows the same bars the user was viewing
326
327        // FORCE chart to stay at live edge if:
328        // 1. shift_visible_range_on_new_bar is enabled AND
329        // 2. We were near the live edge AND
330        // 3. Either new bars were appended OR we should shift for whitespace replacement
331        let should_auto_shift = self.chart_options.time_scale.shift_visible_range_on_new_bar
332            && near_live
333            && (appended || should_shift_for_whitespace);
334
335        if should_auto_shift {
336            self.state.time_scale_mut().scroll_to_realtime();
337            // Cancel any pending start_idx to prevent override
338            self.pending_start_idx = None;
339            true // Return true to indicate we auto-followed
340        } else {
341            false
342        }
343    }
344
345    /// Replaces the visual configuration on a persistent chart instance.
346    ///
347    /// Use this when reconfiguring a long-lived chart (e.g., after the user
348    /// changes settings). For initial construction prefer [`Chart::config`].
349    pub fn update_config(&mut self, config: ChartConfig) {
350        self.config = config;
351    }
352
353    /// Updates the desired number of visible bars on a persistent chart instance.
354    ///
355    /// Only triggers a recalculation if `count` differs from the current value.
356    /// The new bar spacing is applied on the next frame.
357    pub fn set_visible_bars(&mut self, count: usize) {
358        // Only reapply if requested value differs from last computed
359        if self.last_visible_bars == 0 || count != self.last_visible_bars {
360            self.desired_visible_bars = Some(count);
361            self.apply_visible_bars_once = true;
362        }
363    }
364
365    /// Updates the starting bar index on a persistent chart instance.
366    ///
367    /// Only triggers an update if `index` differs from the current value.
368    /// The viewport shift is applied on the next frame.
369    pub fn set_start_idx(&mut self, index: usize) {
370        if index != self.start_idx {
371            self.start_idx = index;
372            self.pending_start_idx = Some(index);
373        }
374    }
375
376    /// Sets the chart type (Candles, Bars, Line, Area, Renko, Kagi).
377    ///
378    /// Takes effect on the next rendered frame. For Renko/Kagi charts, also
379    /// set the brick/reversal size with [`Chart::set_renko_brick_size`] or
380    /// [`Chart::set_kagi_reversal_amount`].
381    pub fn set_chart_type(&mut self, chart_type: ChartType) {
382        self.chart_type = chart_type;
383    }
384
385    /// Enables tracking mode, which automatically scrolls to keep the latest bar visible.
386    ///
387    /// When enabled, the chart immediately scrolls to the live edge and stays
388    /// there as new data arrives. Disable with [`Chart::disable_tracking_mode`].
389    pub fn enable_tracking_mode(&mut self) {
390        self.tracking_mode_active = true;
391        // Immediately scroll to latest when enabling
392        self.state.time_scale_mut().scroll_to_realtime();
393    }
394
395    /// Disables tracking mode, allowing the user to scroll freely through history.
396    pub fn disable_tracking_mode(&mut self) {
397        self.tracking_mode_active = false;
398    }
399
400    /// Toggles tracking mode on or off.
401    pub fn toggle_tracking_mode(&mut self) {
402        if self.tracking_mode_active {
403            self.disable_tracking_mode();
404        } else {
405            self.enable_tracking_mode();
406        }
407    }
408
409    /// Returns whether tracking mode is currently active
410    pub fn is_tracking_mode_active(&self) -> bool {
411        self.tracking_mode_active
412    }
413
414    /// Enables data validation for incoming bar data.
415    ///
416    /// When enabled, calls to [`Chart::update_data`] check new bars for
417    /// anomalies such as duplicate timestamps or suspicious price spikes.
418    /// Validation is enabled by default on new charts.
419    pub fn enable_validation(&mut self) {
420        if self.validator.is_none() {
421            self.validator = Some(DataValidator::new());
422        }
423    }
424
425    /// Disables data validation, skipping anomaly checks on new bars.
426    pub fn disable_validation(&mut self) {
427        self.validator = None;
428    }
429
430    /// Replaces the data validator with a custom-configured one.
431    ///
432    /// Use this to adjust thresholds for duplicate detection or price-spike
433    /// sensitivity beyond what the default [`DataValidator`] provides.
434    pub fn set_validator(&mut self, validator: DataValidator) {
435        self.validator = Some(validator);
436    }
437
438    /// Returns `true` if data validation is currently enabled.
439    pub fn is_validation_enabled(&self) -> bool {
440        self.validator.is_some()
441    }
442
443    /// Returns the current chart type (Candles, Bars, Line, Area, Renko, or Kagi).
444    pub fn chart_type(&self) -> ChartType {
445        self.chart_type
446    }
447
448    /// Sets the Renko brick size in price units.
449    ///
450    /// Each Renko brick represents a fixed price movement of this size.
451    /// Only affects rendering when the chart type is [`ChartType::Renko`].
452    pub fn set_renko_brick_size(&mut self, brick_size: f64) {
453        self.renko_brick_size = brick_size;
454    }
455
456    /// Returns the current Renko brick size in price units.
457    pub fn renko_brick_size(&self) -> f64 {
458        self.renko_brick_size
459    }
460
461    /// Sets the Kagi reversal amount in price units.
462    ///
463    /// A new Kagi line segment is drawn when price reverses by at least this
464    /// amount. Only affects rendering when the chart type is [`ChartType::Kagi`].
465    pub fn set_kagi_reversal_amount(&mut self, reversal_amount: f64) {
466        self.kagi_reversal_amount = reversal_amount;
467    }
468
469    /// Returns the current Kagi reversal amount in price units.
470    pub fn kagi_reversal_amount(&self) -> f64 {
471        self.kagi_reversal_amount
472    }
473
474    /// Returns a reference to the chart's current OHLCV data.
475    ///
476    /// Useful for progressive/historical loading where you need to inspect
477    /// the existing data range before prepending or appending bars.
478    pub fn data(&self) -> &crate::model::BarData {
479        self.state.data()
480    }
481
482    /// Calculates how many bars fit in the given pixel width at the current bar spacing.
483    pub fn calculate_visible_bars(&self, width: f32) -> usize {
484        (width / self.state.time_scale().bar_spacing()).floor() as usize
485    }
486
487    /// Calculates the bar spacing (pixels per bar) needed to fit the desired
488    /// number of bars in the given pixel width.
489    pub fn calculate_bar_spacing(&self, width: f32, visible_bars: usize) -> f32 {
490        if visible_bars == 0 {
491            self.chart_options.time_scale.bar_spacing
492        } else {
493            width / visible_bars as f32
494        }
495    }
496
497    /// Get the price range used for actual rendering (includes zoom adjustments)
498    ///
499    /// This is useful for external code that needs to use the same coordinate system
500    /// as the rendered chart (e.g., selection dots, hit testing).
501    pub fn get_rendered_price_range(&self) -> (f64, f64) {
502        self.last_rendered_price_range
503    }
504
505    /// Get the price rect used for actual rendering
506    ///
507    /// This is the actual screen rect where candles are drawn, useful for
508    /// external code that needs to draw overlays (e.g., selection dots).
509    pub fn get_rendered_price_rect(&self) -> egui::Rect {
510        self.last_rendered_price_rect
511    }
512
513    /// Get the volume rect used for actual rendering
514    ///
515    /// This is the actual screen rect where volume bars are drawn, useful for
516    /// external code that needs to draw overlays (e.g., selection dots).
517    pub fn get_rendered_volume_rect(&self) -> egui::Rect {
518        self.last_rendered_volume_rect
519    }
520
521    /// Get the rendered indicator pane info for hit testing
522    ///
523    /// This returns information about each rendered indicator pane, useful for
524    /// external code that needs to do hit testing on indicator lines.
525    pub fn get_rendered_indicator_panes(&self) -> &[super::RenderedIndicatorPane] {
526        &self.last_rendered_indicator_panes
527    }
528}