Skip to main content

flowsurface_data/chart/ticks/
y.rs

1use super::MAX_GRID_LINES;
2use crate::util::abbr_large_numbers;
3use exchange::unit::{MinTicksize, Price, PriceStep};
4
5/// The family of Y-axis scales.
6///
7/// A scale owns both how tick *values* are chosen and how values map to
8/// normalized positions.
9#[derive(Debug, Clone, Copy)]
10pub enum YAxisScale {
11    /// Row-aligned price scale: grid steps are snapped to multiples of
12    /// `row_step` so every label lands on a real price row. Values are exact
13    /// atomic units; positions are linear in price.
14    Price { row_step: PriceStep },
15    /// Plain continuous linear scale (indicator values, etc.). Steps are
16    /// "nice"; positions are linear.
17    Linear,
18    /// Percentage scale (comparison chart). Shares the linear grid with
19    /// [`YAxisScale::Linear`] for now, kept distinct so percent-specific step or
20    /// formatting policy has a home.
21    Percent,
22}
23
24/// A concrete grid over a visible range, unified across scale kinds.
25///
26/// Two concrete grids exist: a row-aligned price grid (exact atomic units)
27/// and a plain float grid (continuous values). [`Grid`] exposes the interface
28/// they share ([`Grid::ticks`]) and is what [`YAxisScale`] hands to
29/// [`YTicks::from_grid`] to produce tick lists.
30#[derive(Debug, Clone)]
31enum Grid {
32    /// Row-aligned price grid.
33    Row(RowGrid),
34    /// Plain float grid.
35    Float(FloatGrid),
36}
37
38impl Grid {
39    /// Every grid line as `(value, normalized position)`, ascending by value
40    /// (`0.0` = bottom of the range, `1.0` = top).
41    fn ticks(&self) -> Vec<(TickValue, f64)> {
42        match self {
43            Grid::Row(g) => {
44                let mut out: Vec<_> = g
45                    .values()
46                    .map(|units| (TickValue::PriceUnits(units), g.ratio_of(units)))
47                    .collect();
48                // `RowGrid` iterates top-down; standardize on ascending.
49                out.reverse();
50                out
51            }
52            Grid::Float(g) => g
53                .ticks
54                .iter()
55                .map(|(v, r)| (TickValue::Float(*v), *r))
56                .collect(),
57        }
58    }
59}
60
61/// A row-aligned tick grid over a price range.
62#[derive(Debug, Clone)]
63struct RowGrid {
64    /// Grid step in atomic units, always a positive multiple of `row_step`.
65    step: i64,
66    /// Bottom of the range in atomic units.
67    low: i64,
68    /// Top of the range in atomic units.
69    high: i64,
70    /// `high - low` in atomic units, always positive. Saturates to
71    /// `i64::MAX` when the true range exceeds `i64` (only reachable with
72    /// extreme, saturating prices); exact for every real range.
73    range: i64,
74}
75
76impl RowGrid {
77    fn new(lowest: f64, highest: f64, row_step: PriceStep, labels_can_fit: i32) -> Option<Self> {
78        let row = row_step.units.max(1);
79        let low = Price::from_f64(lowest).units;
80        let high = Price::from_f64(highest).units;
81
82        if high <= low {
83            return None;
84        }
85
86        let range_rows = (high as f64 - low as f64) / row as f64;
87        let budget = labels_can_fit.max(1) as f64;
88        let span_rows = tick_span_min(0.0, range_rows, range_rows / budget, 1.0);
89        let step = (span_rows.ceil() as i64).max(1).saturating_mul(row);
90
91        Self::from_units(low, high, step)
92    }
93
94    /// A grid whose step is exactly `row_step`. Used as a fallback when no
95    /// coarser "nice" grid fits the range (e.g. the visible range is narrower
96    /// than one row step). Returns `None` when the range is empty.
97    fn from_row(lowest: f64, highest: f64, row_step: PriceStep) -> Option<Self> {
98        let row = row_step.units.max(1);
99        let low = Price::from_f64(lowest).units;
100        let high = Price::from_f64(highest).units;
101        if high <= low {
102            return None;
103        }
104        Self::from_units(low, high, row)
105    }
106
107    /// Build a grid from raw bounds and an `i64` step. Returns `None` when no
108    /// grid line can fit the range.
109    fn from_units(low: i64, high: i64, step: i64) -> Option<Self> {
110        let range = high.saturating_sub(low);
111
112        // No grid can fit a range narrower than a single step.
113        if step > range {
114            return None;
115        }
116
117        Some(Self {
118            step,
119            low,
120            high,
121            range,
122        })
123    }
124
125    /// The topmost grid value: the largest multiple of `step` not above
126    /// `high`. `high - (high % step)` cannot overflow for `high >= 0` (all
127    /// real prices); the saturating form keeps pathological inputs from
128    /// panicking.
129    fn top(&self) -> i64 {
130        self.high.saturating_sub(self.high.rem_euclid(self.step))
131    }
132
133    /// Grid values from the top down, in atomic units. The topmost value is
134    /// the largest multiple of `step` at or below `high`; iteration stops
135    /// strictly above `low`.
136    ///
137    /// Always terminates: values strictly decrease by `step >= 1`, stop at
138    /// `low`, and the total count is capped at [`MAX_GRID_LINES`].
139    fn values(&self) -> impl Iterator<Item = i64> + '_ {
140        std::iter::successors(Some(self.top()), move |&v| {
141            v.checked_sub(self.step).filter(|&n| n > self.low)
142        })
143        .take(MAX_GRID_LINES)
144    }
145
146    /// Normalized position of a grid value within `[low, high]`, where `0.0`
147    /// is the bottom of the range and `1.0` the top. Exact for every range
148    /// that fits in `i64`; the saturating fallback only kicks in past it.
149    fn ratio_of(&self, units: i64) -> f64 {
150        units.saturating_sub(self.low) as f64 / self.range as f64
151    }
152}
153
154/// A plain f64 grid over a range with no row alignment (indicator axes,
155/// non-price scales such as percent change).
156#[derive(Debug, Clone)]
157pub struct FloatGrid {
158    /// The selected grid step.
159    step: f64,
160    /// `(value, ratio)` pairs, ascending by value.
161    ticks: Vec<(f64, f64)>,
162}
163
164impl FloatGrid {
165    /// Compute a plain f64 grid (no row alignment) for a range.
166    ///
167    /// Universal across charts: used for indicator axes and for non-price scales
168    /// (e.g. the comparison chart's percent domain), where there is no price row
169    /// to align to.
170    fn new(lowest: f64, highest: f64, labels_can_fit: i32) -> Self {
171        let span = highest - lowest;
172        if !span.is_finite() || span <= 0.0 {
173            return FloatGrid {
174                step: 0.0,
175                ticks: Vec::new(),
176            };
177        }
178
179        let budget = labels_can_fit.max(1) as f64;
180        let step = tick_span_min(0.0, span, span / budget, 0.0);
181
182        if !step.is_finite() || step <= 0.0 || step > span {
183            return FloatGrid {
184                step,
185                ticks: Vec::new(),
186            };
187        }
188
189        let mut value = (highest / step).floor() * step;
190
191        let mut ticks = Vec::with_capacity((budget + 2.0) as usize);
192        let mut safety_counter = 0;
193        while value > lowest && safety_counter < MAX_GRID_LINES {
194            let ratio = (value - lowest) / span;
195            ticks.push((value, ratio));
196            value -= step;
197            safety_counter += 1;
198        }
199
200        // The loop walks top-down; store ascending to match the documented
201        // `ticks` order (bottom-first).
202        ticks.reverse();
203
204        FloatGrid { step, ticks }
205    }
206}
207
208/// A tick value in the scale's natural representation.
209#[derive(Debug, Clone, Copy, PartialEq)]
210pub enum TickValue {
211    /// Exact atomic price units (for [`YAxisScale::Price`]).
212    PriceUnits(i64),
213    /// Continuous float value (for [`YAxisScale::Linear`] and [`YAxisScale::Percent`])
214    Float(f64),
215}
216
217/// A single Y-axis tick: value plus normalized position.
218#[derive(Debug, Clone, Copy)]
219pub struct YTick {
220    pub value: TickValue,
221    /// Normalized position within the visible range: `0.0` = bottom, `1.0` = top.
222    pub ratio: f64,
223}
224
225/// The result of [`YAxisScale::ticks`].
226#[derive(Debug, Clone, Default)]
227pub struct YTicks {
228    /// The chosen grid step for float scales (`Linear`/`Percent`); `None` for
229    /// [`YAxisScale::Price`].
230    pub step: Option<f64>,
231    /// Ticks ascending by value (bottom-first).
232    pub ticks: Vec<YTick>,
233}
234
235impl YTicks {
236    /// Convert any [`Grid`] into ascending ticks.
237    fn from_grid(grid: Grid) -> YTicks {
238        let ticks = grid
239            .ticks()
240            .into_iter()
241            .map(|(value, ratio)| YTick { value, ratio })
242            .collect();
243        let step = match grid {
244            Grid::Float(g) => Some(g.step),
245            Grid::Row(_) => None,
246        };
247        YTicks { step, ticks }
248    }
249}
250
251impl YAxisScale {
252    /// Generate the ticks for a visible range. Returns an empty list when the
253    /// range is empty or no grid fits (callers decide their fallback).
254    pub fn ticks(&self, lowest: f64, highest: f64, labels_can_fit: i32) -> YTicks {
255        match self {
256            YAxisScale::Price { row_step } => {
257                let row = RowGrid::new(lowest, highest, *row_step, labels_can_fit).map(Grid::Row);
258                row.map(YTicks::from_grid).unwrap_or_default()
259            }
260            YAxisScale::Linear | YAxisScale::Percent => {
261                let grid = Grid::Float(FloatGrid::new(lowest, highest, labels_can_fit));
262                YTicks::from_grid(grid)
263            }
264        }
265    }
266
267    /// A `Price` grid at exactly the row step: a dense fallback used when no
268    /// coarser grid fits (e.g. a heatmap that always wants to show rows).
269    /// Returns an empty list for non-`Price` scales or an empty range.
270    fn ticks_from_row(&self, lowest: f64, highest: f64) -> YTicks {
271        let YAxisScale::Price { row_step } = self else {
272            return YTicks::default();
273        };
274
275        let row = RowGrid::from_row(lowest, highest, *row_step).map(Grid::Row);
276        row.map(YTicks::from_grid).unwrap_or_default()
277    }
278}
279
280#[derive(Debug, Clone, Copy)]
281pub struct PriceAxis {
282    /// Effective tick size the grid rows are aligned to.
283    pub row_step: PriceStep,
284    /// Precision used to format label values.
285    pub precision: MinTicksize,
286}
287
288impl PriceAxis {
289    /// Bundle a row step with a label precision. `precision` falls back to the
290    /// precision implied by `row_step` when not given.
291    pub fn new(row_step: PriceStep, precision: Option<MinTicksize>) -> Self {
292        Self {
293            row_step,
294            precision: precision
295                .unwrap_or_else(|| MinTicksize::new(-(row_step.decimal_places() as i8))),
296        }
297    }
298
299    /// Row-aligned ticks over a range; empty when no coarser "nice" grid fits.
300    pub fn ticks(&self, lowest: f64, highest: f64, labels_can_fit: i32) -> YTicks {
301        self.scale().ticks(lowest, highest, labels_can_fit)
302    }
303
304    /// A dense tick grid at exactly the row step (fallback for charts that
305    /// always want to show rows).
306    pub fn ticks_from_row(&self, lowest: f64, highest: f64) -> YTicks {
307        self.scale().ticks_from_row(lowest, highest)
308    }
309
310    /// Format a row value (atomic units) at this axis's precision.
311    pub fn format_units(&self, units: i64) -> String {
312        Price::from_units(units).to_string(self.precision)
313    }
314
315    /// Format a price at this axis's precision.
316    pub fn format(&self, price: Price) -> String {
317        price.to_string(self.precision)
318    }
319
320    /// The row-aligned price scale over this axis.
321    fn scale(&self) -> YAxisScale {
322        YAxisScale::Price {
323            row_step: self.row_step,
324        }
325    }
326
327    /// Convert a canvas y position (pixels) to a price, exact to within one
328    /// atomic unit (1e-11). Only the pixel geometry (`y`, `cell_height`) is
329    /// f32; the base price and row step are exact atomic-unit values, so the
330    /// offset arithmetic runs in f64 and is quantized once at the end.
331    pub fn y_to_price(&self, y: f32, cell_height: f32, min: Price) -> Price {
332        let ticks = f64::from(y) / f64::from(cell_height);
333        let price = min.to_f64() - ticks * self.row_step.to_f64_lossy();
334        Price::from_f64(price)
335    }
336
337    /// Align `value` down to the nearest row multiple, in atomic units.
338    fn floor_to_row(&self, value: f64) -> i64 {
339        let row = self.row_step.units.max(1);
340        let units = Price::from_f64(value).units;
341        units.div_euclid(row) * row
342    }
343}
344
345/// Computes a "nice" span for a range `[low, high]` (both in the same units):
346/// starting from the power of ten at/above the range, it repeatedly divides by
347/// the `dividers` pattern while the span still fits the pixel budget
348/// (`max_span` — the largest span that yields the target label count) and stays
349/// above `min_span`. The final span is always `>= min_span`.
350fn tick_span(low: f64, high: f64, max_span: f64, min_span: f64, dividers: [f64; 3]) -> f64 {
351    const EPS: f64 = 1e-14;
352
353    // The starting power of ten is clamped so extreme ranges can never turn
354    // the walk below into an infinite loop (`10^309` overflows to infinity).
355    let mut span = 10f64.powf((high - low).log10().ceil().clamp(0.0, 308.0));
356    let mut i = 0;
357    loop {
358        let c = dividers[i % dividers.len()];
359        let above_min = span >= min_span - EPS && span > min_span + EPS;
360        let above_budget = span >= max_span * c - EPS;
361        if !(above_min && above_budget) {
362            break;
363        }
364        span /= c;
365        i += 1;
366    }
367    span.max(min_span)
368}
369
370/// The minimum span over three divider patterns, which yields steps from
371/// the `{1, 2, 2.5, 4, 5} * 10^k` family.
372fn tick_span_min(low: f64, high: f64, max_span: f64, min_span: f64) -> f64 {
373    [
374        tick_span(low, high, max_span, min_span, [2.0, 2.5, 2.0]),
375        tick_span(low, high, max_span, min_span, [2.0, 2.0, 2.5]),
376        tick_span(low, high, max_span, min_span, [2.5, 2.0, 2.0]),
377    ]
378    .into_iter()
379    .fold(f64::INFINITY, f64::min)
380}
381
382/// A fully positioned, formatted Y-axis label, ready to be drawn.
383///
384/// UI-agnostic: carries the vertical position and label text, so callers can
385/// map it onto their own rendering primitives.
386#[derive(Debug, Clone, PartialEq)]
387pub struct YTickLabel {
388    /// Vertical position in pixels within the axis height (`0.0` = top).
389    pub y_pos: f32,
390    /// Rendered label text.
391    pub content: String,
392}
393
394impl YTickLabel {
395    /// Compute the Y-axis labels for a price range.
396    ///
397    /// `axis` bundles the chart's effective tick size with the label precision;
398    /// when set, labels are aligned to its rows and formatted at its precision.
399    /// When unset, a plain float grid is used and large numbers are abbreviated.
400    /// Positions are pixels from the top of an axis `height` pixels tall.
401    pub fn for_range(
402        lowest: f64,
403        highest: f64,
404        height: f32,
405        labels_can_fit: i32,
406        axis: Option<PriceAxis>,
407    ) -> Vec<Self> {
408        if !lowest.is_finite() || !highest.is_finite() || highest - lowest <= 0.0 {
409            return Vec::new();
410        }
411
412        if labels_can_fit <= 1 {
413            return match axis {
414                Some(axis) => Self::single_row(highest, axis),
415                None => Self::single_y(highest),
416            };
417        }
418
419        match axis {
420            Some(axis) => Self::row_grid(lowest, highest, height, labels_can_fit, axis),
421            None => Self::float_grid(lowest, highest, height, labels_can_fit),
422        }
423    }
424
425    /// A single label built from a ready-made content string, at the top of the
426    /// axis.
427    fn single(content: String) -> Vec<Self> {
428        vec![Self {
429            y_pos: 0.0,
430            content,
431        }]
432    }
433
434    /// A single fallback label for `value` with abbreviated large-number
435    /// formatting.
436    fn single_y(value: f64) -> Vec<Self> {
437        Self::single(abbr_large_numbers(value))
438    }
439
440    /// A single fallback label aligned down to the nearest price row, formatted
441    /// at the axis precision. Keeps the axis pointing at a real row even when
442    /// there is only room for one label.
443    fn single_row(value: f64, axis: PriceAxis) -> Vec<Self> {
444        let aligned = axis.floor_to_row(value);
445        Self::single(axis.format_units(aligned))
446    }
447
448    /// A grid of labels aligned exactly to the price row grid. Every label
449    /// lands on a real row; the grid math lives in [`PriceAxis::ticks`].
450    fn row_grid(
451        lowest: f64,
452        highest: f64,
453        height: f32,
454        labels_can_fit: i32,
455        axis: PriceAxis,
456    ) -> Vec<Self> {
457        let ticks = axis.ticks(lowest, highest, labels_can_fit);
458        if ticks.ticks.is_empty() {
459            return Self::single_row(highest, axis);
460        }
461
462        let mut labels = Vec::with_capacity((labels_can_fit + 2) as usize);
463        for tick in ticks.ticks {
464            let TickValue::PriceUnits(units) = tick.value else {
465                continue;
466            };
467            labels.push(Self {
468                y_pos: height - tick.ratio as f32 * height,
469                content: axis.format_units(units),
470            });
471        }
472
473        labels
474    }
475
476    /// A grid of labels from a plain f64 range (used for indicator axes that
477    /// have no row grid). Labels use abbreviated large-number formatting. The
478    /// grid values themselves are computed by [`YAxisScale::Linear`].
479    fn float_grid(lowest: f64, highest: f64, height: f32, labels_can_fit: i32) -> Vec<Self> {
480        let ticks = YAxisScale::Linear.ticks(lowest, highest, labels_can_fit);
481        if ticks.ticks.is_empty() {
482            return Self::single_y(highest);
483        }
484
485        let mut labels = Vec::with_capacity((labels_can_fit + 2) as usize);
486        for tick in ticks.ticks {
487            let TickValue::Float(value) = tick.value else {
488                continue;
489            };
490            labels.push(Self {
491                y_pos: height - tick.ratio as f32 * height,
492                content: abbr_large_numbers(value),
493            });
494        }
495
496        labels
497    }
498}