1use super::Chart;
4use chrono::{DateTime, Utc};
5
6const SYNC_MAX_BAR_SPACING: f64 = 50.0;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct VisibleTimeRange {
12 pub from: DateTime<Utc>,
14 pub to: DateTime<Utc>,
16}
17
18impl Chart {
19 pub fn hover_time(&self) -> Option<DateTime<Utc>> {
21 self.hover_period().map(|(open, _)| open)
22 }
23
24 pub(crate) fn hover_period(&self) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
26 let index = self.last_hover_bar_idx?.round();
27 (index >= 0.0 && index < self.state.shown().len() as f64)
28 .then(|| self.bar_period(index as usize))
29 }
30
31 fn bar_period(&self, index: usize) -> (DateTime<Utc>, DateTime<Utc>) {
33 let bars = self.state.shown();
34 let open = bars[index].time;
35 let at = match self.timeframe() {
37 Some(_) => index,
38 None => index.max(1).min(bars.len() - 1),
39 };
40 let step = crate::model::time_at_with_timeframe(
42 &bars[..=at],
43 at as f64 + 1.0,
44 self.timeframe(),
45 self.calendar_timezone().as_ref(),
46 )
47 .map_or(chrono::TimeDelta::zero(), |end| end - bars[at].time);
48 let end = open.checked_add_signed(step).unwrap_or(open);
49 let end = bars.get(index + 1).map_or(end, |next| end.min(next.time));
50 (open, end.max(open))
51 }
52
53 pub fn set_synced_crosshair_time(&mut self, time: Option<DateTime<Utc>>) {
59 self.set_synced_crosshair(time.map(|time| (time, time)), None);
60 }
61
62 pub fn set_crosshair_position(&mut self, position: Option<(DateTime<Utc>, f64)>) {
67 self.set_synced_crosshair(
68 position.map(|(time, _)| (time, time)),
69 position.map(|(_, price)| price),
70 );
71 }
72
73 pub(crate) fn set_synced_crosshair(
77 &mut self,
78 period: Option<(DateTime<Utc>, DateTime<Utc>)>,
79 price: Option<f64>,
80 ) {
81 let period = period.filter(|_| price.is_none_or(f64::is_finite));
82 self.synced_crosshair_bar_idx = period.and_then(|(from, to)| self.synced_index(from, to));
83 self.synced_crosshair_period = period.filter(|_| self.synced_crosshair_bar_idx.is_some());
84 self.synced_crosshair_price = price.filter(|_| self.synced_crosshair_bar_idx.is_some());
85 }
86
87 fn synced_index(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> Option<f64> {
88 let bars = self.state.shown();
89 let after = bars.partition_point(|bar| bar.time <= from);
90 let index = if after > 0 && from < self.bar_period(after - 1).1 {
91 after - 1
92 } else if after < bars.len() && bars[after].time < to {
93 after
94 } else if after == 0 || after == bars.len() {
95 return None;
96 } else if from - bars[after - 1].time <= bars[after].time - from {
97 after - 1
98 } else {
99 after
100 };
101 Some(bars.partition_point(|bar| bar.time < bars[index].time) as f64)
102 }
103
104 pub fn visible_time_range(&self) -> Option<VisibleTimeRange> {
108 let scale = self.state.time_scale();
109 Some(VisibleTimeRange {
111 from: self.index_to_time(scale.coord_to_idx_precise(0.0, 0.0, scale.width()))?,
112 to: self.index_to_time(scale.coord_to_idx_precise(
113 scale.width() - 1.0,
114 0.0,
115 scale.width(),
116 ))?,
117 })
118 }
119
120 pub fn apply_synced_time_range(&mut self, range: VisibleTimeRange) -> bool {
128 self.apply_synced_time_range_from(range, None)
129 }
130
131 pub(crate) fn apply_synced_time_range_from(
137 &mut self,
138 range: VisibleTimeRange,
139 source: Option<(crate::model::Timeframe, f64)>,
140 ) -> bool {
141 let cap = match source {
142 Some((timeframe, slots)) if Some(timeframe) == self.timeframe() => {
143 SYNC_MAX_BAR_SPACING.max((f64::from(self.state.time_scale().width()) - 1.0) / slots)
144 }
145 _ => SYNC_MAX_BAR_SPACING,
146 };
147 self.fit_time_range(range, cap)
148 }
149
150 pub(crate) fn sync_source(&self) -> Option<(crate::model::Timeframe, f64)> {
152 let scale = self.state.time_scale();
153 let edge = |x| scale.coord_to_idx_precise(x, 0.0, scale.width());
154 Some((self.timeframe()?, edge(scale.width() - 1.0) - edge(0.0)))
155 }
156
157 pub(super) fn fit_time_range(&mut self, range: VisibleTimeRange, default_max: f64) -> bool {
159 if range.from >= range.to {
160 return false;
161 }
162 let Some((from, to)) = self
163 .time_to_index(range.from)
164 .zip(self.time_to_index(range.to))
165 else {
166 return false;
167 };
168 let scale = self.state.time_scale_mut();
169 if to <= from || scale.width() <= 1.0 || to < -0.5 || from >= scale.base_idx() as f64 + 1.0
171 {
172 return false;
173 }
174 scale.fit_logical_range(from, to, default_max);
175 self.tracking_mode_active = false;
176 self.kinetic_scroll = super::state::KineticScrollState::new();
177 true
178 }
179
180 pub fn set_synced_crosshair_bar_idx(&mut self, bar_idx: Option<f64>) {
187 self.synced_crosshair_bar_idx = bar_idx;
188 self.synced_crosshair_period = None;
189 self.synced_crosshair_price = None;
190 }
191
192 pub fn get_hover_bar_idx(&self) -> Option<f64> {
195 self.last_hover_bar_idx
196 }
197
198 pub fn apply_synced_time_scale(&mut self, bar_spacing: f32, right_offset: f32) {
202 self.state.time_scale_mut().set_bar_spacing(bar_spacing);
203 self.state.time_scale_mut().set_right_offset(right_offset);
204 }
205
206 pub fn get_time_scale_state(&self) -> (f32, f32) {
209 (
210 self.state.time_scale().bar_spacing(),
211 self.state.time_scale().right_offset(),
212 )
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::model::{Bar, BarData, index_at, time_at};
220 use chrono::Duration;
221
222 fn chart(times: &[i64], width: f32) -> Chart {
223 let bars = times
224 .iter()
225 .map(|&t| Bar::new(time(t), 10.0, 11.0, 9.0, 10.0, 1.0))
226 .collect();
227 let mut chart = Chart::new(BarData::new());
228 chart.state.set_data(BarData::from_bars(bars));
230 chart.state.time_scale_mut().set_width(width);
231 chart
232 }
233
234 #[test]
235 fn synced_crosshair_keeps_its_time_when_history_changes() {
236 use crate::widget::test_frames::frame;
237 use crate::widget::{ImageExportOptions, SeriesId};
238
239 let bar = |minute: i64| Bar::new(time(minute * 60), 10.0, 11.0, 9.0, 10.0, 1.0);
240 for price in [Some(10.5), None] {
241 for (prepend, minute, expected_index) in [
242 (false, 10, Some(7.0)),
243 (true, 10, Some(14.0)),
244 (false, 1, None),
245 ] {
246 let mut chart = Chart::new(BarData::from_bars((0..16).map(bar).collect()));
247 if !prepend {
248 chart.set_bar_limit(SeriesId::MAIN, std::num::NonZeroUsize::new(16));
249 }
250 let ctx = egui::Context::default();
251 frame(&ctx, &mut chart, 0.0, Vec::new());
252 let selected = time(minute * 60);
253 match price {
254 Some(price) => chart.set_crosshair_position(Some((selected, price))),
255 None => chart.set_synced_crosshair_time(Some(selected)),
256 }
257 frame(&ctx, &mut chart, 0.1, Vec::new());
258 assert_eq!(chart.last_crosshair, Some((selected, price)));
259 if prepend {
260 for minute in -4..0 {
261 chart.update(SeriesId::MAIN, bar(minute));
262 }
263 assert_eq!(chart.bars(SeriesId::MAIN)[0].time, time(-240));
264 } else {
265 chart.update(SeriesId::MAIN, bar(16));
266 assert_eq!(chart.bars(SeriesId::MAIN)[0].time, time(180));
267 }
268 let expected = expected_index.map(|_| (selected, price));
269 frame(&ctx, &mut chart, 0.2, Vec::new());
270 assert_eq!(chart.synced_crosshair_bar_idx, expected_index);
271 assert_eq!(chart.last_crosshair, expected);
272 let options = ImageExportOptions {
273 include_crosshair: true,
274 ..Default::default()
275 };
276 chart
277 .export_layout_image(&ctx, &options, |ui, snapshot| {
278 snapshot.show(ui);
279 assert_eq!(snapshot.last_crosshair, expected);
280 })
281 .unwrap()
282 .textures_delta
283 .clear();
284 chart.set_synced_crosshair_bar_idx(Some(5.0));
286 chart.update(SeriesId::MAIN, bar(-5));
287 frame(&ctx, &mut chart, 0.3, Vec::new());
288 assert_eq!(chart.synced_crosshair_bar_idx, Some(5.0));
289 assert_eq!(
290 chart.last_crosshair,
291 Some((chart.state.shown()[5].time, None))
292 );
293 }
294 }
295 }
296
297 fn time(seconds: i64) -> DateTime<Utc> {
298 DateTime::from_timestamp(seconds, 0).unwrap()
299 }
300
301 #[test]
302 fn sync_keeps_coarse_receivers_readable() {
303 let hourly: Vec<_> = (0..24).map(|i| i * 3600).collect();
304 let daily: Vec<_> = (0..30).map(|i| i * 86_400).collect();
305 let minutes: Vec<_> = (0..1000).map(|i| i * 60).collect();
306 let mut closed = minutes.clone();
308 closed.push(999 * 60 + 235_800);
309 for (times, slots, from, to, maximum, expected, right) in [
313 (&hourly, None, 70, 170, 0.0, None, None),
314 (&hourly, None, -1550, -1450, 0.0, None, None),
315 (&hourly, None, 10, 50, 0.0, Some(50.0), Some(330.0)),
317 (&hourly, None, 10, 50, 25.0, Some(25.0), Some(630.0)),
318 (&hourly, None, 10, 50, 100.0, Some(100.0), Some(180.0)),
319 (&hourly, None, -121_380, 0, 0.0, Some(0.5), Some(0.0)),
320 (&hourly, None, 35, 55, 0.0, Some(50.0), Some(345.0)),
322 (&daily, None, 1050, 1170, 0.0, Some(50.0), Some(8310.0)),
323 (&hourly, None, 35, 55, 1000.0, Some(1000.0), Some(15.0)),
325 (&minutes, None, -125_280, 4320, 0.0, Some(0.5), Some(0.5)),
327 (&closed, Some(240.0), -3330, -3090, 0.0, Some(50.0), None),
329 ] {
330 let last_open = *times.last().unwrap();
331 let (from, to) = (last_open + from * 60, last_open + to * 60);
332 let mut receiver = chart(times, 501.0);
333 let source = slots.map(|slots| (crate::model::Timeframe::Min1, slots));
334 receiver.timeframe = source.map(|(timeframe, _)| timeframe);
335 receiver.state.time_scale_mut().set_max_bar_spacing(maximum);
336 receiver.tracking_mode_active = true;
337 let before = receiver.get_time_scale_state();
338 assert_eq!(
339 receiver.apply_synced_time_range_from(
340 VisibleTimeRange {
341 from: time(from),
342 to: time(to),
343 },
344 source,
345 ),
346 expected.is_some(),
347 "{from}..{to}, max {maximum}"
348 );
349 let Some(spacing) = expected else {
350 assert_eq!(receiver.get_time_scale_state(), before);
351 assert!(receiver.tracking_mode_active);
352 continue;
353 };
354 let scale = receiver.state.time_scale();
355 assert!((scale.bar_spacing() - spacing).abs() < 1e-4, "{from}..{to}");
356 let edge = |x| scale.coord_to_idx_precise(x, 0.0, 501.0);
357 let (left, last) = (edge(0.0), (times.len() - 1) as f64);
358 assert!(
359 left.ceil().max(0.0) <= edge(500.0).floor().min(last),
360 "{from}..{to}"
361 );
362 assert!(!receiver.tracking_mode_active);
363 if let Some(right) = right {
364 let actual = receiver.visible_time_range().unwrap().to;
365 let expected = time(last_open + (right * 60.0) as i64);
366 assert!(
367 (actual - expected).num_milliseconds().abs() < 1,
368 "{from}..{to}"
369 );
370 }
371 }
372 }
373
374 #[test]
375 fn sync_resolves_receiver_times_and_fractional_edges() {
376 const H: i64 = 3600;
377 const D: i64 = 86_400;
378 let minutes = vec![0, 60, 120, 180, 240, 300];
379 let (gap, doubled) = (vec![60, 120, 240, 300], vec![60, 120, 120, 300]);
380 let days: Vec<_> = (0..30).map(|i| i * D).collect();
381 let four_hours: Vec<_> = (0..6).map(|i| i * 4 * H).collect();
382 let sessions: Vec<_> = (0..2)
384 .flat_map(|day| (0..390).map(move |i| day * D + 13 * H + 1800 + i * 60))
385 .collect();
386 let one = |time| vec![time];
388 for (source, hovered, times, expected) in [
390 (&minutes, Some(2.0), &gap, Some(1.0)),
391 (&minutes, Some(3.0), &gap, Some(1.0)),
392 (&minutes, Some(2.0), &doubled, Some(1.0)),
393 (&minutes, Some(3.0), &doubled, Some(1.0)),
394 (&minutes, Some(0.0), &gap, None),
395 (&minutes, Some(6.0), &gap, None),
396 (&minutes, Some(2.0), &Vec::new(), None),
397 (&minutes, None, &one(120), None),
398 (&one(10 * D + 14 * H + 1800), Some(0.0), &days, Some(10.0)),
400 (&one(29 * D + 14 * H + 1800), Some(0.0), &days, Some(29.0)),
401 (&one(30 * D + 1800), Some(0.0), &days, None),
402 (&one(3 * H), Some(0.0), &four_hours, Some(0.0)),
403 (&days, Some(1.0), &sessions, Some(390.0)),
405 ] {
406 let mut source = chart(source, 1000.0);
407 source.last_hover_bar_idx = hovered;
408 let mut receiver = chart(times, 501.0);
409 receiver.set_synced_crosshair(source.hover_period(), None);
410 assert_eq!(
411 receiver.synced_crosshair_bar_idx, expected,
412 "{times:?} {hovered:?}"
413 );
414 }
415 let repeated = chart(&[0, 60, 60, 120], 501.0);
416 for (index, seconds) in [(0.5, 30), (1.5, 60), (2.5, 90), (3.5, 150)] {
417 assert_eq!(time_at(repeated.state.shown(), index), Some(time(seconds)));
418 }
419 let trailing = chart(&[0, 60, 60], 501.0);
422 assert_eq!(time_at(trailing.state.shown(), 3.5), Some(time(105)));
423 assert_eq!(index_at(trailing.state.shown(), time(105)), Some(3.5));
424 #[cfg(feature = "drawings")]
425 {
426 let mut repeated = repeated;
427 let mut drawing =
428 crate::drawings::Drawing::new(7, crate::drawings::DrawingToolType::TrendLine);
429 drawing
430 .chart_points
431 .push(crate::drawings::ChartPoint::new(1.5, 10.0));
432 repeated.drawings.drawings.push(drawing);
433 repeated.anchor_drawing_points();
434 repeated.project_drawing_points();
435 assert_eq!(repeated.drawings.drawings[0].chart_points[0].bar_idx, 1.5);
436 }
437 let mut source = chart(&[0, 60, 120, 180, 240, 300], 1001.0);
438 source.apply_synced_time_scale(250.0, -1.0);
440 let range = source.visible_time_range().unwrap();
441 assert_eq!(
442 range,
443 VisibleTimeRange {
444 from: time(30),
445 to: time(270)
446 }
447 );
448 for (times, expected_spacing, expected_offset) in [
449 (vec![60, 120, 240, 300], 500.0 / 3.0, -1.0),
450 (vec![0, 120, 240, 360], 250.0, -1.25),
451 ] {
452 let mut receiver = chart(×, 501.0);
453 receiver.state.time_scale_mut().set_max_bar_spacing(5000.0);
454 assert!(receiver.apply_synced_time_range(range));
455 let (spacing, offset) = receiver.get_time_scale_state();
456 assert!((spacing - expected_spacing).abs() < 0.001);
457 assert!((offset - expected_offset).abs() < 0.001);
458 let actual = receiver.visible_time_range().unwrap();
459 assert!((actual.from - range.from).num_milliseconds().abs() < 1);
460 assert!((actual.to - range.to).num_milliseconds().abs() < 1);
461 let before = receiver.get_time_scale_state();
462 assert!(!receiver.apply_synced_time_range(VisibleTimeRange {
463 from: range.to,
464 to: range.from
465 }));
466 assert_eq!(receiver.get_time_scale_state(), before);
467 }
468 let mut times: Vec<i64> = (0..=4096).map(|index| index * 60).collect();
471 let start = *times.last().unwrap();
472 times.extend((0..1024).map(|index| start + 235_800 + index * 60));
473 let range = VisibleTimeRange {
474 from: time(start + 28_800),
475 to: time(start + 115_199),
476 };
477 for width in [251.0, 592.125] {
478 let mut receiver = chart(×, width);
479 receiver.state.time_scale_mut().set_max_bar_spacing(5000.0);
480 assert!(receiver.apply_synced_time_range(range));
481 let actual = receiver.visible_time_range().unwrap();
482 assert!((actual.from - range.from).num_milliseconds().abs() < 1);
483 assert!((actual.to - range.to).num_milliseconds().abs() < 1);
484 }
485 for count in [2, 20_000] {
487 let mut times: Vec<_> = (0..count).map(|i| i * 60).collect();
488 let last = times[count as usize - 1];
489 times.push(last + 3 * 86_400);
490 let mut receiver = chart(×, 1001.0);
491 receiver.state.time_scale_mut().set_max_bar_spacing(5000.0);
492 let range = VisibleTimeRange {
493 from: time(last + 86_400),
494 to: time(last + 2 * 86_400),
495 };
496 assert!(receiver.apply_synced_time_range(range));
497 let actual = receiver.visible_time_range().unwrap();
498 assert!((actual.from - range.from).num_milliseconds().abs() < 10);
499 assert!((actual.to - range.to).num_milliseconds().abs() < 10);
500 }
501 assert!(!chart(&[], 501.0).apply_synced_time_range(range));
502 let mut fine = chart(&[0, 1], 501.0);
504 fine.state.set_data(BarData::from_bars(
505 [0, 100, 200]
506 .map(|ns| {
507 Bar::new(
508 time(1_700_000_000) + Duration::nanoseconds(ns),
509 10.0,
510 11.0,
511 9.0,
512 10.0,
513 1.0,
514 )
515 })
516 .to_vec(),
517 ));
518 let t = time(1_700_000_000) + Duration::nanoseconds(150);
519 assert_eq!(time_at(fine.state.shown(), 1.5), Some(t));
520 assert_eq!(index_at(fine.state.shown(), t), Some(1.5));
521 }
522}