Skip to main content

flowsurface_data/chart/kline/
viewport.rs

1use super::series::KlineSeries;
2use crate::chart::Basis;
3pub use crate::chart::axis::{
4    PanelYValueRange, PanelYViewportLimits, ZOOM_SENSITIVITY, autoscale_span_limits,
5};
6pub use crate::chart::value::{PanelValueScale, Y_UNIT_STEP_FALLBACK, YUnit, YUnitRange};
7use exchange::{Timeframe, UnixMs};
8
9use std::ops::Range;
10
11pub const DEFAULT_HORIZONTAL_SPACING_PX: f32 = 5.0;
12pub const MIN_HORIZONTAL_SPACING_PX: f32 = 2.0;
13pub const MAX_HORIZONTAL_SPACING_PX: f32 = 360.0;
14
15const BAR_CENTER_OFFSET_UNITS: f32 = 0.5;
16const RIGHT_EDGE_INSET_PX: f32 = 1.0;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
19pub struct ChartStepMs(i64);
20
21impl ChartStepMs {
22    pub fn from_u64(step: u64) -> Self {
23        let clamped = i64::try_from(step).unwrap_or(i64::MAX);
24        Self(clamped.max(1))
25    }
26
27    pub fn get(self) -> i64 {
28        self.0
29    }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub struct ChartCoord(i64);
34
35impl ChartCoord {
36    fn from_u64_clamped(value: u64) -> Self {
37        Self(i64::try_from(value).unwrap_or(i64::MAX))
38    }
39
40    pub fn from_unix_ms(value: UnixMs) -> Self {
41        Self::from_u64_clamped(value.as_u64())
42    }
43
44    pub fn get(self) -> i64 {
45        self.0
46    }
47
48    pub fn saturating_add_i64(self, rhs: i64) -> Self {
49        Self(self.0.saturating_add(rhs))
50    }
51
52    pub fn saturating_sub_i64(self, rhs: i64) -> Self {
53        Self(self.0.saturating_sub(rhs))
54    }
55
56    pub fn to_unix_ms_non_negative(self) -> UnixMs {
57        UnixMs::new(u64::try_from(self.0.max(0)).unwrap_or(u64::MAX))
58    }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct HorizontalWorld {
63    spacing: f32,
64    offset: f32,
65}
66
67impl HorizontalWorld {
68    pub fn visible_window(self, data_bounds: (i64, i64), plot_width: f32) -> (i64, i64, f32) {
69        let (_, data_max_x) = data_bounds;
70
71        let span = self.spacing();
72        let visible_units = (plot_width.max(1.0) / span).max(1.0);
73        let right = data_max_x as f32 + self.offset();
74        let left = right - visible_units + 1.0;
75        let left_unit = left.floor() as i64;
76        let right_unit = right.ceil() as i64;
77
78        if right_unit <= left_unit {
79            return (left_unit, left_unit.saturating_add(1), right);
80        }
81
82        (left_unit, right_unit, right)
83    }
84
85    pub fn new(spacing: f32, offset: f32) -> Self {
86        let spacing = Self::normalized_spacing(spacing);
87
88        Self { spacing, offset }
89    }
90
91    fn normalized_spacing(spacing: f32) -> f32 {
92        if spacing.is_finite() {
93            spacing.clamp(MIN_HORIZONTAL_SPACING_PX, MAX_HORIZONTAL_SPACING_PX)
94        } else {
95            DEFAULT_HORIZONTAL_SPACING_PX
96        }
97    }
98
99    pub fn spacing(self) -> f32 {
100        self.spacing
101    }
102
103    pub fn offset(self) -> f32 {
104        self.offset
105    }
106
107    pub fn with_offset(self, offset: f32) -> Self {
108        Self { offset, ..self }
109    }
110
111    pub fn axis_at_right_unit(self, right_unit: f32) -> WorldAxis {
112        WorldAxis::new(
113            self.spacing,
114            -(right_unit + BAR_CENTER_OFFSET_UNITS) * self.spacing - RIGHT_EDGE_INSET_PX,
115        )
116    }
117
118    pub fn zoom_around_screen(
119        self,
120        current_right_unit: f32,
121        cursor_screen: f32,
122        origin: f32,
123        delta: f32,
124    ) -> Self {
125        let axis = self.axis_at_right_unit(current_right_unit);
126        let spacing = (self.spacing * (delta / ZOOM_SENSITIVITY).exp())
127            .clamp(MIN_HORIZONTAL_SPACING_PX, MAX_HORIZONTAL_SPACING_PX);
128        let cursor_world = axis.screen_to_world(cursor_screen, origin);
129        let translation = cursor_screen - origin - cursor_world * spacing;
130        let next_right_unit =
131            (-translation - RIGHT_EDGE_INSET_PX) / spacing - BAR_CENTER_OFFSET_UNITS;
132
133        Self {
134            spacing,
135            offset: self.offset + next_right_unit - current_right_unit,
136        }
137    }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq)]
141pub struct WorldAxis {
142    spacing: f32,
143    translation: f32,
144}
145
146impl WorldAxis {
147    fn new(spacing: f32, translation: f32) -> Self {
148        Self {
149            spacing: spacing.max(f32::EPSILON),
150            translation,
151        }
152    }
153
154    pub fn spacing(self) -> f32 {
155        self.spacing
156    }
157
158    pub fn world_to_screen(self, world: f32, origin: f32) -> f32 {
159        origin + world * self.spacing + self.translation
160    }
161
162    pub fn screen_to_world(self, screen: f32, origin: f32) -> f32 {
163        (screen - origin - self.translation) / self.spacing
164    }
165
166    pub fn screen_to_logical_index(self, screen: f32, origin: f32) -> f32 {
167        self.screen_to_world(screen, origin) - BAR_CENTER_OFFSET_UNITS
168    }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
172pub struct TickIndex(u64);
173
174impl TickIndex {
175    pub const ZERO: Self = Self(0);
176
177    #[inline]
178    pub fn as_u64(self) -> u64 {
179        self.0
180    }
181
182    pub fn from_u64(value: u64) -> Self {
183        Self(value)
184    }
185}
186
187#[derive(Debug, Clone, Copy)]
188pub enum XAxis {
189    Time {
190        timeframe: Timeframe,
191        anchor: UnixMs,
192    },
193    Tick {
194        anchor: TickIndex,
195    },
196}
197
198impl XAxis {
199    pub fn for_series(basis: Basis, series: &[KlineSeries]) -> Option<Self> {
200        match basis {
201            Basis::Time(timeframe) => {
202                let anchor = series
203                    .iter()
204                    .filter_map(KlineSeries::latest_point)
205                    .map(|point| point.kline.time)
206                    .max()?;
207                Some(Self::Time { timeframe, anchor })
208            }
209            Basis::Tick(_) => series
210                .iter()
211                .any(|series| series.point_count() > 0)
212                .then_some(Self::Tick {
213                    anchor: TickIndex::ZERO,
214                }),
215        }
216    }
217
218    pub fn data_bounds(self, series: &[KlineSeries]) -> Option<(i64, i64)> {
219        match self {
220            Self::Time { .. } => series
221                .iter()
222                .filter_map(|series| {
223                    let first = series.first_point()?.kline.time;
224                    let last = series.latest_point()?.kline.time;
225                    Some((self.unit_from_time(first), self.unit_from_time(last)))
226                })
227                .reduce(|(min, max), (first, last)| (min.min(first), max.max(last))),
228            Self::Tick { .. } => {
229                let point_count = series.iter().map(KlineSeries::point_count).max()?;
230                let last = self.unit_from_tick(TickIndex::ZERO);
231                let first =
232                    self.unit_from_tick(TickIndex::from_u64(point_count.saturating_sub(1) as u64));
233                Some((first, last))
234            }
235        }
236    }
237
238    pub fn visible_window(
239        self,
240        data_bounds: (i64, i64),
241        output_offset: i64,
242        world: HorizontalWorld,
243        plot_width: f32,
244    ) -> (i64, i64, f32) {
245        let (min_extension, max_extension) = self.output_domain_expansion(output_offset);
246        world.visible_window(
247            (
248                data_bounds.0.saturating_add(min_extension),
249                data_bounds.1.saturating_add(max_extension),
250            ),
251            plot_width,
252        )
253    }
254
255    pub fn output_domain_expansion(self, offset: i64) -> (i64, i64) {
256        match self {
257            Self::Time { .. } => (0, offset),
258            Self::Tick { .. } => (offset.saturating_neg(), 0),
259        }
260    }
261
262    #[inline]
263    pub fn unit_from_time(self, value: UnixMs) -> i64 {
264        match self {
265            Self::Time { timeframe, anchor } => {
266                let aligned = ChartCoord::from_unix_ms(value.floor_to(timeframe)).get();
267                let anchor = ChartCoord::from_unix_ms(anchor).get();
268                let step = ChartStepMs::from_u64(timeframe.to_milliseconds().max(1)).get();
269
270                aligned.saturating_sub(anchor) / step
271            }
272            Self::Tick { .. } => 0,
273        }
274    }
275
276    pub fn unit_from_time_f32(self, value: UnixMs) -> Option<f32> {
277        match self {
278            Self::Time { timeframe, anchor } => {
279                let delta_ms = value.as_u64() as f64 - anchor.as_u64() as f64;
280                Some((delta_ms / timeframe.to_milliseconds().max(1) as f64) as f32)
281            }
282            Self::Tick { .. } => None,
283        }
284    }
285
286    #[inline]
287    pub fn unit_from_tick(self, value: TickIndex) -> i64 {
288        match self {
289            Self::Tick { anchor } => {
290                let anchor = ChartCoord::from_u64_clamped(anchor.as_u64()).get();
291                let index = ChartCoord::from_u64_clamped(value.as_u64()).get();
292
293                anchor.saturating_sub(index)
294            }
295            Self::Time { .. } => 0,
296        }
297    }
298
299    #[inline]
300    pub fn time_from_unit(self, unit: i64) -> Option<UnixMs> {
301        match self {
302            Self::Time { timeframe, anchor } => {
303                let step = i64::try_from(timeframe.to_milliseconds()).unwrap_or(i64::MAX);
304                Some(anchor.saturating_add_signed(unit.saturating_mul(step)))
305            }
306            Self::Tick { .. } => None,
307        }
308    }
309
310    pub fn time_from_unit_f32(self, unit: f32) -> Option<UnixMs> {
311        if !unit.is_finite() {
312            return None;
313        }
314
315        match self {
316            Self::Time { timeframe, anchor } => {
317                let offset = (f64::from(unit) * timeframe.to_milliseconds() as f64).round();
318                let offset = offset.clamp(i64::MIN as f64, i64::MAX as f64) as i64;
319                Some(anchor.saturating_add_signed(offset))
320            }
321            Self::Tick { .. } => None,
322        }
323    }
324
325    #[inline]
326    pub fn tick_from_unit(self, unit: i64) -> Option<TickIndex> {
327        match self {
328            Self::Tick { anchor } => {
329                let anchor = anchor.as_u64();
330                if unit >= 0 {
331                    anchor.checked_sub(unit as u64).map(TickIndex)
332                } else {
333                    anchor.checked_add(unit.unsigned_abs()).map(TickIndex)
334                }
335            }
336            Self::Time { .. } => None,
337        }
338    }
339
340    #[inline]
341    pub fn step_ms(self, step_units: i64) -> u64 {
342        match self {
343            Self::Time { timeframe, .. } => {
344                let step = step_units.max(1) as u64;
345                timeframe.to_milliseconds().max(1).saturating_mul(step)
346            }
347            Self::Tick { .. } => 1,
348        }
349    }
350
351    pub fn bucket_start(self, unit: i64) -> Option<UnixMs> {
352        let Self::Time { timeframe, .. } = self else {
353            return None;
354        };
355        Some(self.time_from_unit(unit)?.floor_to(timeframe))
356    }
357
358    pub fn bucket_end(self, unit: i64) -> Option<UnixMs> {
359        let Self::Time { timeframe, .. } = self else {
360            return None;
361        };
362        Some(
363            self.bucket_start(unit)?
364                .saturating_add(timeframe.to_milliseconds().max(1).saturating_sub(1)),
365        )
366    }
367
368    pub fn output_unit(self, source_unit: i64, offset_bars: i64) -> i64 {
369        match self {
370            Self::Time { .. } => source_unit.saturating_add(offset_bars),
371            Self::Tick { .. } => source_unit.saturating_sub(offset_bars),
372        }
373    }
374
375    pub fn source_unit(self, output_unit: i64, offset_bars: i64) -> i64 {
376        match self {
377            Self::Time { .. } => output_unit.saturating_sub(offset_bars),
378            Self::Tick { .. } => output_unit.saturating_add(offset_bars),
379        }
380    }
381
382    pub fn source_window(self, min: i64, max: i64, offsets: &[i64]) -> (i64, i64) {
383        let mut source_min = min;
384        let mut source_max = max;
385        for &offset in offsets {
386            source_min = source_min.min(self.source_unit(min, offset));
387            source_max = source_max.max(self.source_unit(max, offset));
388        }
389        (source_min.saturating_sub(1), source_max.saturating_add(1))
390    }
391}
392
393#[derive(Debug, Clone)]
394pub struct BarUnitWindow {
395    values: Vec<i64>,
396    start_index: usize,
397}
398
399impl BarUnitWindow {
400    pub(crate) fn from_units(values: Vec<i64>, start_index: usize) -> Self {
401        Self {
402            values,
403            start_index,
404        }
405    }
406
407    pub fn values(&self) -> &[i64] {
408        &self.values
409    }
410
411    pub fn start_index(&self) -> usize {
412        self.start_index
413    }
414
415    pub fn global_index(&self, local_index: usize) -> Option<usize> {
416        self.start_index.checked_add(local_index)
417    }
418
419    pub fn global_range(&self, local_range: Range<usize>) -> Range<usize> {
420        self.start_index.saturating_add(local_range.start)
421            ..self.start_index.saturating_add(local_range.end)
422    }
423
424    pub fn unit_at_global_index(&self, index: usize) -> Option<i64> {
425        index
426            .checked_sub(self.start_index)
427            .and_then(|local_index| self.values.get(local_index).copied())
428    }
429
430    pub fn index_range(&self, min: i64, max: i64) -> Range<usize> {
431        self.values.partition_point(|&unit| unit < min)
432            ..self.values.partition_point(|&unit| unit <= max)
433    }
434
435    pub fn output_range(
436        &self,
437        axis: XAxis,
438        min: i64,
439        max: i64,
440        offset: i64,
441        end: usize,
442    ) -> Range<usize> {
443        let values = &self.values[..end];
444        values.partition_point(|&unit| axis.output_unit(unit, offset) < min)
445            ..values.partition_point(|&unit| axis.output_unit(unit, offset) <= max)
446    }
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum PrimaryViewportState {
451    Auto,
452    Manual { range: Option<YUnitRange> },
453}
454
455impl PrimaryViewportState {
456    pub fn is_auto(self) -> bool {
457        matches!(self, Self::Auto)
458    }
459
460    pub fn range(self) -> Option<YUnitRange> {
461        match self {
462            Self::Auto => None,
463            Self::Manual { range } => range,
464        }
465    }
466
467    pub fn disable(&mut self) -> bool {
468        if !self.is_auto() {
469            return false;
470        }
471
472        *self = Self::Manual { range: None };
473        true
474    }
475
476    pub fn set_range(&mut self, range: YUnitRange) -> bool {
477        let next = Self::Manual { range: Some(range) };
478        if *self == next {
479            return false;
480        }
481
482        *self = next;
483        true
484    }
485
486    pub fn reset(&mut self) -> bool {
487        if matches!(self, Self::Auto) {
488            return false;
489        }
490
491        *self = Self::Auto;
492        true
493    }
494
495    pub fn toggle(&mut self) -> bool {
496        if self.is_auto() {
497            *self = Self::Manual { range: None };
498        } else {
499            *self = Self::Auto;
500        }
501        true
502    }
503}