Skip to main content

sandbox_quant/charting/
inspect.rs

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    // When the full dataset spans less than one second, keep the clamp bounds ordered.
58    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 = &times[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(&times, EpochMs::new(2_600)).expect("index");
679
680        assert_eq!(index, 2);
681    }
682}