1use chrono::{DateTime, Utc};
2
3use crate::charting::scene::{
4 BarSeries, CandleSeries, ChartScene, Crosshair, EpochMs, HoverModel, LineSeries, MarkerSeries,
5 Pane, Series, TooltipModel, TooltipRow, TooltipSection, ValueFormatter,
6};
7
8const OUTER_MARGIN: f32 = 12.0;
9const LEFT_AXIS_WIDTH: f32 = 72.0;
10const RIGHT_PADDING: f32 = 12.0;
11const CAPTION_HEIGHT: f32 = 30.0;
12const TOP_PADDING: f32 = 8.0;
13const X_LABEL_HEIGHT: f32 = 44.0;
14const COMPACT_BOTTOM_PADDING: f32 = 12.0;
15
16pub fn hover_model_at(
17 scene: &ChartScene,
18 width: f32,
19 height: f32,
20 x: f32,
21 y: f32,
22) -> Option<HoverModel> {
23 let pane = pane_at(scene, y, height)?;
24 let plot_rect = pane_plot_rect(scene, pane, width, height);
25 if x < plot_rect.left || x > plot_rect.right || y < plot_rect.top || y > plot_rect.bottom {
26 return None;
27 }
28
29 let (min_x, max_x) = visible_time_bounds(scene)?;
30 let local_x =
31 ((x - plot_rect.left) / (plot_rect.right - plot_rect.left).max(1.0)).clamp(0.0, 1.0);
32 let interpolated_time = interpolate_time(min_x, max_x, local_x);
33 let time_ms =
34 nearest_visible_time(pane, min_x, max_x, interpolated_time).unwrap_or(interpolated_time);
35 let (min_y, max_y) = pane_value_bounds(pane)?;
36 let local_y =
37 ((y - plot_rect.top) / (plot_rect.bottom - plot_rect.top).max(1.0)).clamp(0.0, 1.0);
38 let value = max_y - (max_y - min_y) * f64::from(local_y);
39 Some(HoverModel {
40 crosshair: Some(Crosshair {
41 time_ms,
42 value: Some(value),
43 color: None,
44 }),
45 tooltip: Some(tooltip_for_time(scene, pane, time_ms)),
46 })
47}
48
49pub fn zoom_scene(scene: &mut ChartScene, anchor_ratio: f32, zoom_delta: f32) {
50 let Some((full_min, full_max)) = scene_time_bounds(scene) else {
51 return;
52 };
53 let (current_min, current_max) = visible_time_bounds(scene).unwrap_or((full_min, full_max));
54 let full_span = (full_max.as_i64() - full_min.as_i64()).max(1);
55 let current_span = (current_max.as_i64() - current_min.as_i64()).max(1);
56 let factor = 0.85_f64.powf(f64::from(zoom_delta));
57 let min_span = full_span.clamp(1, 1_000);
59 let new_span = ((current_span as f64) * factor)
60 .round()
61 .clamp(min_span as f64, full_span as f64) as i64;
62 let anchor =
63 current_min.as_i64() + ((current_span as f32) * anchor_ratio.clamp(0.0, 1.0)) as i64;
64 let left_ratio = f64::from(anchor_ratio.clamp(0.0, 1.0));
65 let mut new_min = anchor - (new_span as f64 * left_ratio).round() as i64;
66 let mut new_max = new_min + new_span;
67 if new_min < full_min.as_i64() {
68 let shift = full_min.as_i64() - new_min;
69 new_min += shift;
70 new_max += shift;
71 }
72 if new_max > full_max.as_i64() {
73 let shift = new_max - full_max.as_i64();
74 new_min -= shift;
75 new_max -= shift;
76 }
77 scene.viewport.x_range = Some((
78 EpochMs::new(new_min),
79 EpochMs::new(new_max.max(new_min + 1)),
80 ));
81}
82
83pub fn pan_scene(scene: &mut ChartScene, delta_ratio: f32) {
84 let Some((full_min, full_max)) = scene_time_bounds(scene) else {
85 return;
86 };
87 let (current_min, current_max) = visible_time_bounds(scene).unwrap_or((full_min, full_max));
88 let span = (current_max.as_i64() - current_min.as_i64()).max(1);
89 let shift = ((span as f32) * delta_ratio) as i64;
90 if shift == 0 {
91 return;
92 }
93 let mut new_min = current_min.as_i64() + shift;
94 let mut new_max = current_max.as_i64() + shift;
95 if new_min < full_min.as_i64() {
96 let adjust = full_min.as_i64() - new_min;
97 new_min += adjust;
98 new_max += adjust;
99 }
100 if new_max > full_max.as_i64() {
101 let adjust = new_max - full_max.as_i64();
102 new_min -= adjust;
103 new_max -= adjust;
104 }
105 scene.viewport.x_range = Some((
106 EpochMs::new(new_min),
107 EpochMs::new(new_max.max(new_min + 1)),
108 ));
109}
110
111pub fn tooltip_for_time(scene: &ChartScene, pane: &Pane, time_ms: EpochMs) -> TooltipModel {
112 let mut sections = Vec::new();
113 for series in &pane.series {
114 match series {
115 Series::Candles(series) => {
116 append_candle_tooltip(sections.as_mut(), series, pane, time_ms)
117 }
118 Series::Bars(series) => append_bar_tooltip(sections.as_mut(), series, pane, time_ms),
119 Series::Line(series) => append_line_tooltip(sections.as_mut(), series, pane, time_ms),
120 Series::Markers(series) => append_marker_tooltip(sections.as_mut(), series, time_ms),
121 }
122 }
123 TooltipModel {
124 title: format_time(time_ms, &scene.time_label_format),
125 sections,
126 }
127}
128
129pub fn format_value(value: f64, formatter: &ValueFormatter) -> String {
130 match formatter {
131 ValueFormatter::Number {
132 decimals,
133 prefix,
134 suffix,
135 } => format!(
136 "{prefix}{value:.prec$}{suffix}",
137 prec = usize::from(*decimals)
138 ),
139 ValueFormatter::Compact {
140 decimals,
141 prefix,
142 suffix,
143 } => {
144 let abs = value.abs();
145 let (scaled, unit) = if abs >= 1_000_000_000.0 {
146 (value / 1_000_000_000.0, "B")
147 } else if abs >= 1_000_000.0 {
148 (value / 1_000_000.0, "M")
149 } else if abs >= 1_000.0 {
150 (value / 1_000.0, "K")
151 } else {
152 (value, "")
153 };
154 format!(
155 "{prefix}{scaled:.prec$}{unit}{suffix}",
156 prec = usize::from(*decimals)
157 )
158 }
159 ValueFormatter::Percent { decimals } => {
160 format!("{:.prec$}%", value * 100.0, prec = usize::from(*decimals))
161 }
162 }
163}
164
165pub fn pane_value_bounds(pane: &Pane) -> Option<(f64, f64)> {
166 let mut values = pane_points(pane)
167 .map(|(_, value)| value)
168 .collect::<Vec<_>>();
169 if values.is_empty() {
170 return None;
171 }
172 if pane.y_axis.include_zero {
173 values.push(0.0);
174 }
175 let min = values.iter().copied().fold(f64::INFINITY, f64::min);
176 let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
177 let span = (max - min).abs();
178 let padding = if span < f64::EPSILON {
179 1.0
180 } else {
181 span * 0.08
182 };
183 Some((min - padding, max + padding))
184}
185
186pub fn scene_time_bounds(scene: &ChartScene) -> Option<(EpochMs, EpochMs)> {
187 let mut times = scene
188 .panes
189 .iter()
190 .flat_map(|pane| pane_points(pane).map(|(time, _)| time))
191 .collect::<Vec<_>>();
192 if times.is_empty() {
193 return None;
194 }
195 times.sort();
196 let min = *times.first()?;
197 let max = *times.last()?;
198 Some(if min == max {
199 (min, EpochMs::new(min.as_i64().saturating_add(1)))
200 } else {
201 (min, max)
202 })
203}
204
205pub fn visible_time_bounds(scene: &ChartScene) -> Option<(EpochMs, EpochMs)> {
206 match (scene.viewport.x_range, scene_time_bounds(scene)) {
207 (Some((min, max)), Some((full_min, full_max))) => {
208 let clamped_min = EpochMs::new(min.as_i64().max(full_min.as_i64()));
209 let clamped_max = EpochMs::new(
210 max.as_i64()
211 .min(full_max.as_i64())
212 .max(clamped_min.as_i64() + 1),
213 );
214 Some((clamped_min, clamped_max))
215 }
216 (None, full) => full,
217 _ => None,
218 }
219}
220
221pub fn pane_rect(scene: &ChartScene, pane: &Pane, total_height: f32) -> (f32, f32) {
222 let total_weight = scene
223 .panes
224 .iter()
225 .map(|pane| pane.weight.max(1) as f32)
226 .sum::<f32>()
227 .max(1.0);
228 let mut top = 0.0f32;
229 for current in &scene.panes {
230 let pane_height = total_height * (current.weight.max(1) as f32 / total_weight);
231 let bottom = top + pane_height;
232 if current.id == pane.id {
233 return (top, bottom);
234 }
235 top = bottom;
236 }
237 (0.0, total_height)
238}
239
240fn pane_plot_rect(
241 scene: &ChartScene,
242 pane: &Pane,
243 total_width: f32,
244 total_height: f32,
245) -> PlotRect {
246 let (pane_top, pane_bottom) = pane_rect(scene, pane, total_height);
247 let is_last = scene
248 .panes
249 .last()
250 .is_some_and(|current| current.id == pane.id);
251 PlotRect {
252 left: OUTER_MARGIN + LEFT_AXIS_WIDTH,
253 right: total_width - OUTER_MARGIN - RIGHT_PADDING,
254 top: pane_top + OUTER_MARGIN + CAPTION_HEIGHT + TOP_PADDING,
255 bottom: pane_bottom
256 - OUTER_MARGIN
257 - if is_last {
258 X_LABEL_HEIGHT
259 } else {
260 COMPACT_BOTTOM_PADDING
261 },
262 }
263}
264
265fn pane_at(scene: &ChartScene, y: f32, total_height: f32) -> Option<&Pane> {
266 scene.panes.iter().find(|pane| {
267 let (top, bottom) = pane_rect(scene, pane, total_height);
268 y >= top && y <= bottom
269 })
270}
271
272fn pane_points(pane: &Pane) -> impl Iterator<Item = (EpochMs, f64)> + '_ {
273 pane.series.iter().flat_map(|series| match series {
274 Series::Candles(series) => series
275 .candles
276 .iter()
277 .flat_map(|candle| {
278 [
279 (candle.open_time_ms, candle.high),
280 (candle.close_time_ms, candle.low),
281 (candle.open_time_ms, candle.open),
282 (candle.close_time_ms, candle.close),
283 ]
284 })
285 .collect::<Vec<_>>(),
286 Series::Bars(series) => series
287 .bars
288 .iter()
289 .flat_map(|bar| [(bar.open_time_ms, 0.0), (bar.close_time_ms, bar.value)])
290 .collect::<Vec<_>>(),
291 Series::Line(series) => series
292 .points
293 .iter()
294 .map(|point| (point.time_ms, point.value))
295 .collect::<Vec<_>>(),
296 Series::Markers(series) => series
297 .markers
298 .iter()
299 .map(|marker| (marker.time_ms, marker.value))
300 .collect::<Vec<_>>(),
301 })
302}
303
304fn nearest_visible_time(
305 pane: &Pane,
306 min_x: EpochMs,
307 max_x: EpochMs,
308 target: EpochMs,
309) -> Option<EpochMs> {
310 pane.series
311 .iter()
312 .filter_map(|series| nearest_series_time(series, min_x, max_x, target))
313 .min_by_key(|time| distance(*time, target))
314}
315
316fn append_candle_tooltip(
317 sections: &mut Vec<TooltipSection>,
318 series: &CandleSeries,
319 pane: &Pane,
320 time_ms: EpochMs,
321) {
322 let Some(index) = nearest_index_by_time(
323 &series
324 .candles
325 .iter()
326 .map(|candle| candle.close_time_ms)
327 .collect::<Vec<_>>(),
328 time_ms,
329 ) else {
330 return;
331 };
332 let candle = &series.candles[index];
333 sections.push(TooltipSection {
334 title: "OHLC".to_string(),
335 rows: vec![
336 TooltipRow {
337 label: "Open".to_string(),
338 value: format_value(candle.open, &pane.y_axis.formatter),
339 },
340 TooltipRow {
341 label: "High".to_string(),
342 value: format_value(candle.high, &pane.y_axis.formatter),
343 },
344 TooltipRow {
345 label: "Low".to_string(),
346 value: format_value(candle.low, &pane.y_axis.formatter),
347 },
348 TooltipRow {
349 label: "Close".to_string(),
350 value: format_value(candle.close, &pane.y_axis.formatter),
351 },
352 ],
353 });
354}
355
356fn append_bar_tooltip(
357 sections: &mut Vec<TooltipSection>,
358 series: &BarSeries,
359 pane: &Pane,
360 time_ms: EpochMs,
361) {
362 let Some(index) = nearest_index_by_time(
363 &series
364 .bars
365 .iter()
366 .map(|bar| bar.close_time_ms)
367 .collect::<Vec<_>>(),
368 time_ms,
369 ) else {
370 return;
371 };
372 let bar = &series.bars[index];
373 sections.push(TooltipSection {
374 title: title_case(&series.name),
375 rows: vec![TooltipRow {
376 label: "Value".to_string(),
377 value: format_value(bar.value, &pane.y_axis.formatter),
378 }],
379 });
380}
381
382fn append_line_tooltip(
383 sections: &mut Vec<TooltipSection>,
384 series: &LineSeries,
385 pane: &Pane,
386 time_ms: EpochMs,
387) {
388 let Some(index) = nearest_index_by_time(
389 &series
390 .points
391 .iter()
392 .map(|point| point.time_ms)
393 .collect::<Vec<_>>(),
394 time_ms,
395 ) else {
396 return;
397 };
398 let point = &series.points[index];
399 sections.push(TooltipSection {
400 title: title_case(&series.name),
401 rows: vec![TooltipRow {
402 label: "Value".to_string(),
403 value: format_value(point.value, &pane.y_axis.formatter),
404 }],
405 });
406}
407
408fn append_marker_tooltip(
409 sections: &mut Vec<TooltipSection>,
410 series: &MarkerSeries,
411 time_ms: EpochMs,
412) {
413 let rows = series
414 .markers
415 .iter()
416 .filter(|marker| distance(marker.time_ms, time_ms) <= 60_000_u64)
417 .map(|marker| TooltipRow {
418 label: "Event".to_string(),
419 value: marker.label.clone(),
420 })
421 .collect::<Vec<_>>();
422 if rows.is_empty() {
423 return;
424 }
425 sections.push(TooltipSection {
426 title: "Signals".to_string(),
427 rows,
428 });
429}
430
431fn nearest_series_time(
432 series: &Series,
433 min_x: EpochMs,
434 max_x: EpochMs,
435 target: EpochMs,
436) -> Option<EpochMs> {
437 match series {
438 Series::Candles(series) => nearest_time_in_sorted(
439 &series
440 .candles
441 .iter()
442 .map(|candle| candle.close_time_ms)
443 .collect::<Vec<_>>(),
444 min_x,
445 max_x,
446 target,
447 ),
448 Series::Bars(series) => nearest_time_in_sorted(
449 &series
450 .bars
451 .iter()
452 .map(|bar| bar.close_time_ms)
453 .collect::<Vec<_>>(),
454 min_x,
455 max_x,
456 target,
457 ),
458 Series::Line(series) => nearest_time_in_sorted(
459 &series
460 .points
461 .iter()
462 .map(|point| point.time_ms)
463 .collect::<Vec<_>>(),
464 min_x,
465 max_x,
466 target,
467 ),
468 Series::Markers(series) => nearest_time_in_sorted(
469 &series
470 .markers
471 .iter()
472 .map(|marker| marker.time_ms)
473 .collect::<Vec<_>>(),
474 min_x,
475 max_x,
476 target,
477 ),
478 }
479}
480
481fn nearest_time_in_sorted(
482 times: &[EpochMs],
483 min_x: EpochMs,
484 max_x: EpochMs,
485 target: EpochMs,
486) -> Option<EpochMs> {
487 let start = lower_bound_time(times, min_x);
488 let end = upper_bound_time(times, max_x);
489 if start >= end {
490 return None;
491 }
492 let local = ×[start..end];
493 nearest_index_by_time(local, target).map(|index| local[index])
494}
495
496fn nearest_index_by_time(times: &[EpochMs], target: EpochMs) -> Option<usize> {
497 if times.is_empty() {
498 return None;
499 }
500 let insertion = lower_bound_time(times, target);
501 if insertion == 0 {
502 return Some(0);
503 }
504 if insertion >= times.len() {
505 return Some(times.len() - 1);
506 }
507 let left = insertion - 1;
508 let right = insertion;
509 Some(
510 if distance(times[left], target) <= distance(times[right], target) {
511 left
512 } else {
513 right
514 },
515 )
516}
517
518fn lower_bound_time(times: &[EpochMs], target: EpochMs) -> usize {
519 times.partition_point(|value| *value < target)
520}
521
522fn upper_bound_time(times: &[EpochMs], target: EpochMs) -> usize {
523 times.partition_point(|value| *value <= target)
524}
525
526fn interpolate_time(min: EpochMs, max: EpochMs, t: f32) -> EpochMs {
527 let min_i = min.as_i64() as f64;
528 let span = max.as_i64().saturating_sub(min.as_i64()) as f64;
529 EpochMs::new((min_i + span * f64::from(t)).round() as i64)
530}
531
532fn format_time(time_ms: EpochMs, fmt: &str) -> String {
533 DateTime::<Utc>::from_timestamp_millis(time_ms.as_i64())
534 .map(|value| value.format(fmt).to_string())
535 .unwrap_or_else(|| "-".to_string())
536}
537
538fn distance(left: EpochMs, right: EpochMs) -> u64 {
539 left.as_i64().abs_diff(right.as_i64())
540}
541
542fn title_case(value: &str) -> String {
543 let mut result = String::new();
544 let mut capitalize = true;
545 for ch in value.chars() {
546 if ch == '-' || ch == '_' || ch == ' ' {
547 result.push(' ');
548 capitalize = true;
549 } else if capitalize {
550 result.extend(ch.to_uppercase());
551 capitalize = false;
552 } else {
553 result.extend(ch.to_lowercase());
554 }
555 }
556 result
557}
558
559#[derive(Debug, Clone, Copy)]
560struct PlotRect {
561 left: f32,
562 right: f32,
563 top: f32,
564 bottom: f32,
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use crate::charting::scene::{
571 ChartScene, LinePoint, LineSeries, Pane, Series, Viewport, YAxisSpec,
572 };
573 use crate::charting::style::{ChartTheme, RgbColor};
574
575 #[test]
576 fn distance_handles_extreme_epoch_values() {
577 let left = EpochMs::new(i64::MIN);
578 let right = EpochMs::new(i64::MAX);
579
580 assert_eq!(distance(left, right), u64::MAX);
581 }
582
583 #[test]
584 fn interpolate_time_saturates_large_spans() {
585 let min = EpochMs::new(i64::MIN);
586 let max = EpochMs::new(i64::MAX);
587
588 let mid = interpolate_time(min, max, 0.5);
589
590 assert!(mid.as_i64() >= min.as_i64());
591 assert!(mid.as_i64() <= max.as_i64());
592 }
593
594 #[test]
595 fn zoom_scene_handles_subsecond_full_span() {
596 let mut scene = ChartScene {
597 title: "test".to_string(),
598 time_label_format: "%H:%M:%S".to_string(),
599 theme: ChartTheme::default(),
600 viewport: Viewport::default(),
601 hover: None,
602 panes: vec![Pane {
603 id: "pane".to_string(),
604 title: None,
605 weight: 1,
606 y_axis: YAxisSpec::default(),
607 series: vec![Series::Line(LineSeries {
608 name: "line".to_string(),
609 color: RgbColor::new(255, 255, 255),
610 width: 1,
611 points: vec![
612 LinePoint {
613 time_ms: EpochMs::new(0),
614 value: 1.0,
615 },
616 LinePoint {
617 time_ms: EpochMs::new(1),
618 value: 2.0,
619 },
620 ],
621 })],
622 }],
623 };
624
625 zoom_scene(&mut scene, 0.5, 1.0);
626
627 assert!(scene.viewport.x_range.is_some());
628 }
629
630 #[test]
631 fn nearest_visible_time_snaps_to_closest_point_in_view() {
632 let pane = Pane {
633 id: "pane".to_string(),
634 title: None,
635 weight: 1,
636 y_axis: YAxisSpec::default(),
637 series: vec![Series::Line(LineSeries {
638 name: "line".to_string(),
639 color: RgbColor::new(255, 255, 255),
640 width: 1,
641 points: vec![
642 LinePoint {
643 time_ms: EpochMs::new(1_000),
644 value: 1.0,
645 },
646 LinePoint {
647 time_ms: EpochMs::new(2_000),
648 value: 2.0,
649 },
650 LinePoint {
651 time_ms: EpochMs::new(3_000),
652 value: 3.0,
653 },
654 ],
655 })],
656 };
657
658 let snapped = nearest_visible_time(
659 &pane,
660 EpochMs::new(1_500),
661 EpochMs::new(3_000),
662 EpochMs::new(2_200),
663 )
664 .expect("snapped time");
665
666 assert_eq!(snapped.as_i64(), 2_000);
667 }
668
669 #[test]
670 fn nearest_index_by_time_uses_binary_search_behavior() {
671 let times = [
672 EpochMs::new(1_000),
673 EpochMs::new(2_000),
674 EpochMs::new(3_000),
675 EpochMs::new(4_000),
676 ];
677
678 let index = nearest_index_by_time(×, EpochMs::new(2_600)).expect("index");
679
680 assert_eq!(index, 2);
681 }
682}