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}