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_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}