1use super::Chart;
4use crate::model::LogicalRange;
5use chrono::{DateTime, Utc};
6use egui::{Ui, Vec2};
7
8#[derive(Debug, Clone, Copy)]
10pub(super) struct ScrollAnimation {
11 to: f32,
12 duration: f64,
13 progress: Option<(f64, f64, f64)>,
15}
16
17impl Chart {
18 pub fn fit_content(&mut self) {
23 self.cancel_view_motion();
24 self.state.time_scale_mut().fit_content();
25 }
26
27 pub fn reset_time_scale(&mut self) {
29 self.cancel_view_motion();
30 let scale = self.state.time_scale_mut();
31 scale.set_bar_spacing(self.chart_options.time_scale.bar_spacing);
32 scale.scroll_to_realtime();
33 }
34
35 pub fn reset_chart_view(&mut self) {
39 self.apply_visible_bars_once = false;
40 self.desired_visible_bars = None;
41 self.tracking_mode_active = false;
42 self.reset_time_scale();
43 self.state.price_scale_mut().reset();
44 for scale in self.series_scales.values_mut() {
45 scale.reset();
46 }
47 for pane in &mut self.pane_layout.panes {
48 pane.scale.reset();
49 }
50 }
51
52 pub fn scroll_to_real_time(&mut self, animated: bool) {
57 self.cancel_view_motion();
58 let target = self.state.time_scale().live_edge_offset();
59 self.scroll_to(target, 0.4, animated);
60 }
61
62 pub fn scroll_to_position(&mut self, position: f32, animated: bool) {
68 if position.is_finite() {
69 self.cancel_view_motion();
70 self.tracking_mode_active = false;
71 self.scroll_to(position, 1.0, animated);
72 }
73 }
74
75 pub fn visible_logical_range(&self) -> Option<LogicalRange> {
82 (!self.state.shown().is_empty()).then(|| self.state.time_scale().visible_logical_range())
83 }
84
85 pub fn set_visible_logical_range(&mut self, range: LogicalRange) -> bool {
91 let bars = range.right - range.left + 1.0;
92 if self.state.shown().is_empty() || !bars.is_finite() || range.right < range.left {
93 return false;
94 }
95 self.cancel_view_motion();
96 self.tracking_mode_active = false;
97 let scale = self.state.time_scale_mut();
98 scale.set_bar_spacing(scale.width() / bars);
99 scale.set_right_offset(range.right - scale.base_idx() as f32);
100 true
101 }
102
103 pub(super) fn calendar_timezone(&self) -> Option<crate::model::TimezoneId> {
105 let timezone = self.display_timezone();
106 match timezone {
107 crate::scales::TimezoneMode::Local => None,
108 _ => Some(timezone.to_tz().unwrap_or_default()),
109 }
110 }
111
112 pub fn index_to_time(&self, index: f64) -> Option<DateTime<Utc>> {
122 crate::model::time_at_with_timeframe(
123 self.state.shown(),
124 index,
125 self.timeframe(),
126 self.calendar_timezone().as_ref(),
127 )
128 }
129
130 pub fn time_to_index(&self, time: DateTime<Utc>) -> Option<f64> {
135 crate::model::index_at_with_timeframe(
136 self.state.shown(),
137 time,
138 self.timeframe(),
139 self.calendar_timezone().as_ref(),
140 )
141 }
142
143 pub fn set_horizontal_scale(
151 &mut self,
152 scale: Option<std::sync::Arc<dyn crate::scales::HorizontalScale>>,
153 ) {
154 self.horizontal_scale = scale;
155 }
156
157 pub fn index_to_x(&self, index: f64) -> Option<f32> {
161 let plot = self.last_rendered_price_rect;
162 (index.is_finite() && plot.width() > 0.0).then(|| {
163 self.state
164 .time_scale()
165 .idx_to_coord_precise(index, plot.left(), plot.width())
166 })
167 }
168
169 pub fn x_to_index(&self, x: f32) -> Option<f64> {
174 let plot = self.last_rendered_price_rect;
175 (x.is_finite() && plot.width() > 0.0).then(|| {
176 self.state
177 .time_scale()
178 .coord_to_idx_precise(x, plot.left(), plot.width())
179 })
180 }
181
182 pub fn time_scale_size(&self) -> Vec2 {
186 Vec2::new(
187 self.last_rendered_price_rect.width().max(0.0),
188 self.time_axis_rect.height().max(0.0),
189 )
190 }
191
192 pub(super) fn time_axis_height(&self) -> f32 {
195 if self.config.show_time_labels {
196 crate::styles::sizing::chart::BOTTOM_PADDING_WITH_TIME
197 .max(self.chart_options.time_scale.minimum_height)
198 } else {
199 0.0
200 }
201 }
202
203 pub(super) fn zoom_anchor(&self, x: f32, width: f32) -> f32 {
206 if self.chart_options.time_scale.right_bar_stays_on_scroll {
207 width - 1.0
208 } else {
209 x
210 }
211 }
212
213 pub(super) fn advance_scroll_animation(&mut self, ui: &Ui) {
216 let Some(mut animation) = self.scroll_animation.take() else {
217 return;
218 };
219 let now = ui.input(|input| input.time);
220 let scale = self.state.time_scale_mut();
221 let (start, from, last) = *animation.progress.get_or_insert((
222 now,
223 scale.right_offset_precise(),
224 scale.right_offset_precise(),
225 ));
226 if scale.right_offset_precise() != last {
227 return;
228 }
229 let elapsed = ((now - start) / animation.duration) as f32;
230 if elapsed >= 1.0 {
231 scale.set_right_offset(animation.to);
232 return;
233 }
234 scale.set_right_offset(from + (f64::from(animation.to) - from) * f64::from(elapsed));
235 animation.progress = Some((start, from, scale.right_offset_precise()));
236 self.scroll_animation = Some(animation);
237 ui.ctx().request_repaint();
238 }
239
240 fn scroll_to(&mut self, offset: f32, duration: f64, animated: bool) {
241 if animated {
242 self.scroll_animation = Some(ScrollAnimation {
243 to: offset,
244 duration,
245 progress: None,
246 });
247 } else {
248 self.state.time_scale_mut().set_right_offset(offset);
249 }
250 }
251
252 pub(super) fn cancel_view_motion(&mut self) {
254 self.pending_scroll_time = None;
255 self.pending_start_idx = None;
256 self.scroll_animation = None;
257 self.kinetic_scroll = super::state::KineticScrollState::new();
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use crate::model::{Bar, BarData, LogicalRange};
264 use crate::scales::{PriceHorizontalScale, key_time};
265 use crate::widget::test_frames::{chart, frame, shapes};
266 use crate::widget::{Chart, ChartEvent, SeriesId};
267 use chrono::Duration;
268
269 fn assert_close(actual: f64, expected: f64, what: &str) {
270 assert!(
271 (actual - expected).abs() < 1e-3,
272 "{what}: {actual} != {expected}"
273 );
274 }
275
276 fn assert_range(chart: &Chart, left: f32, right: f32, what: &str) {
277 let range = chart.visible_logical_range().unwrap();
278 assert_close(range.left.into(), left.into(), what);
279 assert_close(range.right.into(), right.into(), what);
280 }
281
282 #[test]
283 fn zero_margin_keeps_the_latest_bar_and_its_live_updates_visible() {
284 let ctx = egui::Context::default();
285 let mut chart = chart(&[95.0], &[105.0]);
286 let first = chart.bars(SeriesId::MAIN)[0].time;
287 chart.set_data(
288 SeriesId::MAIN,
289 (0..1000)
290 .map(|index| {
291 Bar::new(
292 first + Duration::minutes(index),
293 95.0,
294 105.0,
295 95.0,
296 105.0,
297 100.0,
298 )
299 })
300 .collect(),
301 );
302 let mut options = chart.chart_options().clone();
303 options.time_scale.right_offset = 0.0;
304 options.time_scale.fix_right_edge = true;
305 chart.set_chart_options(options);
306 for time in [0.0, 0.1, 0.2] {
307 frame(&ctx, &mut chart, time, vec![]);
308 }
309 let last = chart.bars(SeriesId::MAIN).len() - 1;
310 let plot = chart.get_rendered_price_rect();
311 assert!((plot.left()..plot.right()).contains(&chart.index_to_x(last as f64).unwrap()));
312 assert_eq!(chart.state().visible_range().1, last + 1);
313 let mut update = chart.bars(SeriesId::MAIN)[last].clone();
314 update.close = 110.0;
315 update.high = 110.0;
316 chart.update(SeriesId::MAIN, update);
317 frame(&ctx, &mut chart, 0.3, vec![]);
318 assert_eq!(chart.state().visible_data().last().unwrap().close, 110.0);
319 assert!(
320 chart
321 .series_price_map(SeriesId::MAIN)
322 .unwrap()
323 .range()
324 .contains(110.0)
325 );
326 }
327
328 #[test]
329 fn host_coordinates_retain_fractions_on_large_histories() {
330 let bars = (0..1_000_000)
331 .map(|i| {
332 Bar::from_value(
333 chrono::DateTime::from_timestamp(1_700_000_000 + i * 60, 0).unwrap(),
334 10.0,
335 )
336 })
337 .collect();
338 let mut chart = Chart::new(BarData::from_bars(bars));
339 let ctx = egui::Context::default();
340 frame(&ctx, &mut chart, 0.0, vec![]);
341 chart.state.time_scale_mut().set_bar_spacing(200.0);
342 chart.state.time_scale_mut().set_right_offset(0.0);
343 let x = chart.index_to_x(999_999.0).unwrap();
344 let fractional = chart.index_to_x(999_999.005).unwrap();
345 assert!((fractional - x - 1.0).abs() < 1e-4);
346 assert!((chart.x_to_index(fractional).unwrap() - 999_999.005).abs() < 1e-6);
347 let scale = chart.state.time_scale();
348 let distance = (f64::from(scale.width()) - 1.0) / 200.0 - 0.5;
349 let expected = chart.bars(SeriesId::MAIN).last().unwrap().time
350 - Duration::nanoseconds((distance * 60e9).round() as i64);
351 assert!(
352 (chart.visible_time_range().unwrap().from - expected)
353 .num_nanoseconds()
354 .unwrap()
355 .abs()
356 < 1_000
357 );
358 let plot = chart.get_rendered_price_rect();
359 for (step, (index, nearest)) in [(999_997.49, 999_997), (999_997.51, 999_998)]
360 .into_iter()
361 .enumerate()
362 {
363 let pos = egui::pos2(chart.index_to_x(index).unwrap(), plot.center().y);
364 frame(
365 &ctx,
366 &mut chart,
367 0.1 + step as f64 * 0.1,
368 vec![egui::Event::PointerMoved(pos)],
369 );
370 assert!(
371 chart.drain_events().any(|event| matches!(
372 event,
373 ChartEvent::CrosshairMoved(Some(pointer)) if pointer.index == nearest
374 )),
375 "index {index} must select bar {nearest}"
376 );
377 }
378 let time = chart.bars(SeriesId::MAIN)[999_997].time + Duration::milliseconds(300);
379 let added = chart.add_series(crate::widget::SeriesOptions {
380 chart_type: crate::model::ChartType::Candles,
381 up_color: egui::Color32::BLUE,
382 ..Default::default()
383 });
384 chart.set_data(added, vec![Bar::new(time, 9.0, 11.0, 9.0, 11.0, 100.0)]);
385 let painted = shapes(&ctx, &mut chart, 0.3, vec![]);
386 let x = chart.index_to_x(999_997.005).unwrap();
387 assert!(
388 painted.iter().any(|shape| matches!(shape,
389 egui::Shape::Rect(body)
390 if body.fill == egui::Color32::BLUE && (body.rect.center().x - x).abs() < 1e-3
391 )),
392 "an added series must retain its fractional time position"
393 );
394 let plot = chart.get_rendered_price_rect();
395 let center = 999_997.137;
396 let x = chart.index_to_x(center).unwrap();
397 assert!(chart.execute_box_zoom(
398 egui::pos2(x - 125.0, plot.top() + 100.0),
399 egui::pos2(x + 125.0, plot.top() + 200.0),
400 plot,
401 plot.width(),
402 ));
403 assert!((chart.index_to_x(center).unwrap() - plot.center().x).abs() < 1e-3);
404 chart.state.time_scale_mut().set_bar_spacing(200.0);
405 chart.state.time_scale_mut().set_right_offset(-500_000.005);
406 let x = chart.index_to_x(499_999.0).unwrap();
407 assert_eq!(chart.get_chart_mapping().idx_to_x(499_999), x);
408 chart.state.time_scale_mut().scroll_pixels(1.0);
409 assert!((chart.index_to_x(499_999.0).unwrap() - x - 1.0).abs() < 1e-4);
410 }
411
412 #[test]
417 fn time_scale_methods_set_read_and_convert_the_view() {
418 let ctx = egui::Context::default();
419 let mut chart = chart(&[95.0], &[105.0]);
420 for t in [0.0, 0.1] {
421 frame(&ctx, &mut chart, t, vec![]);
422 }
423 let plot = chart.get_rendered_price_rect();
424 let width = plot.width();
425 let events: Vec<_> = chart.drain_events().collect();
426 assert_eq!(chart.time_scale_size(), egui::vec2(width, 35.0));
427 assert!(events.contains(&ChartEvent::TimeScaleResized(chart.time_scale_size())));
428 assert!(events.contains(&ChartEvent::VisibleLogicalRangeChanged(
429 chart.visible_logical_range()
430 )));
431
432 assert!(chart.set_visible_logical_range(LogicalRange::new(10.0, 49.0)));
434 let spacing = chart.get_time_scale_state().0;
435 assert_close(spacing.into(), (width / 40.0).into(), "spacing");
436 assert_close(chart.get_time_scale_state().1.into(), -50.0, "offset");
437 assert_range(&chart, 10.0, 49.0, "set range");
438 let x = chart.index_to_x(10.0).unwrap();
440 assert_close(x.into(), (plot.left() + spacing / 2.0 - 1.0).into(), "x");
441 assert_close(chart.x_to_index(x).unwrap(), 10.0, "index");
442 frame(&ctx, &mut chart, 0.2, vec![]);
443 let range = chart.visible_logical_range();
444 assert!(
445 chart
446 .drain_events()
447 .any(|event| event == ChartEvent::VisibleLogicalRangeChanged(range))
448 );
449 for invalid in [
450 LogicalRange::new(5.0, 4.0),
451 LogicalRange::new(f32::NAN, 4.0),
452 ] {
453 assert!(!chart.set_visible_logical_range(invalid));
454 }
455 assert_range(&chart, 10.0, 49.0, "invalid ranges");
456
457 let first = chart.bars(SeriesId::MAIN)[0].time;
459 let noon = first + Duration::hours(10 * 24 + 12);
460 assert_eq!(chart.index_to_time(10.5), Some(noon));
461 assert_close(chart.time_to_index(noon).unwrap(), 10.5, "time");
462 assert_eq!(
463 chart.index_to_time(105.0),
464 Some(first + Duration::days(105))
465 );
466
467 chart.fit_content();
469 assert_close(
470 chart.get_time_scale_state().0.into(),
471 (width / 102.5).into(),
472 "fit",
473 );
474 assert_range(&chart, 0.0, 101.5, "fit");
475 chart.reset_time_scale();
476 assert_eq!(chart.get_time_scale_state(), (6.0, 2.5));
477
478 chart.scroll_to_position(-20.0, false);
479 assert_eq!(chart.get_time_scale_state().1, -20.0);
480 chart.scroll_to_position(10.0, true);
483 for (t, offset) in [(1.0, -20.0), (1.5, -5.0), (2.0, 10.0), (2.1, 10.0)] {
484 frame(&ctx, &mut chart, t, vec![]);
485 assert_close(chart.get_time_scale_state().1.into(), offset, "position");
486 }
487 chart.scroll_to_real_time(true);
488 for (t, offset) in [(3.0, 10.0), (3.2, 6.25), (3.4, 2.5)] {
489 frame(&ctx, &mut chart, t, vec![]);
490 assert_close(chart.get_time_scale_state().1.into(), offset, "real time");
491 }
492 chart.scroll_to_position(-20.0, true);
494 frame(&ctx, &mut chart, 4.0, vec![]);
495 chart.apply_synced_time_scale(6.0, 0.0);
496 frame(&ctx, &mut chart, 4.5, vec![]);
497 assert_eq!(chart.get_time_scale_state().1, 0.0);
498
499 let mut options = chart.chart_options().clone();
500 options.time_scale.minimum_height = 40.0;
501 options.time_scale.right_offset_pixels = Some(60.0);
502 let mut chart = chart.with_chart_options(options);
503 frame(&ctx, &mut chart, 5.0, vec![]);
504 assert_eq!(chart.time_scale_size(), egui::vec2(width, 40.0));
505 assert!(
506 chart
507 .drain_events()
508 .any(|event| event == ChartEvent::TimeScaleResized(egui::vec2(width, 40.0)))
509 );
510 chart.fit_content();
512 assert_close(
513 chart.get_time_scale_state().0.into(),
514 ((width - 60.0) / 100.0).into(),
515 "fit with pixels",
516 );
517 assert_range(
518 &chart,
519 0.0,
520 99.0 + 60.0 / chart.get_time_scale_state().0,
521 "fit with pixels",
522 );
523 }
524
525 #[test]
529 fn a_horizontal_scale_labels_the_axis_and_the_crosshair_by_key() {
530 let bars = (0..=8)
531 .map(|i| Bar::from_value(key_time(4400.0 + 25.0 * f64::from(i)).unwrap(), 20.0))
532 .collect();
533 let mut chart = Chart::new(BarData::from_bars(bars));
534 chart.set_horizontal_scale(Some(std::sync::Arc::new(PriceHorizontalScale {
535 precision: 0,
536 })));
537 let ctx = egui::Context::default();
538 frame(&ctx, &mut chart, 0.0, vec![]);
539 assert!(chart.set_visible_logical_range(LogicalRange::new(0.0, 8.0)));
540 frame(&ctx, &mut chart, 0.1, vec![]);
541 let plot = chart.get_rendered_price_rect();
542 let hover = egui::pos2(chart.index_to_x(3.0).unwrap(), plot.center().y);
543 let texts = |painted: Vec<egui::Shape>| -> Vec<String> {
544 painted
545 .into_iter()
546 .filter_map(|shape| match shape {
547 egui::Shape::Text(text) if text.pos.y >= plot.bottom() => {
548 Some(text.galley.text().to_owned())
549 }
550 _ => None,
551 })
552 .collect()
553 };
554 let axis = texts(shapes(&ctx, &mut chart, 0.2, vec![]));
555 assert_eq!(axis, ["4400", "4450", "4500", "4550", "4600"]);
556 let hovered = texts(shapes(
557 &ctx,
558 &mut chart,
559 0.3,
560 vec![egui::Event::PointerMoved(hover)],
561 ));
562 assert!(hovered.contains(&"4475".to_owned()), "{hovered:?}");
563 #[cfg(feature = "csv")]
564 {
565 let csv = chart.export_csv().unwrap();
566 assert_eq!(
567 csv.lines()
568 .nth(1)
569 .and_then(|row| row.split_once(','))
570 .map(|(key, _)| key),
571 Some("4400")
572 );
573 }
574 }
575}