1use super::Chart;
2use crate::chart::rendering;
3use crate::scales::TimeFormatterBuilder;
4
5impl Chart {
6 pub fn format_time(&self, time: chrono::DateTime<chrono::Utc>) -> String {
11 let bar_seconds = self.time_label_seconds();
12 match &self.horizontal_scale {
13 Some(scale) => scale.format_crosshair(time, bar_seconds),
14 None => self
15 .build_time_formatter()
16 .format_crosshair(time, bar_seconds),
17 }
18 }
19
20 pub(super) fn chart_time(&self) -> Option<chrono::DateTime<chrono::Utc>> {
23 self.replay_time().or_else(|| {
24 self.server_time_offset
25 .and_then(|offset| chrono::Utc::now().checked_add_signed(offset))
26 .or_else(|| {
27 self.feed_link
28 .as_ref()
29 .and_then(super::feed::FeedLink::server_time)
30 })
31 })
32 }
33
34 pub(super) fn current_bar_countdown(&self) -> Option<String> {
35 if !self.config.show_countdown || !self.config.show_symbol_last_val {
36 return None;
37 }
38 let last = crate::model::last_with_value(&self.state.data().bars)?;
39 let timeframe = self.timeframe?;
40 if timeframe.is_trade_based() {
41 return None;
42 }
43 let now = self
44 .chart_time()
45 .or_else(|| self.quote.as_ref().map(|quote| quote.ts))?;
46 let session = rendering::exchange_session(
47 self.instrument(),
48 self.config.session_config.as_ref(),
49 &self.display_timezone(),
50 timeframe,
51 );
52 let session_open = if self.feed_link.is_some() && timeframe.duration_ms() >= 86_400_000 {
54 let date = match self.display_timezone() {
55 crate::scales::TimezoneMode::Local => {
56 last.time.with_timezone(&chrono::Local).date_naive()
57 }
58 zone => last
59 .time
60 .with_timezone(&zone.to_tz().unwrap_or(crate::model::TimezoneId::utc()))
61 .date_naive(),
62 };
63 date.and_hms_opt(0, 0, 0)?.and_utc()
64 } else {
65 last.time
66 };
67 let session_close = session.bar_session_close(session_open, timeframe, self.extended_hours);
68 let boundary = self
69 .feed_link
70 .as_ref()
71 .and_then(|link| link.clock.next_open(link.clock.bar_open(last.time)));
72 let close = match (session_close, boundary) {
73 (Some(session), Some(boundary)) if timeframe.duration_ms() < 86_400_000 => {
74 Some(session.min(boundary))
75 }
76 (session, boundary) => session.or(boundary),
77 };
78 let has_session = self
79 .instrument()
80 .and_then(|instrument| instrument.session.as_ref())
81 .is_some_and(|session| {
82 !session.windows.is_empty()
83 || !session.extended_windows.is_empty()
84 || !session.overrides.is_empty()
85 })
86 || self.config.session_config.is_some();
87 if self.bar_close_time.is_none() && session_close.is_none() && has_session {
88 return None;
89 }
90 crate::scales::bar_countdown(last.time, timeframe, now, self.bar_close_time.or(close))
91 }
92
93 pub(super) fn time_label_seconds(&self) -> i64 {
95 if let Some(timeframe) = self
96 .timeframe
97 .filter(|timeframe| !timeframe.is_trade_based())
98 {
99 return timeframe.duration().num_seconds();
100 }
101 let bars = &self.state.data().bars;
102 let shown = self.state.shown();
103 let (start, end) = self.state.visible_range();
105 let nearest = |index: usize| shown.get(index.min(shown.len().saturating_sub(1)));
106 let Some((first, last)) = nearest(start).zip(nearest(end.saturating_sub(1).max(start)))
107 else {
108 return 1;
109 };
110 let end = bars
112 .partition_point(|bar| bar.time <= last.time)
113 .max(16)
114 .min(bars.len());
115 let at = bars
116 .partition_point(|bar| bar.time < first.time)
117 .min(end.saturating_sub(16));
118 let end = end.min(at + 256);
120 if end - at < 2 {
121 return 1;
122 }
123 crate::scales::median_bar_duration(bars[at..end].iter().map(|bar| bar.time)).num_seconds()
124 }
125
126 pub(super) fn display_timezone(&self) -> crate::scales::TimezoneMode {
127 match &self.chart_options.time_scale.timezone {
128 crate::scales::TimezoneMode::Exchange(fallback) => {
129 crate::scales::TimezoneMode::Exchange(
130 self.instrument()
131 .and_then(|instrument| instrument.time_zone.as_ref())
132 .cloned()
133 .unwrap_or_else(|| fallback.clone()),
134 )
135 }
136 timezone => timezone.clone(),
137 }
138 }
139
140 pub(crate) fn build_time_formatter(&self) -> Box<dyn crate::scales::TimeFormatter> {
141 let mut builder = TimeFormatterBuilder::new()
142 .with_24_hour(true)
143 .with_seconds(self.chart_options.time_scale.seconds_visible)
144 .with_time_visible(self.chart_options.time_scale.time_visible)
145 .with_timezone(self.display_timezone());
146 if let Some(locale) = &self.locale {
147 builder = builder.with_locale(locale.clone());
148 }
149 if let Some(date_format) = &self.date_format {
150 builder = builder.with_date_format(date_format.clone());
151 }
152 if let Some(formatter) = &self.time_formatter {
153 builder = builder.with_formatter(formatter.clone_box());
154 }
155 builder.build()
156 }
157
158 pub fn set_locale(&mut self, locale: Option<String>) {
165 self.locale = locale;
166 }
167
168 pub fn set_date_format(&mut self, date_format: Option<String>) {
172 self.date_format = date_format;
173 }
174
175 pub fn set_time_formatter(
191 &mut self,
192 formatter: Option<std::sync::Arc<dyn crate::scales::TimeFormatter>>,
193 ) {
194 self.time_formatter = formatter;
195 }
196
197 pub fn set_price_formatter(
207 &mut self,
208 formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
209 ) {
210 self.price_formatter = formatter;
211 }
212
213 pub fn set_percentage_formatter(
218 &mut self,
219 formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
220 ) {
221 self.percentage_formatter = formatter;
222 }
223
224 pub fn set_volume_formatter(
227 &mut self,
228 formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
229 ) {
230 self.volume_formatter = formatter.clone();
231 for study in self
232 .studies
233 .indicators_mut()
234 .iter_mut()
235 .chain(self.element_studies.studies.indicators_mut().iter_mut())
236 {
237 if study.value_format() == crate::scales::StudyFormat::Volume {
238 study.set_value_formatter(formatter.clone());
239 }
240 }
241 }
242
243 pub fn set_series_formatter(
250 &mut self,
251 id: super::SeriesId,
252 formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
253 ) -> bool {
254 if !(id == super::SeriesId::MAIN && self.main_series_present
255 || self.price_series.iter().any(|item| item.id == id))
256 {
257 return false;
258 }
259 if let Some(formatter) = formatter {
260 self.series_formatters.insert(id, formatter);
261 } else {
262 self.series_formatters.remove(&id);
263 }
264 true
265 }
266
267 pub(super) fn series_label_formatter(
268 &self,
269 id: super::SeriesId,
270 mode: crate::scales::PriceScaleMode,
271 ) -> Option<std::sync::Arc<dyn crate::scales::PriceFormatter>> {
272 match mode {
273 crate::scales::PriceScaleMode::Percentage
274 | crate::scales::PriceScaleMode::IndexedTo100 => self.percentage_formatter.clone(),
275 _ => self
276 .series_formatters
277 .get(&id)
278 .cloned()
279 .or_else(|| self.price_formatter.clone())
280 .or_else(|| Some(std::sync::Arc::new(self.series_style(id)?.display_format?))),
281 }
282 }
283
284 pub(super) fn series_price_formatter(
286 &self,
287 id: super::SeriesId,
288 ) -> Option<&dyn crate::scales::PriceFormatter> {
289 self.series_formatters
290 .get(&id)
291 .or(self.price_formatter.as_ref())
292 .map(|formatter| formatter.as_ref())
293 .or_else(|| {
294 self.series_style(id)?
295 .display_format
296 .as_ref()
297 .map(|format| format as &dyn crate::scales::PriceFormatter)
298 })
299 }
300
301 pub(super) fn series_axis_owner(&self, id: super::SeriesId) -> super::SeriesId {
303 let (pane, scale) = if id == super::SeriesId::MAIN {
304 (Some(super::PaneId::MAIN), Some(self.main_series_scale))
306 } else {
307 (self.series_pane(id), self.series_scale(id))
308 };
309 if pane == Some(super::PaneId::MAIN)
310 && scale == Some(self.main_series_scale)
311 && self.main_series_present
312 && self.main_series_visible
313 {
314 return super::SeriesId::MAIN;
315 }
316 self.price_series
317 .iter()
318 .find(|item| {
319 item.visible
320 && self.series_pane(item.id) == pane
321 && (pane != Some(super::PaneId::MAIN) || Some(item.options.scale) == scale)
322 })
323 .map_or(id, |item| item.id)
324 }
325
326 pub(super) fn series_display_map(
327 &self,
328 id: super::SeriesId,
329 map: crate::scales::PriceMap,
330 ) -> crate::scales::PriceMap {
331 self.series_style(self.series_axis_owner(id))
332 .and_then(|style| style.display_format)
333 .map_or(map, |format| map.with_display_format(format))
334 }
335
336 pub(crate) fn value_formatters(&self) -> rendering::ValueFormatters<'_> {
337 rendering::ValueFormatters {
338 price: self.series_price_formatter(super::SeriesId::MAIN),
339 volume: self.volume_formatter.as_deref(),
340 percentage: self.percentage_formatter.as_deref(),
341 }
342 }
343
344 pub fn set_session_provider(
346 &mut self,
347 provider: Option<std::sync::Arc<dyn crate::model::SessionProvider>>,
348 ) {
349 self.session_provider = provider;
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use crate::chart::series::SeriesSettings;
356 use crate::scales::PriceScaleMode;
357 #[cfg(feature = "timezones")]
358 use crate::widget::test_frames::chart;
359 use crate::widget::test_frames::{frame, shapes};
360 use egui::{Event, Pos2, Shape};
361
362 #[cfg(feature = "timezones")]
363 #[test]
364 fn countdown_uses_feed_calendar_and_session_trading_dates() {
365 use crate::data::{Datafeed, HistoryPage, HistoryRequest, Reply};
366 use crate::model::{
367 Bar, Instrument, Level1Quote, Session, SessionWindow, Timeframe, TimezoneId,
368 };
369 use chrono::{
370 NaiveTime,
371 Weekday::{Fri, Mon, Thu, Tue, Wed},
372 };
373 struct Feed;
374 impl Datafeed for Feed {
375 fn history(&self, _: HistoryRequest, _: Reply<HistoryPage>) {
376 unreachable!("this test only reads the attached calendar");
377 }
378 }
379 for (interval, open, now, session, expected) in [
380 (
381 "7min",
382 "2024-03-31T00:59:00Z",
383 "2024-03-31T00:59:30Z",
384 false,
385 Some("00:30"),
386 ),
387 (
388 "1D",
389 "2024-03-10T23:00:00Z",
390 "2024-03-11T14:59:00Z",
391 true,
392 Some("01:00"),
393 ),
394 (
395 "1W",
396 "2024-03-10T23:00:00Z",
397 "2024-03-15T14:59:00Z",
398 true,
399 Some("01:00"),
400 ),
401 (
402 "1M",
403 "2024-02-29T23:00:00Z",
404 "2024-03-29T14:59:00Z",
405 true,
406 Some("01:00"),
407 ),
408 (
409 "10t",
410 "2024-03-11T14:59:00Z",
411 "2024-03-11T14:59:30Z",
412 false,
413 None,
414 ),
415 ] {
416 let timeframe: Timeframe = interval.parse().unwrap();
417 let mut chart = chart(&[100.0], &[110.0]);
418 chart.timeframe = Some(timeframe);
419 chart.chart_options.time_scale.timezone =
420 crate::scales::TimezoneMode::Exchange("Europe/Zurich".parse().unwrap());
421 chart.attach_feed(std::sync::Arc::new(Feed), 1);
422 let mut instrument =
423 Instrument::new("TEST").with_time_zone(TimezoneId::named("Europe/Zurich").unwrap());
424 if session {
425 instrument.session = Some(Session::new([SessionWindow::new(
426 [Mon, Tue, Wed, Thu, Fri],
427 NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
428 NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
429 )]));
430 }
431 chart.set_instrument(Some(instrument));
432 chart.feed_link.as_mut().unwrap().clock = chart.bar_clock();
434 chart.set_data(
435 crate::widget::SeriesId::MAIN,
436 vec![Bar::new(
437 open.parse().unwrap(),
438 100.0,
439 110.0,
440 100.0,
441 110.0,
442 1.0,
443 )],
444 );
445 chart.set_quote(Some(Level1Quote {
446 symbol: "TEST".into(),
447 ts: now.parse().unwrap(),
448 bid: 109.0,
449 ask: 111.0,
450 bid_size: 1.0,
451 ask_size: 1.0,
452 }));
453 assert_eq!(
454 chart.current_bar_countdown().as_deref(),
455 expected,
456 "{interval}"
457 );
458 }
459 }
460
461 #[test]
464 fn time_precision_follows_the_interval_across_gaps_and_clock_changes() {
465 use crate::model::{Bar, BarData, LogicalRange, Timeframe};
466 use chrono::{Duration, TimeZone, Utc};
467 let session = |day, minutes| {
468 let open = Utc.with_ymd_and_hms(2024, 1, day, 14, 30, 0).unwrap();
469 (0..minutes).map(move |minute| {
470 Bar::new(
471 open + Duration::minutes(minute),
472 100.0,
473 110.0,
474 100.0,
475 110.0,
476 1.0,
477 )
478 })
479 };
480 for (monday, left, right, at, expected) in [
482 (390, 390.5, 450.5, 405, "Mon 08 Jan '24 14:45"),
483 (390, 389.5, 450.5, 405, "Mon 08 Jan '24 14:45"),
484 (1, 400.5, 460.5, 390, "Mon 08 Jan '24 14:30"),
485 ] {
486 for timeframe in [
487 Some(Timeframe::Min1),
488 None,
489 Some(Timeframe::Ticks(std::num::NonZeroU64::new(10).unwrap())),
490 ] {
491 let ctx = egui::Context::default();
492 let bars = session(5, 390).chain(session(8, monday)).collect();
493 let mut chart = crate::widget::Chart::new(BarData::from_bars(bars));
494 if let Some(timeframe) = timeframe {
495 chart.set_timeframe(timeframe);
496 }
497 chart.chart_options.tooltip.enabled = false;
498 frame(&ctx, &mut chart, 0.0, vec![]);
499 assert!(chart.set_visible_logical_range(LogicalRange::new(left, right)));
500 let time = chart.state.data().bars[at].time;
501 assert_eq!(
502 chart.format_time(time),
503 expected,
504 "{left}..{right}, {timeframe:?}"
505 );
506 if at as f32 >= left {
507 let map = chart.get_chart_mapping();
508 let painted = shapes(
509 &ctx,
510 &mut chart,
511 0.1,
512 vec![Event::PointerMoved(Pos2::new(
513 map.idx_to_x(at),
514 map.rect.center().y,
515 ))],
516 );
517 assert!(
518 painted.iter().any(|shape| matches!(shape,
519 Shape::Text(text) if text.galley.text() == expected
520 )),
521 "crosshair: {left}..{right}, {timeframe:?}"
522 );
523 }
524 }
525 }
526 #[cfg(feature = "timezones")]
527 for (left, right) in [(39.5, 69.5), (38.5, 68.5)] {
528 let ctx = egui::Context::default();
529 let first = Utc.with_ymd_and_hms(2024, 2, 1, 5, 0, 0).unwrap();
530 let bars = (0..90)
532 .map(|day| {
533 let time = first + Duration::days(day)
534 - if day >= 39 {
535 Duration::hours(1)
536 } else {
537 Duration::zero()
538 };
539 Bar::new(time, 100.0, 110.0, 100.0, 110.0, 1.0)
540 })
541 .collect();
542 let mut chart = crate::widget::Chart::new(BarData::from_bars(bars));
543 chart.set_timeframe(Timeframe::Day1);
544 chart.chart_options.time_scale.timezone =
545 crate::scales::TimezoneMode::Timezone("America/New_York".parse().unwrap());
546 frame(&ctx, &mut chart, 0.0, vec![]);
547 assert!(chart.set_visible_logical_range(LogicalRange::new(left, right)));
548 assert_eq!(
549 chart.format_time(chart.state.data().bars[39].time),
550 "Mar 11, 2024",
551 "{left}..{right}"
552 );
553 }
554 }
555
556 #[test]
557 fn tick_context_tracks_locale_and_falls_back_without_changing_crosshair() {
558 use crate::model::{BarData, Timeframe};
559 use crate::scales::{TickMarkContext, TickMarkType, TimeFormatter, TimezoneMode};
560 use chrono::{DateTime, Utc};
561 use std::sync::Arc;
562
563 #[derive(Clone)]
564 struct Host;
565 impl TimeFormatter for Host {
566 fn format(&self, _: DateTime<Utc>, _: TickMarkType) -> String {
567 "legacy tick".into()
568 }
569 fn format_tick(&self, context: TickMarkContext<'_>) -> Option<String> {
570 match context.mark_type {
571 TickMarkType::Year => Some(format!(
572 "{} {}",
573 context.locale.unwrap_or("default"),
574 context.time.format("%Y-%m-%d %H:%M:%S%.3f")
575 )),
576 TickMarkType::TimeWithMilliseconds => Some(String::new()),
577 _ => None,
578 }
579 }
580 fn format_crosshair(&self, time: DateTime<Utc>, _: i64) -> String {
581 format!("hover {}", time.format("%H:%M"))
582 }
583 fn clone_box(&self) -> Box<dyn TimeFormatter> {
584 Box::new(self.clone())
585 }
586 }
587
588 let time = "2024-02-29T19:00:01.123Z".parse().unwrap();
589 let mut chart = crate::widget::Chart::new(BarData::from_bars(vec![]));
590 chart.set_timeframe(Timeframe::Min1);
591 chart.chart_options.time_scale.timezone = TimezoneMode::Timezone("+05:45".parse().unwrap());
592 chart.set_time_formatter(Some(Arc::new(Host)));
593 chart.set_date_format(Some("%d.%m.%Y".into()));
594 for (locale, prefix, month) in [
596 (Some("en-US"), "en-US", "Mar"),
597 (
598 Some("de-DE"),
599 "de-DE",
600 if cfg!(feature = "locales") {
601 "Mär"
602 } else {
603 "Mar"
604 },
605 ),
606 (Some("unknown"), "unknown", "Mar"),
607 (None, "default", "Mar"),
608 ] {
609 chart.set_locale(locale.map(str::to_owned));
610 let formatter = chart.build_time_formatter();
611 for formatter in [formatter.clone_box(), formatter] {
612 for (kind, expected) in [
613 (
614 TickMarkType::Year,
615 format!("{prefix} 2024-03-01 00:45:01.123"),
616 ),
617 (TickMarkType::Month, month.into()),
618 (TickMarkType::DayOfMonth, "01".into()),
619 (TickMarkType::Time, "00:45".into()),
620 (TickMarkType::TimeWithSeconds, "00:45:01".into()),
621 (TickMarkType::TimeWithMilliseconds, String::new()),
622 ] {
623 assert_eq!(
624 formatter.format(time, kind),
625 expected,
626 "{locale:?}, {kind:?}"
627 );
628 }
629 assert_eq!(formatter.format_crosshair(time, 60), "hover 00:45");
630 }
631 assert_eq!(chart.format_time(time), "hover 00:45");
632 }
633 chart.chart_options.time_scale.seconds_visible = false;
634 assert_eq!(
635 chart
636 .build_time_formatter()
637 .format(time, TickMarkType::TimeWithSeconds),
638 "00:45"
639 );
640 chart.chart_options.time_scale.time_visible = false;
641 assert_eq!(
642 chart
643 .build_time_formatter()
644 .format(time, TickMarkType::Time),
645 "hover 00:45"
646 );
647 }
648
649 #[test]
650 fn host_formatters_reach_axes_crosshair_and_tags() {
651 use crate::model::{Bar, BarData, Timeframe};
652 use crate::scales::{PriceFormatter, TickMarkType, TimeFormatter};
653 use chrono::{DateTime, Duration, TimeZone, Utc};
654 use std::sync::Arc;
655 #[derive(Clone)]
656 struct Host;
657 impl TimeFormatter for Host {
658 fn format(&self, _: DateTime<Utc>, _: TickMarkType) -> String {
659 "tick time".into()
660 }
661 fn format_crosshair(&self, _: DateTime<Utc>, _: i64) -> String {
662 "hover time".into()
663 }
664 fn clone_box(&self) -> Box<dyn TimeFormatter> {
665 Box::new(Host)
666 }
667 }
668 impl PriceFormatter for Host {
669 fn format(&self, _: f64) -> String {
670 "label price".into()
671 }
672 fn format_tick(&self, _: f64) -> String {
673 "tick price".into()
674 }
675 fn clone_box(&self) -> Box<dyn PriceFormatter> {
676 Box::new(Host)
677 }
678 }
679 let time = Utc.with_ymd_and_hms(2024, 3, 13, 14, 30, 0).unwrap();
681 type Setup = fn(&mut crate::widget::Chart);
682 let cases: [(Setup, &[&str]); 6] = [
683 (
684 |chart| chart.set_locale(Some("de-DE".into())),
685 &[if cfg!(feature = "locales") {
686 "Mi 13 Mär '24 14:30"
687 } else {
688 "Wed 13 Mar '24 14:30"
689 }],
690 ),
691 (
692 |chart| chart.set_date_format(Some("%d.%m.%Y".into())),
693 &["13.03.2024 14:30"],
694 ),
695 (
696 |chart| chart.set_time_formatter(Some(Arc::new(Host))),
697 &["hover time", "tick time"],
698 ),
699 (
700 |chart| chart.set_price_formatter(Some(Arc::new(Host))),
701 &["label price", "tick price", "O label price"],
702 ),
703 (
704 |chart| {
705 chart.add_indicator(Box::new(
706 crate::studies::CustomIndicator::new("Volume units", |bars| {
707 vec![crate::studies::IndicatorValue::Single(99.0); bars.len()]
708 })
709 .with_value_format(crate::scales::StudyFormat::Volume),
710 ));
711 chart.set_volume_formatter(Some(Arc::new(Host)));
712 },
713 &["label price"],
714 ),
715 (
716 |chart| {
717 chart.set_percentage_formatter(Some(Arc::new(Host)));
718 chart.apply_series_settings(&SeriesSettings {
719 price_scale_mode: PriceScaleMode::Percentage,
720 ..SeriesSettings::default()
721 });
722 },
723 &["label price", "tick price"],
724 ),
725 ];
726 for (setup, expected) in cases {
727 let ctx = egui::Context::default();
728 let bars = (0..100)
729 .map(|i| {
730 let open = 100.0 + i as f64;
731 Bar::new(
732 time + Duration::minutes(i - 50),
733 open,
734 open + 2.0,
735 open - 1.0,
736 open + 1.0,
737 1.0,
738 )
739 })
740 .collect();
741 let mut chart = crate::widget::Chart::new(BarData::from_bars(bars));
742 chart.set_timeframe(Timeframe::Min1);
743 chart.chart_options.tooltip.enabled = false;
744 frame(&ctx, &mut chart, 0.0, vec![]);
745 setup(&mut chart);
747 frame(&ctx, &mut chart, 0.05, vec![]);
748 let map = chart.get_chart_mapping();
749 let pos = Pos2::new(map.idx_to_x(50), map.rect.center().y);
750 let painted = shapes(&ctx, &mut chart, 0.1, vec![Event::PointerMoved(pos)]);
751 for text in expected {
752 assert!(
753 painted.iter().any(
754 |shape| matches!(shape, Shape::Text(shape) if shape.galley.text() == *text)
755 ),
756 "{text}"
757 );
758 }
759 if chart.volume_formatter.is_some() {
760 let labels = painted
762 .iter()
763 .filter(|shape| {
764 matches!(shape,
765 Shape::Text(text) if text.pos.x < chart.last_rendered_price_rect.center().x
766 && text.galley.job.sections.iter().any(|section| {
767 &text.galley.job.text[section.byte_range.start.0..section.byte_range.end.0]
768 == "label price"
769 })
770 )
771 })
772 .count();
773 assert_eq!(
774 labels, 2,
775 "the volume row and overlay legend use the host formatter"
776 );
777 }
778 if expected.contains(&"O label price") {
779 chart.chart_options.tooltip.enabled = true;
780 chart.chart_options.tooltip.on_long_press = false;
781 let painted = shapes(&ctx, &mut chart, 0.2, vec![Event::PointerMoved(pos)]);
782 let labels = painted.iter().filter(|shape| matches!(shape, Shape::Text(text) if text.galley.text() == "label price")).count();
783 assert!(
784 labels >= 5,
785 "OHLC tooltip values must use the host formatter: {labels}"
786 );
787 }
788 }
789 }
790
791 #[cfg(feature = "timezones")]
792 #[test]
793 fn hover_times_share_the_chart_zone_through_dst() {
794 use crate::model::{Bar, BarData, Timeframe};
795 use crate::scales::TimezoneMode;
796 use chrono::{Duration, TimeZone, Utc};
797 for (zone, month, day, hour, before, after) in [
800 (
801 "America/New_York",
802 3,
803 10,
804 7,
805 "Sun 10 Mar '24 01:59:59",
806 "Sun 10 Mar '24 03:00:00",
807 ),
808 (
809 "America/New_York",
810 11,
811 3,
812 6,
813 "Sun 03 Nov '24 01:59:59",
814 "Sun 03 Nov '24 01:00:00",
815 ),
816 (
817 "Europe/Zurich",
818 3,
819 31,
820 1,
821 "Sun 31 Mar '24 01:59:59",
822 "Sun 31 Mar '24 03:00:00",
823 ),
824 (
825 "Europe/Zurich",
826 10,
827 27,
828 1,
829 "Sun 27 Oct '24 02:59:59",
830 "Sun 27 Oct '24 02:00:00",
831 ),
832 ] {
833 let transition = Utc.with_ymd_and_hms(2024, month, day, hour, 0, 0).unwrap();
834 for (time, expected) in [
835 (transition - Duration::seconds(1), before),
836 (transition, after),
837 ] {
838 for mode in [
839 crate::config::TooltipMode::Floating,
840 crate::config::TooltipMode::Tracking,
841 ] {
842 let ctx = egui::Context::default();
843 let bars = (0..100)
844 .map(|i| {
845 Bar::new(
846 time + Duration::seconds(i - 50),
847 100.0,
848 102.0,
849 99.0,
850 101.0,
851 1.0,
852 )
853 })
854 .collect();
855 let mut chart = crate::widget::Chart::new(BarData::from_bars(bars));
856 let study = chart.add_indicator(Box::new(
857 crate::studies::CustomIndicator::new("Fixture", |bars| {
858 vec![crate::studies::IndicatorValue::Single(0.12345); bars.len()]
859 }),
860 ));
861 let study = chart.indicator_mut(study).unwrap();
862 let mut settings = study.settings();
863 settings.precision = Some(3);
864 study.apply_settings(&[], settings).unwrap();
865 chart.set_timeframe(Timeframe::Sec1);
866 chart.chart_options.time_scale.timezone = TimezoneMode::nyse();
867 chart.set_instrument(Some(
868 crate::model::Instrument::new("DST")
869 .with_time_zone(crate::model::TimezoneId::named(zone).unwrap()),
870 ));
871 assert_eq!(chart.format_time(time), expected);
872 chart.chart_options.tooltip.mode = mode;
873 chart.chart_options.tooltip.show_time = true;
874 chart.chart_options.tooltip.on_long_press = false;
875 frame(&ctx, &mut chart, 0.0, vec![]);
876 let map = chart.get_chart_mapping();
877 let pos = Pos2::new(map.idx_to_x(50), map.rect.center().y);
878 let painted = shapes(&ctx, &mut chart, 0.1, vec![Event::PointerMoved(pos)]);
879 let times: Vec<_> = painted
880 .iter()
881 .filter_map(|shape| match shape {
882 Shape::Text(text) if text.galley.text().contains(expected) => {
883 Some(text.galley.text())
884 }
885 _ => None,
886 })
887 .collect();
888 let values: Vec<_> = painted
889 .iter()
890 .filter_map(|shape| match shape {
891 Shape::Text(text) => Some(text.galley.text()),
892 _ => None,
893 })
894 .collect();
895 match mode {
896 crate::config::TooltipMode::Floating => {
897 assert_eq!(
898 values.iter().filter(|text| **text == "Fixture").count(),
899 2,
900 "{values:?}"
901 );
902 assert_eq!(
903 values
904 .windows(2)
905 .filter(|pair| *pair == ["Fixture", "0.123"])
906 .count(),
907 2,
908 "{values:?}"
909 );
910 }
911 _ => assert!(
912 values.iter().any(|text| text.contains("Fixture: 0.123")),
913 "{values:?}"
914 ),
915 }
916 assert_eq!(
917 times.len(),
918 2,
919 "{zone} {time} {mode:?}: expected matching tooltip and crosshair, {times:?}"
920 );
921 }
922 }
923 }
924 }
925}