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_points(pane)
311        .map(|(time, _)| time)
312        .filter(|time| *time >= min_x && *time <= max_x)
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(candle) = series
323        .candles
324        .iter()
325        .min_by_key(|candle| distance(candle.close_time_ms, time_ms))
326    else {
327        return;
328    };
329    sections.push(TooltipSection {
330        title: "OHLC".to_string(),
331        rows: vec![
332            TooltipRow {
333                label: "Open".to_string(),
334                value: format_value(candle.open, &pane.y_axis.formatter),
335            },
336            TooltipRow {
337                label: "High".to_string(),
338                value: format_value(candle.high, &pane.y_axis.formatter),
339            },
340            TooltipRow {
341                label: "Low".to_string(),
342                value: format_value(candle.low, &pane.y_axis.formatter),
343            },
344            TooltipRow {
345                label: "Close".to_string(),
346                value: format_value(candle.close, &pane.y_axis.formatter),
347            },
348        ],
349    });
350}
351
352fn append_bar_tooltip(
353    sections: &mut Vec<TooltipSection>,
354    series: &BarSeries,
355    pane: &Pane,
356    time_ms: EpochMs,
357) {
358    let Some(bar) = series
359        .bars
360        .iter()
361        .min_by_key(|bar| distance(bar.close_time_ms, time_ms))
362    else {
363        return;
364    };
365    sections.push(TooltipSection {
366        title: title_case(&series.name),
367        rows: vec![TooltipRow {
368            label: "Value".to_string(),
369            value: format_value(bar.value, &pane.y_axis.formatter),
370        }],
371    });
372}
373
374fn append_line_tooltip(
375    sections: &mut Vec<TooltipSection>,
376    series: &LineSeries,
377    pane: &Pane,
378    time_ms: EpochMs,
379) {
380    let Some(point) = series
381        .points
382        .iter()
383        .min_by_key(|point| distance(point.time_ms, time_ms))
384    else {
385        return;
386    };
387    sections.push(TooltipSection {
388        title: title_case(&series.name),
389        rows: vec![TooltipRow {
390            label: "Value".to_string(),
391            value: format_value(point.value, &pane.y_axis.formatter),
392        }],
393    });
394}
395
396fn append_marker_tooltip(
397    sections: &mut Vec<TooltipSection>,
398    series: &MarkerSeries,
399    time_ms: EpochMs,
400) {
401    let rows = series
402        .markers
403        .iter()
404        .filter(|marker| distance(marker.time_ms, time_ms) <= 60_000_u64)
405        .map(|marker| TooltipRow {
406            label: "Event".to_string(),
407            value: marker.label.clone(),
408        })
409        .collect::<Vec<_>>();
410    if rows.is_empty() {
411        return;
412    }
413    sections.push(TooltipSection {
414        title: "Signals".to_string(),
415        rows,
416    });
417}
418
419fn interpolate_time(min: EpochMs, max: EpochMs, t: f32) -> EpochMs {
420    let min_i = min.as_i64() as f64;
421    let span = max.as_i64().saturating_sub(min.as_i64()) as f64;
422    EpochMs::new((min_i + span * f64::from(t)).round() as i64)
423}
424
425fn format_time(time_ms: EpochMs, fmt: &str) -> String {
426    DateTime::<Utc>::from_timestamp_millis(time_ms.as_i64())
427        .map(|value| value.format(fmt).to_string())
428        .unwrap_or_else(|| "-".to_string())
429}
430
431fn distance(left: EpochMs, right: EpochMs) -> u64 {
432    left.as_i64().abs_diff(right.as_i64())
433}
434
435fn title_case(value: &str) -> String {
436    let mut result = String::new();
437    let mut capitalize = true;
438    for ch in value.chars() {
439        if ch == '-' || ch == '_' || ch == ' ' {
440            result.push(' ');
441            capitalize = true;
442        } else if capitalize {
443            result.extend(ch.to_uppercase());
444            capitalize = false;
445        } else {
446            result.extend(ch.to_lowercase());
447        }
448    }
449    result
450}
451
452#[derive(Debug, Clone, Copy)]
453struct PlotRect {
454    left: f32,
455    right: f32,
456    top: f32,
457    bottom: f32,
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::charting::scene::{
464        ChartScene, LinePoint, LineSeries, Pane, Series, Viewport, YAxisSpec,
465    };
466    use crate::charting::style::{ChartTheme, RgbColor};
467
468    #[test]
469    fn distance_handles_extreme_epoch_values() {
470        let left = EpochMs::new(i64::MIN);
471        let right = EpochMs::new(i64::MAX);
472
473        assert_eq!(distance(left, right), u64::MAX);
474    }
475
476    #[test]
477    fn interpolate_time_saturates_large_spans() {
478        let min = EpochMs::new(i64::MIN);
479        let max = EpochMs::new(i64::MAX);
480
481        let mid = interpolate_time(min, max, 0.5);
482
483        assert!(mid.as_i64() >= min.as_i64());
484        assert!(mid.as_i64() <= max.as_i64());
485    }
486
487    #[test]
488    fn zoom_scene_handles_subsecond_full_span() {
489        let mut scene = ChartScene {
490            title: "test".to_string(),
491            time_label_format: "%H:%M:%S".to_string(),
492            theme: ChartTheme::default(),
493            viewport: Viewport::default(),
494            hover: None,
495            panes: vec![Pane {
496                id: "pane".to_string(),
497                title: None,
498                weight: 1,
499                y_axis: YAxisSpec::default(),
500                series: vec![Series::Line(LineSeries {
501                    name: "line".to_string(),
502                    color: RgbColor::new(255, 255, 255),
503                    width: 1,
504                    points: vec![
505                        LinePoint {
506                            time_ms: EpochMs::new(0),
507                            value: 1.0,
508                        },
509                        LinePoint {
510                            time_ms: EpochMs::new(1),
511                            value: 2.0,
512                        },
513                    ],
514                })],
515            }],
516        };
517
518        zoom_scene(&mut scene, 0.5, 1.0);
519
520        assert!(scene.viewport.x_range.is_some());
521    }
522
523    #[test]
524    fn nearest_visible_time_snaps_to_closest_point_in_view() {
525        let pane = Pane {
526            id: "pane".to_string(),
527            title: None,
528            weight: 1,
529            y_axis: YAxisSpec::default(),
530            series: vec![Series::Line(LineSeries {
531                name: "line".to_string(),
532                color: RgbColor::new(255, 255, 255),
533                width: 1,
534                points: vec![
535                    LinePoint {
536                        time_ms: EpochMs::new(1_000),
537                        value: 1.0,
538                    },
539                    LinePoint {
540                        time_ms: EpochMs::new(2_000),
541                        value: 2.0,
542                    },
543                    LinePoint {
544                        time_ms: EpochMs::new(3_000),
545                        value: 3.0,
546                    },
547                ],
548            })],
549        };
550
551        let snapped = nearest_visible_time(
552            &pane,
553            EpochMs::new(1_500),
554            EpochMs::new(3_000),
555            EpochMs::new(2_200),
556        )
557        .expect("snapped time");
558
559        assert_eq!(snapped.as_i64(), 2_000);
560    }
561}